@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +486 -0
  3. package/bin/loops.mjs +16 -0
  4. package/dist/App-3YQS6DXA.js +461 -0
  5. package/dist/App-3YQS6DXA.js.map +1 -0
  6. package/dist/agent-sdk-RF5VJZAT.js +95 -0
  7. package/dist/agent-sdk-RF5VJZAT.js.map +1 -0
  8. package/dist/anthropic-api-XJY6Y4T2.js +131 -0
  9. package/dist/anthropic-api-XJY6Y4T2.js.map +1 -0
  10. package/dist/api.d.ts +949 -0
  11. package/dist/api.js +898 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/chunk-33YIGWNU.js +63 -0
  14. package/dist/chunk-33YIGWNU.js.map +1 -0
  15. package/dist/chunk-3BPU34DE.js +2163 -0
  16. package/dist/chunk-3BPU34DE.js.map +1 -0
  17. package/dist/chunk-CXEPZHSR.js +86 -0
  18. package/dist/chunk-CXEPZHSR.js.map +1 -0
  19. package/dist/chunk-I3STY7U6.js +61 -0
  20. package/dist/chunk-I3STY7U6.js.map +1 -0
  21. package/dist/chunk-JFTXJ7I2.js +18 -0
  22. package/dist/chunk-JFTXJ7I2.js.map +1 -0
  23. package/dist/chunk-XC46B4FD.js +9 -0
  24. package/dist/chunk-XC46B4FD.js.map +1 -0
  25. package/dist/chunk-Y2SD7GBL.js +30 -0
  26. package/dist/chunk-Y2SD7GBL.js.map +1 -0
  27. package/dist/claude-cli-U7WEVAOL.js +124 -0
  28. package/dist/claude-cli-U7WEVAOL.js.map +1 -0
  29. package/dist/codex-6I5UZ2HM.js +60 -0
  30. package/dist/codex-6I5UZ2HM.js.map +1 -0
  31. package/dist/env/command.d.ts +53 -0
  32. package/dist/env/command.js +3 -0
  33. package/dist/env/command.js.map +1 -0
  34. package/dist/env/docker.d.ts +38 -0
  35. package/dist/env/docker.js +33 -0
  36. package/dist/env/docker.js.map +1 -0
  37. package/dist/env/sst.d.ts +39 -0
  38. package/dist/env/sst.js +20 -0
  39. package/dist/env/sst.js.map +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +620 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/types-B4wGVpqo.d.ts +898 -0
  44. package/package.json +100 -0
  45. package/skills/author-loop/SKILL.md +121 -0
package/dist/api.js ADDED
@@ -0,0 +1,898 @@
1
+ import { mergeNoCommit, stageAll, commit, mergeAbort, log, setMeta, jobMeta, isRepo, addWorktree, childContext, composeCommitBody, mergeBranch, removeWorktree, deleteBranch, push, consolidate, toCondition, GhForge } from './chunk-3BPU34DE.js';
2
+ export { Budget, EXIT_PAUSED, EngineRegistry, GhForge, MockForge, Stats, addWorktree, agentCheck, agentJob, all, always, any, appendLedger, appendPrompt, bodyPassed, buildChecksArgs, buildCreateArgs, buildEditArgs, buildMergeArgs, buildViewArgs, 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, stageAll, toCondition } from './chunk-3BPU34DE.js';
3
+ import './chunk-JFTXJ7I2.js';
4
+ export { SUBAGENT_TOOLS, isEngine } from './chunk-XC46B4FD.js';
5
+ import './chunk-Y2SD7GBL.js';
6
+ import { LoopError } from './chunk-I3STY7U6.js';
7
+ export { LoopError } from './chunk-I3STY7U6.js';
8
+ import pLimit from 'p-limit';
9
+ import toposort from 'toposort';
10
+ import { readFileSync, writeFileSync } from 'fs';
11
+ import { join } from 'path';
12
+
13
+ function stripFence(s) {
14
+ const m = /^```[^\n]*\n([\s\S]*?)\n```$/.exec(s.trim());
15
+ return `${(m ? m[1] : s).replace(/\s+$/, "")}
16
+ `;
17
+ }
18
+ function firstLine(s) {
19
+ return s.split("\n").find((l) => l.trim()) ?? "";
20
+ }
21
+ async function mergeSynthesis(ctx, config) {
22
+ const cwd = ctx.workspace.dir;
23
+ const engine = config.engine ? ctx.resolveEngine(config.engine) : ctx.engine;
24
+ const merge = await mergeNoCommit(cwd, config.branch, { signal: ctx.signal });
25
+ try {
26
+ if (!merge.clean) {
27
+ for (const file of merge.conflicted) {
28
+ const conflicted = readFileSync(join(cwd, file), "utf8");
29
+ const out = await engine.run(
30
+ {
31
+ prompt: `Resolve this git merge conflict in \`${file}\`. Combine both sides coherently, preserving the intent of each. Output ONLY the fully resolved file content \u2014 no conflict markers, no commentary, no code fence.
32
+
33
+ ${conflicted}`,
34
+ model: config.model,
35
+ maxTokens: 4e3
36
+ },
37
+ () => {
38
+ },
39
+ ctx.signal
40
+ );
41
+ writeFileSync(join(cwd, file), stripFence(out.text));
42
+ }
43
+ await stageAll({ cwd, signal: ctx.signal });
44
+ }
45
+ const body = await synthesiseBody(ctx, engine, config);
46
+ const sha = await commit(
47
+ {
48
+ subject: config.message ?? `merge: ${config.branch} (synthesis)`,
49
+ body,
50
+ allowEmpty: true
51
+ // a merge commit may have an empty diff after resolution
52
+ },
53
+ { cwd, signal: ctx.signal }
54
+ );
55
+ return { ok: true, conflict: !merge.clean, sha };
56
+ } catch (e) {
57
+ await mergeAbort(cwd, { signal: ctx.signal }).catch(() => {
58
+ });
59
+ throw LoopError.from(e, { code: "BODY", path: ctx.path });
60
+ }
61
+ }
62
+ async function synthesiseBody(ctx, engine, config) {
63
+ const ways = await log({
64
+ cwd: ctx.workspace.dir,
65
+ ref: config.branch,
66
+ max: 8,
67
+ signal: ctx.signal
68
+ });
69
+ const summary = ways.map((w) => `- ${w.subject}${w.body ? `: ${firstLine(w.body)}` : ""}`).join("\n");
70
+ const out = await engine.run(
71
+ {
72
+ prompt: `A branch is being merged. Its commits:
73
+ ${summary || "(none)"}
74
+
75
+ Write a concise MERGE SYNTHESIS for the commit body: what this line of work accomplished, how it integrates, and any tradeoff reconciled. A few sentences, no preamble.`,
76
+ system: "You write merge synthesis commit bodies that capture intent.",
77
+ model: config.model,
78
+ maxTokens: 600
79
+ },
80
+ () => {
81
+ },
82
+ ctx.signal
83
+ );
84
+ return out.text.trim();
85
+ }
86
+
87
+ // src/core/dag.ts
88
+ function slug(s) {
89
+ return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/(^-+|-+$)/g, "") || "node";
90
+ }
91
+ function normalize(node) {
92
+ return typeof node === "function" ? { job: node } : node;
93
+ }
94
+ function dag(config) {
95
+ if (!config.name)
96
+ throw new LoopError({
97
+ code: "CONFIG",
98
+ message: "dag() requires a non-empty name"
99
+ });
100
+ const names = Object.keys(config.nodes);
101
+ const nodes = new Map(
102
+ names.map((n) => [n, normalize(config.nodes[n])])
103
+ );
104
+ const edges = [];
105
+ for (const [name, node] of nodes) {
106
+ for (const dep of node.needs ?? []) {
107
+ if (!nodes.has(dep)) {
108
+ throw new LoopError({
109
+ code: "CONFIG",
110
+ message: `dag "${config.name}": node "${name}" needs unknown node "${dep}"`
111
+ });
112
+ }
113
+ edges.push([dep, name]);
114
+ }
115
+ }
116
+ let order;
117
+ try {
118
+ order = toposort.array(names, edges);
119
+ } catch (e) {
120
+ throw new LoopError({
121
+ code: "CONFIG",
122
+ message: `dag "${config.name}": dependency cycle detected`,
123
+ cause: e
124
+ });
125
+ }
126
+ const stopOnError = config.stopOnError ?? true;
127
+ const maxKickbacks = config.maxKickbacks ?? 0;
128
+ const dependents = new Map(names.map((n) => [n, []]));
129
+ for (const [dep, name] of edges) dependents.get(dep).push(name);
130
+ const ancestorsOf = (name) => {
131
+ const seen = /* @__PURE__ */ new Set();
132
+ const stack = [...nodes.get(name).needs ?? []];
133
+ while (stack.length) {
134
+ const n = stack.pop();
135
+ if (seen.has(n)) continue;
136
+ seen.add(n);
137
+ stack.push(...nodes.get(n).needs ?? []);
138
+ }
139
+ return seen;
140
+ };
141
+ const dirtyFrom = (target) => {
142
+ const seen = /* @__PURE__ */ new Set([target]);
143
+ const stack = [target];
144
+ while (stack.length) {
145
+ const n = stack.pop();
146
+ for (const d of dependents.get(n))
147
+ if (!seen.has(d)) {
148
+ seen.add(d);
149
+ stack.push(d);
150
+ }
151
+ }
152
+ return seen;
153
+ };
154
+ const limitN = config.concurrency && config.concurrency > 0 ? config.concurrency : names.length || 1;
155
+ const job = async (parent) => {
156
+ const path = [...parent.path, config.name];
157
+ const depth = parent.depth + 1;
158
+ const ts = () => Date.now();
159
+ parent.emit({ kind: "dag:start", ts: ts(), path, depth, nodes: names });
160
+ const limit = pLimit(limitN);
161
+ const results = /* @__PURE__ */ new Map();
162
+ const memo = /* @__PURE__ */ new Map();
163
+ let stopped = false;
164
+ const pendingKickback = /* @__PURE__ */ new Map();
165
+ const nodeCtx = (name, workspace, environment) => childContext(parent, {
166
+ depth,
167
+ path: [...path, name],
168
+ workspace,
169
+ environment,
170
+ lastReview: pendingKickback.get(name)
171
+ });
172
+ const mergeLimit = pLimit(1);
173
+ let forkSeq2 = 0;
174
+ const runNodeJob = async (name, node) => {
175
+ const isolated2 = node.isolate ?? config.isolation === "worktree";
176
+ if (!isolated2) return node.job(nodeCtx(name));
177
+ const base = parent.workspace;
178
+ if (!await isRepo({ cwd: base.dir, signal: parent.signal })) {
179
+ parent.log(
180
+ `node "${name}" requested worktree isolation but ${base.dir} is not a git repo; running in the shared workspace`,
181
+ "warn"
182
+ );
183
+ return node.job(nodeCtx(name));
184
+ }
185
+ const branch = `loops/${slug(config.name)}-${slug(name)}-${forkSeq2 += 1}`;
186
+ const wt = await addWorktree(base.dir, {
187
+ branch,
188
+ base: "HEAD",
189
+ signal: parent.signal
190
+ });
191
+ const wtWs = { dir: wt.dir, branch };
192
+ let envHandle;
193
+ try {
194
+ if (config.environment)
195
+ envHandle = await config.environment.up(wtWs, parent.signal);
196
+ const outcome2 = await node.job(nodeCtx(name, wtWs, envHandle));
197
+ if (outcome2.status === "pass") {
198
+ await stageAll({ cwd: wt.dir, signal: parent.signal });
199
+ await commit(
200
+ {
201
+ subject: `chore(${slug(name)}): worktree changes`,
202
+ body: await composeCommitBody(parent, wtWs)
203
+ },
204
+ { cwd: wt.dir, signal: parent.signal }
205
+ );
206
+ const merged = await mergeLimit(
207
+ () => mergeBranch(base.dir, branch, {
208
+ signal: parent.signal,
209
+ message: `merge ${branch} (node ${name})`
210
+ })
211
+ );
212
+ if (!merged.ok) {
213
+ if (config.onConflict !== "synthesize") {
214
+ return {
215
+ status: "fail",
216
+ summary: `node "${name}" landed with a merge conflict; needs resolution`,
217
+ error: new LoopError({
218
+ code: "BODY",
219
+ message: `merge conflict landing node "${name}"`,
220
+ path: [...path, name]
221
+ })
222
+ };
223
+ }
224
+ try {
225
+ await mergeLimit(
226
+ () => mergeSynthesis(parent, {
227
+ branch,
228
+ message: `merge: ${branch} (node ${name}, synthesis)`
229
+ })
230
+ );
231
+ } catch (e) {
232
+ const error = LoopError.from(e, {
233
+ code: "BODY",
234
+ path: [...path, name]
235
+ });
236
+ return {
237
+ status: "fail",
238
+ summary: `node "${name}" merge synthesis failed: ${error.message}`,
239
+ error
240
+ };
241
+ }
242
+ }
243
+ await deleteBranch(base.dir, branch, { signal: parent.signal }).catch(
244
+ () => {
245
+ }
246
+ );
247
+ }
248
+ return outcome2;
249
+ } finally {
250
+ if (envHandle)
251
+ await envHandle.down(parent.signal).catch(() => {
252
+ });
253
+ await removeWorktree(base.dir, wt.dir, {
254
+ signal: parent.signal
255
+ }).catch(() => {
256
+ });
257
+ }
258
+ };
259
+ const record = (name, outcome2, phase) => {
260
+ results.set(name, outcome2);
261
+ parent.emit({
262
+ kind: "dag:node",
263
+ ts: ts(),
264
+ path,
265
+ node: name,
266
+ phase,
267
+ outcome: outcome2
268
+ });
269
+ if (phase === "done" && outcome2.status !== "pass" && nodes.get(name).optional !== true && stopOnError && // A node requesting a kickback is going to be re-run — don't let its
270
+ // (provisional) non-pass abort siblings before the feedback is resolved.
271
+ !(maxKickbacks > 0 && outcome2.kickback)) {
272
+ stopped = true;
273
+ }
274
+ return outcome2;
275
+ };
276
+ const run2 = (name) => {
277
+ const existing = memo.get(name);
278
+ if (existing) return existing;
279
+ const node = nodes.get(name);
280
+ const promise = (async () => {
281
+ try {
282
+ const needs = node.needs ?? [];
283
+ const deps = await Promise.all(needs.map(run2));
284
+ const blocked = needs.some((_, i) => deps[i].status !== "pass");
285
+ if (blocked)
286
+ return record(
287
+ name,
288
+ { status: "aborted", summary: "blocked by a failed dependency" },
289
+ "done"
290
+ );
291
+ if (parent.signal.aborted || stopped)
292
+ return record(
293
+ name,
294
+ { status: "aborted", summary: "aborted before start" },
295
+ "done"
296
+ );
297
+ const result = await limit(
298
+ async () => {
299
+ if (parent.signal.aborted || stopped)
300
+ return {
301
+ outcome: {
302
+ status: "aborted",
303
+ summary: "aborted before start"
304
+ },
305
+ phase: "done"
306
+ };
307
+ if (node.when) {
308
+ const r = await toCondition(node.when)(
309
+ nodeCtx(name),
310
+ void 0
311
+ );
312
+ if (!r.met)
313
+ return {
314
+ outcome: {
315
+ status: "pass",
316
+ summary: `skipped: ${r.reason}`,
317
+ data: { skipped: true }
318
+ },
319
+ phase: "skip"
320
+ };
321
+ }
322
+ parent.emit({
323
+ kind: "dag:node",
324
+ ts: ts(),
325
+ path,
326
+ node: name,
327
+ phase: "start"
328
+ });
329
+ return { outcome: await runNodeJob(name, node), phase: "done" };
330
+ }
331
+ );
332
+ return record(name, result.outcome, result.phase);
333
+ } catch (e) {
334
+ const error = LoopError.from(e, {
335
+ code: "BODY",
336
+ phase: "body",
337
+ path: [...path, name]
338
+ });
339
+ parent.emit({
340
+ kind: "error",
341
+ ts: ts(),
342
+ path: [...path, name],
343
+ message: error.message,
344
+ code: error.code
345
+ });
346
+ return record(
347
+ name,
348
+ { status: "fail", summary: error.message, error },
349
+ "done"
350
+ );
351
+ }
352
+ })();
353
+ memo.set(name, promise);
354
+ return promise;
355
+ };
356
+ await Promise.all(names.map(run2));
357
+ if (maxKickbacks > 0) {
358
+ let used = 0;
359
+ const rejected = /* @__PURE__ */ new Set();
360
+ const emitKickback = (from, to, reason, accepted, note) => parent.emit({
361
+ kind: "dag:kickback",
362
+ ts: ts(),
363
+ path,
364
+ from,
365
+ to,
366
+ reason,
367
+ accepted,
368
+ note
369
+ });
370
+ for (; ; ) {
371
+ const from = order.find(
372
+ (n) => results.get(n)?.kickback && !rejected.has(n)
373
+ );
374
+ if (!from) break;
375
+ const { to, reason } = results.get(from).kickback;
376
+ const allow = nodes.get(from).acceptsKickbackTo;
377
+ const note = !nodes.has(to) ? `unknown node "${to}"` : !ancestorsOf(from).has(to) ? `"${to}" is not an ancestor of "${from}"` : allow && !allow.includes(to) ? `"${from}" does not accept kickback to "${to}"` : void 0;
378
+ if (note) {
379
+ rejected.add(from);
380
+ emitKickback(from, to, reason, false, note);
381
+ continue;
382
+ }
383
+ if (used >= maxKickbacks) {
384
+ emitKickback(
385
+ from,
386
+ to,
387
+ reason,
388
+ false,
389
+ `kickback budget (${maxKickbacks}) exhausted`
390
+ );
391
+ break;
392
+ }
393
+ used += 1;
394
+ emitKickback(from, to, reason, true);
395
+ const dirty = dirtyFrom(to);
396
+ for (const d of dirty) {
397
+ memo.delete(d);
398
+ results.delete(d);
399
+ rejected.delete(d);
400
+ }
401
+ pendingKickback.set(to, {
402
+ status: "fail",
403
+ summary: `Kicked back from "${from}": ${reason}`,
404
+ data: { kickback: true, from }
405
+ });
406
+ stopped = false;
407
+ await Promise.all(names.map(run2));
408
+ }
409
+ }
410
+ const requiredFailed = names.filter(
411
+ (n) => results.get(n)?.status === "fail" && nodes.get(n).optional !== true
412
+ );
413
+ const requiredAborted = names.filter(
414
+ (n) => results.get(n)?.status === "aborted" && nodes.get(n).optional !== true
415
+ );
416
+ const data = Object.fromEntries(results);
417
+ let outcome;
418
+ if (parent.signal.aborted) {
419
+ outcome = {
420
+ status: "aborted",
421
+ summary: `dag "${config.name}" aborted`,
422
+ data
423
+ };
424
+ } else if (requiredFailed.length > 0 || requiredAborted.length > 0) {
425
+ outcome = {
426
+ status: "fail",
427
+ summary: `dag "${config.name}": ${requiredFailed.length + requiredAborted.length} required node(s) did not complete`,
428
+ data
429
+ };
430
+ } else {
431
+ outcome = {
432
+ status: "pass",
433
+ summary: `dag "${config.name}": all ${names.length} node(s) green`,
434
+ data
435
+ };
436
+ }
437
+ parent.emit({ kind: "dag:end", ts: ts(), path, outcome });
438
+ return outcome;
439
+ };
440
+ return setMeta(job, {
441
+ kind: "dag",
442
+ name: config.name,
443
+ nodes: Object.entries(config.nodes).map(([name, v]) => {
444
+ const node = typeof v === "function" ? void 0 : v;
445
+ const nodeJob = node ? node.job : v;
446
+ return {
447
+ name,
448
+ needs: node?.needs ?? [],
449
+ isolate: node?.isolate ?? false,
450
+ job: jobMeta(nodeJob)
451
+ };
452
+ })
453
+ });
454
+ }
455
+ function sequence(name, ...jobs) {
456
+ const nodes = {};
457
+ jobs.forEach((job, i) => {
458
+ nodes[`step-${i}`] = { job, needs: i > 0 ? [`step-${i - 1}`] : [] };
459
+ });
460
+ return dag({ name, nodes, concurrency: 1, stopOnError: true });
461
+ }
462
+ function parallel(name, jobs, concurrency) {
463
+ const record = Array.isArray(jobs) ? Object.fromEntries(jobs.map((j, i) => [`task-${i}`, j])) : jobs;
464
+ return dag({ name, nodes: record, concurrency, stopOnError: false });
465
+ }
466
+ function slug2(s) {
467
+ return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/(^-+|-+$)/g, "") || "x";
468
+ }
469
+ function tournament(config) {
470
+ if (!config.name)
471
+ throw new LoopError({
472
+ code: "CONFIG",
473
+ message: "tournament() requires a non-empty name"
474
+ });
475
+ if (config.n < 1)
476
+ throw new LoopError({ code: "CONFIG", message: "tournament() needs n >= 1" });
477
+ return async (parent) => {
478
+ const path = [...parent.path, config.name];
479
+ const base = parent.workspace;
480
+ parent.emit({
481
+ kind: "job:start",
482
+ ts: Date.now(),
483
+ path,
484
+ label: config.name
485
+ });
486
+ if (!await isRepo({ cwd: base.dir, signal: parent.signal })) {
487
+ const error = new LoopError({
488
+ code: "CONFIG",
489
+ message: `tournament "${config.name}" requires a git repository (cwd: ${base.dir})`
490
+ });
491
+ return { status: "fail", summary: error.message, error };
492
+ }
493
+ const limit = pLimit(config.concurrency ?? config.n);
494
+ const attempts = await Promise.all(
495
+ Array.from(
496
+ { length: config.n },
497
+ (_, i) => limit(async () => {
498
+ const branch = `loops/${slug2(config.name)}-cand-${i}`;
499
+ const wt = await addWorktree(base.dir, {
500
+ branch,
501
+ base: "HEAD",
502
+ signal: parent.signal
503
+ });
504
+ const ws = { dir: wt.dir, branch };
505
+ try {
506
+ const ctx = childContext(parent, {
507
+ depth: parent.depth + 1,
508
+ path: [...path, `#${i}`],
509
+ workspace: ws
510
+ });
511
+ const outcome2 = await config.candidate(i)(ctx);
512
+ await stageAll({ cwd: wt.dir, signal: parent.signal });
513
+ await commit(
514
+ {
515
+ subject: `${config.name}: candidate ${i}`,
516
+ body: await composeCommitBody(ctx, ws)
517
+ },
518
+ { cwd: wt.dir, signal: parent.signal }
519
+ );
520
+ const score = outcome2.status === "pass" ? await config.judge(outcome2, ctx) : -1;
521
+ parent.log(`${config.name} candidate ${i}: score ${score}`);
522
+ return { i, branch, dir: wt.dir, outcome: outcome2, score };
523
+ } catch (e) {
524
+ const error = LoopError.from(e, { code: "BODY", path });
525
+ return {
526
+ i,
527
+ branch,
528
+ dir: wt.dir,
529
+ outcome: { status: "fail", summary: error.message, error },
530
+ score: -1
531
+ };
532
+ }
533
+ })
534
+ )
535
+ );
536
+ const winner = [...attempts].filter((a) => a.outcome.status === "pass" && a.score >= 0).sort((a, b) => b.score - a.score || a.i - b.i)[0];
537
+ let landed = false;
538
+ if (winner) {
539
+ const merged = await mergeBranch(base.dir, winner.branch, {
540
+ signal: parent.signal,
541
+ message: `${config.name}: land candidate ${winner.i} (score ${winner.score})`
542
+ });
543
+ landed = merged.ok;
544
+ }
545
+ for (const a of attempts) {
546
+ await removeWorktree(base.dir, a.dir, { signal: parent.signal }).catch(
547
+ () => {
548
+ }
549
+ );
550
+ if (a !== winner || landed)
551
+ await deleteBranch(base.dir, a.branch, {
552
+ signal: parent.signal
553
+ }).catch(() => {
554
+ });
555
+ }
556
+ const outcome = winner ? {
557
+ status: landed ? "pass" : "fail",
558
+ confidence: winner.outcome.confidence,
559
+ summary: landed ? `tournament "${config.name}": landed candidate ${winner.i} (score ${winner.score}) of ${config.n}` : `tournament "${config.name}": winner ${winner.i} failed to land`,
560
+ data: {
561
+ winner: winner.i,
562
+ score: winner.score,
563
+ scores: attempts.map((a) => ({ i: a.i, score: a.score }))
564
+ }
565
+ } : {
566
+ status: "fail",
567
+ summary: `tournament "${config.name}": no candidate passed`,
568
+ data: { scores: attempts.map((a) => ({ i: a.i, score: a.score })) }
569
+ };
570
+ parent.emit({ kind: "job:end", ts: Date.now(), path, label: config.name, outcome });
571
+ return outcome;
572
+ };
573
+ }
574
+
575
+ // src/core/pr.ts
576
+ function resolveForge(ctx) {
577
+ return ctx.forge ?? new GhForge();
578
+ }
579
+ async function derive(value, ctx, last) {
580
+ if (value === void 0) return void 0;
581
+ return typeof value === "function" ? await value(
582
+ ctx,
583
+ last
584
+ ) : value;
585
+ }
586
+ function pushJob(config = {}) {
587
+ return async (ctx) => {
588
+ const label = config.label ?? "push";
589
+ const path = [...ctx.path];
590
+ ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
591
+ const branch = config.branch ?? ctx.workspace.branch;
592
+ const res = await push({
593
+ cwd: ctx.workspace.dir,
594
+ signal: ctx.signal,
595
+ remote: config.remote,
596
+ branch,
597
+ setUpstream: config.setUpstream,
598
+ force: config.force
599
+ });
600
+ const outcome = res.ok ? { status: "pass", summary: `pushed ${branch ?? "HEAD"}` } : {
601
+ status: "fail",
602
+ summary: `push failed: ${res.output}`,
603
+ error: new LoopError({ code: "BODY", message: res.output })
604
+ };
605
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
606
+ return outcome;
607
+ };
608
+ }
609
+ function pullRequestJob(config = {}) {
610
+ return async (ctx) => {
611
+ const label = config.label ?? "pull-request";
612
+ const path = [...ctx.path];
613
+ ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
614
+ try {
615
+ const branch = ctx.workspace.branch;
616
+ if (!branch)
617
+ throw new LoopError({
618
+ code: "CONFIG",
619
+ message: `pullRequestJob "${label}" needs a branch checked out (detached HEAD or non-repo)`
620
+ });
621
+ const base = config.base ?? "main";
622
+ const last = ctx.lastOutcome;
623
+ if (config.push !== false) {
624
+ const res = await push({
625
+ cwd: ctx.workspace.dir,
626
+ signal: ctx.signal,
627
+ branch,
628
+ ...typeof config.push === "object" ? config.push : {}
629
+ });
630
+ if (!res.ok)
631
+ throw new LoopError({
632
+ code: "BODY",
633
+ message: `push failed: ${res.output}`
634
+ });
635
+ }
636
+ const body = await derive(config.body, ctx, last) ?? await consolidate(ctx, {
637
+ since: base,
638
+ max: config.max ?? 50,
639
+ model: config.model
640
+ });
641
+ const title = await derive(config.title, ctx, last) ?? last?.summary ?? branch;
642
+ const forge = resolveForge(ctx);
643
+ const fopts = { cwd: ctx.workspace.dir, signal: ctx.signal };
644
+ const existing = await forge.viewPr(branch, fopts);
645
+ let pr;
646
+ if (existing) {
647
+ await forge.editPr(existing, { body }, fopts);
648
+ pr = existing;
649
+ } else {
650
+ pr = await forge.createPr(
651
+ { title, body, base, branch, draft: config.draft },
652
+ fopts
653
+ );
654
+ }
655
+ const outcome = {
656
+ status: "pass",
657
+ summary: `${existing ? "updated" : "opened"} PR #${pr.number}`,
658
+ data: { pr }
659
+ };
660
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
661
+ return outcome;
662
+ } catch (e) {
663
+ const error = LoopError.from(e, {
664
+ code: "BODY",
665
+ phase: "body",
666
+ path: ctx.path
667
+ });
668
+ ctx.emit({
669
+ kind: "error",
670
+ ts: Date.now(),
671
+ path,
672
+ message: error.message,
673
+ code: error.code
674
+ });
675
+ const outcome = { status: "fail", summary: error.message, error };
676
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
677
+ return outcome;
678
+ }
679
+ };
680
+ }
681
+ function mergeJob(config = {}) {
682
+ return async (ctx) => {
683
+ const label = config.label ?? "merge";
684
+ const path = [...ctx.path];
685
+ ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
686
+ try {
687
+ const branch = ctx.workspace.branch;
688
+ if (!branch)
689
+ throw new LoopError({
690
+ code: "CONFIG",
691
+ message: `mergeJob "${label}" needs a branch checked out`
692
+ });
693
+ const forge = resolveForge(ctx);
694
+ const fopts = { cwd: ctx.workspace.dir, signal: ctx.signal };
695
+ const pr = await forge.viewPr(branch, fopts);
696
+ if (!pr)
697
+ throw new LoopError({
698
+ code: "CONFIG",
699
+ message: `mergeJob "${label}": no open PR for branch "${branch}" \u2014 run pullRequestJob first`
700
+ });
701
+ if (config.when) {
702
+ const r = await toCondition(config.when)(ctx, ctx.lastOutcome);
703
+ if (!r.met) {
704
+ const outcome2 = {
705
+ status: "fail",
706
+ summary: `merge gate not met: ${r.reason}`,
707
+ data: { pr }
708
+ };
709
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome: outcome2 });
710
+ return outcome2;
711
+ }
712
+ }
713
+ const last = ctx.lastOutcome;
714
+ const base = config.base ?? "main";
715
+ const body = await derive(config.body, ctx, last) ?? await consolidate(ctx, {
716
+ since: base,
717
+ max: config.max ?? 50,
718
+ model: config.model
719
+ });
720
+ const subject = await derive(config.subject, ctx, last);
721
+ await forge.mergePr(pr, {
722
+ ...fopts,
723
+ squash: config.squash,
724
+ auto: config.auto,
725
+ subject,
726
+ body,
727
+ deleteBranch: config.deleteBranch
728
+ });
729
+ const outcome = {
730
+ status: "pass",
731
+ summary: `${config.auto ? "enqueued" : "merged"} PR #${pr.number}`,
732
+ data: { pr }
733
+ };
734
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
735
+ return outcome;
736
+ } catch (e) {
737
+ const error = LoopError.from(e, {
738
+ code: "BODY",
739
+ phase: "body",
740
+ path: ctx.path
741
+ });
742
+ ctx.emit({
743
+ kind: "error",
744
+ ts: Date.now(),
745
+ path,
746
+ message: error.message,
747
+ code: error.code
748
+ });
749
+ const outcome = { status: "fail", summary: error.message, error };
750
+ ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
751
+ return outcome;
752
+ }
753
+ };
754
+ }
755
+ var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "job";
756
+ var mergeLock = pLimit(1);
757
+ var forkSeq = 0;
758
+ function isolated(job, opts = {}) {
759
+ const label = opts.label ?? "isolated";
760
+ return async (parent) => {
761
+ const base = parent.workspace;
762
+ if (!await isRepo({ cwd: base.dir, signal: parent.signal })) {
763
+ parent.log(
764
+ `isolated("${label}") requested a worktree but ${base.dir} is not a git repo; running in the shared workspace`,
765
+ "warn"
766
+ );
767
+ return job(parent);
768
+ }
769
+ const branch = `loops/${slug3(label)}-${forkSeq += 1}`;
770
+ const wt = await addWorktree(base.dir, {
771
+ branch,
772
+ base: "HEAD",
773
+ signal: parent.signal
774
+ });
775
+ const wtWs = { dir: wt.dir, branch };
776
+ try {
777
+ const ctx = childContext(parent, {
778
+ workspace: wtWs,
779
+ depth: parent.depth + 1,
780
+ path: [...parent.path, label]
781
+ });
782
+ const outcome = await job(ctx);
783
+ if (outcome.status === "pass") {
784
+ await stageAll({ cwd: wt.dir, signal: parent.signal });
785
+ await commit(
786
+ {
787
+ subject: `chore(${slug3(label)}): worktree changes`,
788
+ body: await composeCommitBody(ctx, wtWs)
789
+ },
790
+ { cwd: wt.dir, signal: parent.signal }
791
+ );
792
+ const merged = await mergeLock(
793
+ () => mergeBranch(base.dir, branch, {
794
+ signal: parent.signal,
795
+ message: `merge ${branch}`
796
+ })
797
+ );
798
+ if (!merged.ok) {
799
+ if (opts.onConflict !== "synthesize") {
800
+ return {
801
+ status: "fail",
802
+ summary: `isolated("${label}") landed with a merge conflict; needs resolution`,
803
+ error: new LoopError({
804
+ code: "BODY",
805
+ message: `merge conflict landing isolated("${label}")`,
806
+ path: [...parent.path, label]
807
+ })
808
+ };
809
+ }
810
+ try {
811
+ await mergeLock(
812
+ () => mergeSynthesis(parent, {
813
+ branch,
814
+ message: `merge: ${branch} (synthesis)`
815
+ })
816
+ );
817
+ } catch (e) {
818
+ const error = LoopError.from(e, { code: "BODY", path: [...parent.path, label] });
819
+ return {
820
+ status: "fail",
821
+ summary: `isolated("${label}") merge synthesis failed: ${error.message}`,
822
+ error
823
+ };
824
+ }
825
+ }
826
+ await deleteBranch(base.dir, branch, { signal: parent.signal }).catch(() => {
827
+ });
828
+ }
829
+ return outcome;
830
+ } finally {
831
+ await removeWorktree(base.dir, wt.dir, { signal: parent.signal }).catch(() => {
832
+ });
833
+ }
834
+ };
835
+ }
836
+
837
+ // src/engines/mock.ts
838
+ var MockEngine = class {
839
+ constructor(responder) {
840
+ this.responder = responder;
841
+ }
842
+ responder;
843
+ name = "mock";
844
+ async run(req, onEvent, signal) {
845
+ if (signal.aborted) {
846
+ throw Object.assign(new Error("aborted"), { name: "AbortError" });
847
+ }
848
+ const raw = this.responder(req);
849
+ const out = typeof raw === "string" ? { text: raw } : raw;
850
+ const model = out.model ?? "mock";
851
+ const usage = out.usage ?? { inputTokens: 10, outputTokens: 5 };
852
+ if (out.text) onEvent({ type: "text", delta: out.text });
853
+ onEvent({ type: "usage", usage, model });
854
+ return { text: out.text, usage, model, stopReason: "end_turn" };
855
+ }
856
+ };
857
+ function mockVerdict(verdict, confidence, reason = "mock") {
858
+ return new MockEngine(() => JSON.stringify({ verdict, confidence, reason }));
859
+ }
860
+
861
+ // src/env/environment.ts
862
+ function isEnvironment(value) {
863
+ return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.up === "function";
864
+ }
865
+
866
+ // src/env/mock.ts
867
+ var MockEnvironment = class {
868
+ constructor(opts = {}) {
869
+ this.opts = opts;
870
+ }
871
+ opts;
872
+ name = "mock-env";
873
+ upCount = 0;
874
+ downCount = 0;
875
+ async up(workspace) {
876
+ this.upCount += 1;
877
+ const url = typeof this.opts.url === "function" ? this.opts.url(workspace) : this.opts.url ?? `http://localhost/${workspace.branch ?? "main"}`;
878
+ this.opts.onUp?.(workspace);
879
+ const onDown = this.opts.onDown;
880
+ return {
881
+ url,
882
+ env: { BASE_URL: url, ...this.opts.env },
883
+ down: async () => {
884
+ this.downCount += 1;
885
+ onDown?.();
886
+ }
887
+ };
888
+ }
889
+ };
890
+
891
+ // src/api.ts
892
+ function defineJob(job) {
893
+ return job;
894
+ }
895
+
896
+ export { MockEngine, MockEnvironment, dag, defineJob, isEnvironment, isolated, mergeJob, mergeSynthesis, mockVerdict, parallel, pullRequestJob, pushJob, sequence, tournament };
897
+ //# sourceMappingURL=api.js.map
898
+ //# sourceMappingURL=api.js.map