@loops-adk/core 0.1.0 → 0.2.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.
@@ -1,11 +1,12 @@
1
1
  import { redactSecrets } from './chunk-JFTXJ7I2.js';
2
- import { isEngine } from './chunk-XC46B4FD.js';
2
+ import { isEngine } from './chunk-MA6NDQMO.js';
3
3
  import { isLimitError, waitMsFor } from './chunk-Y2SD7GBL.js';
4
4
  import { LoopError } from './chunk-I3STY7U6.js';
5
- import { readFileSync, mkdtempSync, existsSync, writeFileSync, appendFileSync, mkdirSync, rmSync } from 'fs';
5
+ import { readFileSync, mkdtempSync, existsSync, writeFileSync, appendFileSync, readdirSync, mkdirSync, rmSync } from 'fs';
6
6
  import { execa } from 'execa';
7
- import { tmpdir } from 'os';
7
+ import { tmpdir, homedir } from 'os';
8
8
  import { join, dirname } from 'path';
9
+ import { randomBytes } from 'crypto';
9
10
 
10
11
  // src/core/describe.ts
11
12
  var META = /* @__PURE__ */ new WeakMap();
@@ -34,6 +35,19 @@ function describeConditions(input) {
34
35
  return [condLabel(input)];
35
36
  }
36
37
  var count = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
38
+ function renderContract(value) {
39
+ const c = value;
40
+ if (!c || typeof c !== "object") return void 0;
41
+ const bits = [];
42
+ if (c.tier) bits.push(`tier ${c.tier}`);
43
+ if (c.outputs?.length) bits.push(`outputs ${c.outputs.join(", ")}`);
44
+ if (c.capabilities?.length) bits.push(`capabilities ${c.capabilities.join(", ")}`);
45
+ if (c.requiresSkills?.length) bits.push(`requires ${c.requiresSkills.join(", ")}`);
46
+ if (c.usesSkills?.length) bits.push(`uses ${c.usesSkills.join(", ")}`);
47
+ if (c.humanGates?.length) bits.push(`gates ${c.humanGates.join(", ")}`);
48
+ if (c.failureModes?.length) bits.push(`failure modes ${c.failureModes.join(", ")}`);
49
+ return bits.join("; ");
50
+ }
37
51
  function renderPlan(meta, indent = "") {
38
52
  if (!meta) return [`${indent}(a runnable job, shape not introspectable)`];
39
53
  const nm = meta.name ? ` "${meta.name}"` : "";
@@ -68,6 +82,10 @@ function renderPlan(meta, indent = "") {
68
82
  }
69
83
  case "agent":
70
84
  out.push(`${indent}agent${nm}${meta.ground ? " (grounded)" : ""}`);
85
+ {
86
+ const contract = renderContract(meta.contract);
87
+ if (contract) out.push(`${indent} contract: ${contract}`);
88
+ }
71
89
  break;
72
90
  case "fn":
73
91
  out.push(`${indent}fn${nm}`);
@@ -128,12 +146,59 @@ function defineSkill(skill) {
128
146
  if (!skill.instructions?.trim()) throw new Error(`defineSkill "${skill.name}": empty instructions`);
129
147
  return skill;
130
148
  }
149
+ function skillRefName(ref) {
150
+ return typeof ref === "string" ? ref : ref.name;
151
+ }
152
+ function validateName(value, label) {
153
+ if (!value?.trim()) throw new Error(`${label}: \`name\` is required`);
154
+ }
155
+ function validateSkillRef(ref, label) {
156
+ if (typeof ref === "string") {
157
+ if (!ref.trim()) throw new Error(`${label}: empty skill name`);
158
+ return;
159
+ }
160
+ defineSkill(ref);
161
+ }
131
162
  function defineAgent(def) {
132
163
  if (!def.name) throw new Error("defineAgent: `name` is required");
133
164
  if (!def.system?.trim()) throw new Error(`defineAgent "${def.name}": empty system prompt`);
134
165
  def.skills?.forEach((s) => defineSkill(s));
166
+ def.requiresSkills?.forEach(
167
+ (s) => validateSkillRef(s, `defineAgent "${def.name}" requiresSkills`)
168
+ );
169
+ def.usesSkills?.forEach(
170
+ (s) => validateSkillRef(s, `defineAgent "${def.name}" usesSkills`)
171
+ );
172
+ def.outputs?.forEach(
173
+ (o) => validateName(o.name, `defineAgent "${def.name}" outputs`)
174
+ );
175
+ def.humanGates?.forEach(
176
+ (g) => validateName(g.name, `defineAgent "${def.name}" humanGates`)
177
+ );
178
+ def.failureModes?.forEach((f) => {
179
+ if (!f.mode?.trim())
180
+ throw new Error(`defineAgent "${def.name}" failureModes: \`mode\` is required`);
181
+ if (!f.recovery?.trim())
182
+ throw new Error(`defineAgent "${def.name}" failureModes "${f.mode}": \`recovery\` is required`);
183
+ });
135
184
  return def;
136
185
  }
186
+ function agentContract(agent) {
187
+ if (!agent) return void 0;
188
+ const summary = {};
189
+ if (agent.tier) summary.tier = agent.tier;
190
+ if (agent.capabilities?.length) summary.capabilities = [...agent.capabilities];
191
+ if (agent.outputs?.length) summary.outputs = agent.outputs.map((o) => o.name);
192
+ if (agent.requiresSkills?.length)
193
+ summary.requiresSkills = agent.requiresSkills.map(skillRefName);
194
+ if (agent.usesSkills?.length)
195
+ summary.usesSkills = agent.usesSkills.map(skillRefName);
196
+ if (agent.humanGates?.length)
197
+ summary.humanGates = agent.humanGates.map((g) => g.name);
198
+ if (agent.failureModes?.length)
199
+ summary.failureModes = agent.failureModes.map((f) => f.mode);
200
+ return Object.keys(summary).length ? summary : void 0;
201
+ }
137
202
  function resolveSystem(agent) {
138
203
  if (!agent.skills?.length) return agent.system;
139
204
  const methods = agent.skills.map((s) => `### ${s.name}
@@ -927,6 +992,15 @@ function resetLedger(workspace) {
927
992
  reset(ledgerPath(workspace));
928
993
  }
929
994
 
995
+ // src/core/text.ts
996
+ function oneLine(text) {
997
+ return text.replace(/\s+/g, " ").trim();
998
+ }
999
+ function truncate(s, max) {
1000
+ return s.length > max ? `${s.slice(0, max).trimEnd()}
1001
+ \u2026` : s;
1002
+ }
1003
+
930
1004
  // src/core/consolidate.ts
931
1005
  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
1006
  function digest(body, n = 280) {
@@ -965,11 +1039,6 @@ Output the updated consolidated ledger.`,
965
1039
  return result.text.trim();
966
1040
  }
967
1041
  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
1042
  async function compactLedger(ctx, text, opts = {}) {
974
1043
  const trimmed = text.trim();
975
1044
  if (!trimmed) return "";
@@ -1038,10 +1107,6 @@ function consolidateJob(config = {}) {
1038
1107
  }
1039
1108
 
1040
1109
  // src/core/ground.ts
1041
- function truncate2(s, n) {
1042
- return s.length > n ? `${s.slice(0, n).trimEnd()}
1043
- \u2026` : s;
1044
- }
1045
1110
  async function groundingText(workspace, opts = {}) {
1046
1111
  const records = await log({
1047
1112
  cwd: workspace.dir,
@@ -1058,7 +1123,7 @@ What prior iterations already did and why \u2014 read it before working so you d
1058
1123
  const head = `### ${r.sha.slice(0, 7)} ${r.subject}`;
1059
1124
  return r.body ? `${head}
1060
1125
 
1061
- ${truncate2(r.body, bodyChars)}` : head;
1126
+ ${truncate(r.body, bodyChars)}` : head;
1062
1127
  });
1063
1128
  return `${header}
1064
1129
 
@@ -1114,12 +1179,305 @@ Commits a search judged relevant \u2014 read them before working.`;
1114
1179
  const head = `### ${r.sha.slice(0, 7)} ${r.subject}`;
1115
1180
  return r.body ? `${head}
1116
1181
 
1117
- ${truncate2(r.body, bodyChars)}` : head;
1182
+ ${truncate(r.body, bodyChars)}` : head;
1118
1183
  });
1119
1184
  return `${header}
1120
1185
 
1121
1186
  ${entries.join("\n\n")}`;
1122
1187
  }
1188
+ function normalizeFeedbackSeverity(severity) {
1189
+ switch (severity) {
1190
+ case "advisory":
1191
+ return "nice-to-have";
1192
+ case "blocking":
1193
+ case void 0:
1194
+ return "block";
1195
+ default:
1196
+ return severity;
1197
+ }
1198
+ }
1199
+ function isRequiredFeedbackSeverity(severity) {
1200
+ const normalized = normalizeFeedbackSeverity(severity);
1201
+ return normalized === "block" || normalized === "should-fix";
1202
+ }
1203
+ function findingLine(finding) {
1204
+ const reviewer = finding.reviewer ? `${finding.reviewer} ` : "";
1205
+ const severity = normalizeFeedbackSeverity(finding.severity);
1206
+ const decision = finding.decision ? ` Decision: ${finding.decision}.` : "";
1207
+ const recommendation = finding.recommendation ? ` Recommendation: ${oneLine(finding.recommendation)}` : "";
1208
+ return `- ${reviewer}[${severity}]: ${oneLine(finding.evidence)}${decision}${recommendation}`;
1209
+ }
1210
+ function defaultReason(findings) {
1211
+ if (!findings?.length) return "Revision requested";
1212
+ if (findings.length === 1) return oneLine(findings[0].evidence);
1213
+ return `${findings.length} findings require another pass`;
1214
+ }
1215
+ function normalizeRevision(input) {
1216
+ const reason = input.reason?.trim() || defaultReason(input.findings);
1217
+ return {
1218
+ reason,
1219
+ target: input.target,
1220
+ findings: input.findings,
1221
+ rerun: input.rerun ?? (input.target ? "target-and-dependents" : void 0),
1222
+ source: input.source,
1223
+ decision: input.decision
1224
+ };
1225
+ }
1226
+ function revisionRequest(input, over = {}) {
1227
+ const revision = normalizeRevision(input);
1228
+ return {
1229
+ status: over.status ?? "fail",
1230
+ confidence: over.confidence,
1231
+ summary: over.summary ?? revision.reason,
1232
+ data: over.data,
1233
+ error: over.error,
1234
+ revision
1235
+ };
1236
+ }
1237
+ function kickback(to, reason, over = {}) {
1238
+ return revisionRequest({ target: to, reason }, over);
1239
+ }
1240
+ function revisionFromOutcome(outcome) {
1241
+ return outcome.revision;
1242
+ }
1243
+ function feedbackBlock(outcome) {
1244
+ const revision = revisionFromOutcome(outcome);
1245
+ const parts = [
1246
+ "## Feedback to address",
1247
+ "A review or downstream stage requested another pass. Address this before unrelated work."
1248
+ ];
1249
+ if (revision?.target) parts.push(`Target: ${revision.target}`);
1250
+ if (revision?.source) parts.push(`Source: ${revision.source}`);
1251
+ if (revision?.decision) parts.push(`Caller decision: ${revision.decision}`);
1252
+ const reason = revision?.reason ?? outcome.summary;
1253
+ if (reason) parts.push(`Reason: ${reason}`);
1254
+ const findings = revision?.findings ?? [];
1255
+ if (findings.length) {
1256
+ parts.push("Findings:");
1257
+ parts.push(findings.map(findingLine).join("\n"));
1258
+ }
1259
+ return parts.join("\n\n");
1260
+ }
1261
+ function graphPositionBlock(graph) {
1262
+ return [
1263
+ "## Graph position",
1264
+ `DAG: ${graph.dag}`,
1265
+ `Current node: ${graph.node}`,
1266
+ `Path: ${graph.path.join(" > ")}`,
1267
+ `Depends on: ${graph.needs.length ? graph.needs.join(", ") : "none"}`,
1268
+ `Direct dependents: ${graph.dependents.length ? graph.dependents.join(", ") : "none"}`
1269
+ ].join("\n");
1270
+ }
1271
+ async function runReviewer(reviewer, index, ctx) {
1272
+ const name = reviewer.name ?? `reviewer-${index + 1}`;
1273
+ try {
1274
+ if ("job" in reviewer) {
1275
+ const outcome = await reviewer.job(ctx);
1276
+ return {
1277
+ name,
1278
+ met: outcome.status === "pass",
1279
+ confidence: outcome.confidence,
1280
+ reason: outcome.summary ?? outcome.status
1281
+ };
1282
+ }
1283
+ const result = await toCondition(reviewer.review)(
1284
+ ctx,
1285
+ ctx.lastOutcome
1286
+ );
1287
+ return {
1288
+ name,
1289
+ met: result.met,
1290
+ confidence: result.confidence,
1291
+ reason: result.reason
1292
+ };
1293
+ } catch (e) {
1294
+ if (ctx.signal.aborted) throw e;
1295
+ return {
1296
+ name,
1297
+ met: false,
1298
+ reason: `reviewer errored: ${e instanceof Error ? e.message : String(e)}`
1299
+ };
1300
+ }
1301
+ }
1302
+ function reviewFinding(result) {
1303
+ return {
1304
+ reviewer: result.name,
1305
+ severity: "block",
1306
+ evidence: result.reason
1307
+ };
1308
+ }
1309
+ function findingSeverityCounts(findings) {
1310
+ const counts = {};
1311
+ for (const finding of findings) {
1312
+ const severity = normalizeFeedbackSeverity(finding.severity);
1313
+ counts[severity] = (counts[severity] ?? 0) + 1;
1314
+ }
1315
+ return counts;
1316
+ }
1317
+ function reviewPanel(config) {
1318
+ const label = config.label ?? "review-panel";
1319
+ if (!config.reviewers.length)
1320
+ throw new LoopError({
1321
+ code: "CONFIG",
1322
+ message: `reviewPanel "${label}": at least one reviewer is required`
1323
+ });
1324
+ const job = async (ctx) => {
1325
+ ctx.emit({ kind: "job:start", ts: Date.now(), path: [...ctx.path], label });
1326
+ const results = await Promise.all(
1327
+ config.reviewers.map((reviewer, i) => runReviewer(reviewer, i, ctx))
1328
+ );
1329
+ const passedCount = results.filter((r) => r.met).length;
1330
+ const required = config.pass === void 0 || config.pass === "all" ? results.length : config.pass;
1331
+ const findings = results.filter((r) => !r.met).map(reviewFinding);
1332
+ const passed = passedCount >= required;
1333
+ const summaryHead = `Review panel: ${passedCount}/${results.length} reviewer(s) cleared`;
1334
+ const summary = findings.length ? `${summaryHead}.
1335
+ ${findings.map(findingLine).join("\n")}` : `${summaryHead}.`;
1336
+ const scored = results.map((r) => r.confidence).filter((c) => c != null);
1337
+ const confidence = scored.length ? scored.reduce((sum, c) => sum + c, 0) / scored.length : void 0;
1338
+ const data = {
1339
+ findings,
1340
+ results,
1341
+ passed: passedCount,
1342
+ required,
1343
+ severityCounts: findingSeverityCounts(findings)
1344
+ };
1345
+ const outcome = passed ? { status: "pass", summary, confidence, data } : revisionRequest(
1346
+ {
1347
+ target: config.target,
1348
+ // A clean one-line reason. The findings ride the `findings` array, so
1349
+ // feedbackBlock renders them once (not embedded in the reason too) and
1350
+ // the records/tail `reason` stays a single tidy line. The full
1351
+ // multi-line `summary` is kept on the outcome below for logs/TUI.
1352
+ reason: `${summaryHead}.`,
1353
+ findings,
1354
+ rerun: config.rerun
1355
+ },
1356
+ { summary, confidence, data }
1357
+ );
1358
+ ctx.emit({ kind: "job:end", ts: Date.now(), path: [...ctx.path], label, outcome });
1359
+ return outcome;
1360
+ };
1361
+ return setMeta(job, { kind: "reviewPanel", name: label });
1362
+ }
1363
+ async function gitOutput(cwd, args, signal) {
1364
+ const out = await execa("git", args, {
1365
+ cwd,
1366
+ reject: false,
1367
+ stripFinalNewline: false,
1368
+ cancelSignal: signal
1369
+ });
1370
+ return out.stdout.trim();
1371
+ }
1372
+ async function resolveFiles(ctx, patterns) {
1373
+ const fromGit = await gitOutput(
1374
+ ctx.workspace.dir,
1375
+ ["ls-files", "--", ...patterns],
1376
+ ctx.signal
1377
+ ).catch(() => "");
1378
+ const files = fromGit ? fromGit.split("\n").filter(Boolean) : [];
1379
+ if (files.length) return files;
1380
+ return patterns.filter((p) => existsSync(join(ctx.workspace.dir, p)));
1381
+ }
1382
+ function reviewContext(config) {
1383
+ return async (ctx, last) => {
1384
+ const max = config.maxChars ?? 6e3;
1385
+ const buildTests = async () => {
1386
+ if (!config.tests) return [];
1387
+ if (config.tests === true) {
1388
+ const lines = [];
1389
+ if (last?.status) lines.push(`Last outcome status: ${last.status}`);
1390
+ if (last?.summary) lines.push(`Last outcome summary: ${last.summary}`);
1391
+ if (last?.data !== void 0) {
1392
+ let rendered;
1393
+ try {
1394
+ rendered = JSON.stringify(last.data, null, 2);
1395
+ } catch {
1396
+ rendered = String(last.data);
1397
+ }
1398
+ lines.push(`Last outcome data: ${rendered}`);
1399
+ }
1400
+ return lines.length ? [`## Test and outcome context
1401
+
1402
+ ${lines.join("\n")}`] : [];
1403
+ }
1404
+ const cwd = config.tests.cwd ?? ctx.workspace.dir;
1405
+ const result = await execa(
1406
+ config.tests.command,
1407
+ config.tests.args ?? [],
1408
+ { cwd, reject: false, stripFinalNewline: false, cancelSignal: ctx.signal }
1409
+ ).catch((e) => {
1410
+ if (ctx.signal.aborted) throw e;
1411
+ return {
1412
+ exitCode: void 0,
1413
+ stdout: "",
1414
+ stderr: e instanceof Error ? e.message : String(e)
1415
+ };
1416
+ });
1417
+ const exit = result.exitCode ?? "(command did not run)";
1418
+ return [
1419
+ `## Test command
1420
+
1421
+ ${config.tests.command} ${(config.tests.args ?? []).join(" ")}
1422
+
1423
+ exit: ${exit}
1424
+
1425
+ stdout:
1426
+ ${truncate(result.stdout ?? "", max)}
1427
+
1428
+ stderr:
1429
+ ${truncate(result.stderr ?? "", max)}`
1430
+ ];
1431
+ };
1432
+ const buildDiff = async () => {
1433
+ if (!config.diff) return [];
1434
+ const diff2 = await gitOutput(
1435
+ ctx.workspace.dir,
1436
+ ["diff", "HEAD", "--"],
1437
+ ctx.signal
1438
+ ).catch(() => "");
1439
+ return diff2 ? [`## Git diff
1440
+
1441
+ ${truncate(diff2, max)}`] : [];
1442
+ };
1443
+ const buildFiles = async () => {
1444
+ if (!config.files?.length) return [];
1445
+ const files2 = await resolveFiles(ctx, config.files);
1446
+ const out = [];
1447
+ for (const file of files2) {
1448
+ const path = join(ctx.workspace.dir, file);
1449
+ if (!existsSync(path)) continue;
1450
+ out.push(`## File: ${file}
1451
+
1452
+ ${truncate(readFileSync(path, "utf8"), max)}`);
1453
+ }
1454
+ return out;
1455
+ };
1456
+ const buildLedger = async () => {
1457
+ if (!config.ledger) return [];
1458
+ const out = [];
1459
+ const live = [readPrompt(ctx.workspace), readLedger(ctx.workspace)].filter(Boolean).join("\n\n");
1460
+ if (live) out.push(`## Live ledger
1461
+
1462
+ ${truncate(live, max)}`);
1463
+ const committed = await groundingText(ctx.workspace, {
1464
+ max: 5,
1465
+ bodyChars: 1200,
1466
+ signal: ctx.signal
1467
+ }).catch(() => "");
1468
+ if (committed) out.push(truncate(committed, max));
1469
+ return out;
1470
+ };
1471
+ const [tests, diff, files, ledger] = await Promise.all([
1472
+ buildTests(),
1473
+ buildDiff(),
1474
+ buildFiles(),
1475
+ buildLedger()
1476
+ ]);
1477
+ const sections = [...tests, ...diff, ...files, ...ledger];
1478
+ return sections.join("\n\n---\n\n") || "(no review context)";
1479
+ };
1480
+ }
1123
1481
 
1124
1482
  // src/core/job.ts
1125
1483
  var HANDOFF_MARK = "===HANDOFF===";
@@ -1197,6 +1555,16 @@ var TERMINAL = (text) => ({
1197
1555
  summary: text.trim().slice(0, 280),
1198
1556
  data: text
1199
1557
  });
1558
+ function withOperationalContext(ctx, userPrompt, config) {
1559
+ const parts = [userPrompt];
1560
+ if (config.consumeFeedback && ctx.lastReview) {
1561
+ parts.push(feedbackBlock(ctx.lastReview));
1562
+ }
1563
+ if (config.graphContext && ctx.graph) {
1564
+ parts.push(graphPositionBlock(ctx.graph));
1565
+ }
1566
+ return parts.join("\n\n---\n\n");
1567
+ }
1200
1568
  function agentJob(config) {
1201
1569
  const job = async (ctx) => {
1202
1570
  const path = [...ctx.path];
@@ -1204,7 +1572,8 @@ function agentJob(config) {
1204
1572
  ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
1205
1573
  const engine = ctx.resolveEngine(config.engine);
1206
1574
  const userPrompt = typeof config.prompt === "function" ? await config.prompt(ctx) : config.prompt;
1207
- const prompt = config.ground ? await withGrounding(ctx, userPrompt, config.ground) : userPrompt;
1575
+ const contextualPrompt = withOperationalContext(ctx, userPrompt, config);
1576
+ const prompt = config.ground ? await withGrounding(ctx, contextualPrompt, config.ground) : contextualPrompt;
1208
1577
  const system = config.system !== void 0 ? typeof config.system === "function" ? config.system(ctx) : config.system : config.agent ? resolveSystem(config.agent) : void 0;
1209
1578
  let result;
1210
1579
  const toolUses = /* @__PURE__ */ new Map();
@@ -1305,7 +1674,8 @@ function agentJob(config) {
1305
1674
  return setMeta(job, {
1306
1675
  kind: "agent",
1307
1676
  name: config.label ?? config.agent?.name ?? "agent",
1308
- ground: !!config.ground
1677
+ ground: !!config.ground,
1678
+ contract: agentContract(config.agent)
1309
1679
  });
1310
1680
  }
1311
1681
  function composeWay(ctx, last) {
@@ -1381,14 +1751,6 @@ function commitJob(config) {
1381
1751
  }
1382
1752
  };
1383
1753
  }
1384
- function kickback(to, reason, over) {
1385
- return {
1386
- status: "fail",
1387
- summary: reason,
1388
- ...over,
1389
- kickback: { to, reason }
1390
- };
1391
- }
1392
1754
  function fnJob(label, fn) {
1393
1755
  const job = async (ctx) => {
1394
1756
  const path = [...ctx.path];
@@ -1438,6 +1800,7 @@ function childContext(parent, over) {
1438
1800
  log: parent.log,
1439
1801
  depth: over.depth,
1440
1802
  path: over.path,
1803
+ graph: over.graph ?? parent.graph,
1441
1804
  // Inherit the enclosing iteration by default. A `loop` always passes one
1442
1805
  // explicitly; a `dag`/`sequence` does not, so without this a node nested in a
1443
1806
  // loop would reset to 0 — the "Attempt 0" confound where a retry body could not
@@ -1745,13 +2108,18 @@ function loop(config) {
1745
2108
  iteration
1746
2109
  });
1747
2110
  }
2111
+ const reviewPassed = reviewOutcome.status === "pass";
2112
+ const restartsExhausted = config.maxReviewRestarts != null && consecutiveReviewFails + 1 >= config.maxReviewRestarts;
2113
+ const iterationsRemain = config.max == null || iteration < config.max;
2114
+ const willReenter = !reviewPassed && !restartsExhausted && iterationsRemain;
1748
2115
  parent.emit({
1749
2116
  kind: "loop:review",
1750
2117
  ts: ts(),
1751
2118
  path,
1752
- outcome: reviewOutcome
2119
+ outcome: reviewOutcome,
2120
+ accepted: willReenter
1753
2121
  });
1754
- if (reviewOutcome.status === "pass") {
2122
+ if (reviewPassed) {
1755
2123
  await recordMilestone(ctxAt(iteration, last));
1756
2124
  return finish(
1757
2125
  {
@@ -1769,7 +2137,7 @@ function loop(config) {
1769
2137
  `review did not pass (${reviewOutcome.summary ?? reviewOutcome.status}); re-entering ${config.name}`,
1770
2138
  "warn"
1771
2139
  );
1772
- if (config.maxReviewRestarts != null && consecutiveReviewFails >= config.maxReviewRestarts) {
2140
+ if (restartsExhausted) {
1773
2141
  return finish(
1774
2142
  {
1775
2143
  status: "exhausted",
@@ -1865,14 +2233,14 @@ var EngineRegistry = class {
1865
2233
  this.register(
1866
2234
  "agent-sdk",
1867
2235
  (o) => lazy(
1868
- () => import('./agent-sdk-RF5VJZAT.js').then((m) => new m.AgentSdkEngine(o)),
2236
+ () => import('./agent-sdk-4QJDWM7N.js').then((m) => new m.AgentSdkEngine(o)),
1869
2237
  "agent-sdk"
1870
2238
  )
1871
2239
  );
1872
2240
  this.register(
1873
2241
  "claude-cli",
1874
2242
  (o) => lazy(
1875
- () => import('./claude-cli-U7WEVAOL.js').then((m) => new m.ClaudeCliEngine(o)),
2243
+ () => import('./claude-cli-75AOQUKG.js').then((m) => new m.ClaudeCliEngine(o)),
1876
2244
  "claude-cli"
1877
2245
  )
1878
2246
  );
@@ -1885,7 +2253,7 @@ var EngineRegistry = class {
1885
2253
  );
1886
2254
  this.register(
1887
2255
  "codex",
1888
- (o) => lazy(() => import('./codex-6I5UZ2HM.js').then((m) => new m.CodexEngine(o)), "codex")
2256
+ (o) => lazy(() => import('./codex-LYZF52WL.js').then((m) => new m.CodexEngine(o)), "codex")
1889
2257
  );
1890
2258
  }
1891
2259
  };
@@ -1971,25 +2339,383 @@ var Stats = class {
1971
2339
  return m;
1972
2340
  }
1973
2341
  };
2342
+ function ensureDir2(path) {
2343
+ const dir = dirname(path);
2344
+ if (dir && dir !== ".") mkdirSync(dir, { recursive: true });
2345
+ }
2346
+ function outcomeSummary(outcome) {
2347
+ return {
2348
+ status: outcome.status,
2349
+ summary: outcome.summary,
2350
+ confidence: outcome.confidence
2351
+ };
2352
+ }
2353
+ function strongestFinding(findings) {
2354
+ if (!findings?.length) return void 0;
2355
+ const severities = findings.map((f) => normalizeFeedbackSeverity(f.severity));
2356
+ if (severities.includes("block")) return "block";
2357
+ if (severities.includes("should-fix")) return "should-fix";
2358
+ if (severities.includes("nice-to-have")) return "nice-to-have";
2359
+ if (severities.includes("approve")) return "approve";
2360
+ return void 0;
2361
+ }
2362
+ function emittedRevisionRecord(event, outcome) {
2363
+ const revision = revisionFromOutcome(outcome);
2364
+ return revision ? [
2365
+ {
2366
+ kind: "revision-emitted",
2367
+ ts: event.ts,
2368
+ path: event.path,
2369
+ sourceEvent: "job:end",
2370
+ revision
2371
+ }
2372
+ ] : [];
2373
+ }
2374
+ function semanticRecordsFromEvent(event) {
2375
+ switch (event.kind) {
2376
+ case "job:start":
2377
+ return [
2378
+ {
2379
+ kind: "dispatch",
2380
+ ts: event.ts,
2381
+ path: event.path,
2382
+ unit: "job",
2383
+ label: event.label
2384
+ }
2385
+ ];
2386
+ case "dag:node":
2387
+ if (event.phase === "start")
2388
+ return [
2389
+ {
2390
+ kind: "dispatch",
2391
+ ts: event.ts,
2392
+ path: [...event.path, event.node],
2393
+ unit: "dag-node",
2394
+ node: event.node,
2395
+ attempt: event.attempt
2396
+ }
2397
+ ];
2398
+ return event.outcome ? [
2399
+ {
2400
+ kind: "completion",
2401
+ ts: event.ts,
2402
+ path: [...event.path, event.node],
2403
+ unit: "dag-node",
2404
+ label: event.node,
2405
+ outcome: outcomeSummary(event.outcome),
2406
+ attempt: event.attempt
2407
+ }
2408
+ ] : [];
2409
+ case "job:end":
2410
+ return [
2411
+ {
2412
+ kind: "completion",
2413
+ ts: event.ts,
2414
+ path: event.path,
2415
+ unit: "job",
2416
+ label: event.label,
2417
+ outcome: outcomeSummary(event.outcome)
2418
+ },
2419
+ ...emittedRevisionRecord(event, event.outcome)
2420
+ ];
2421
+ case "loop:review": {
2422
+ if (event.outcome.status === "pass") return [];
2423
+ const revision = revisionFromOutcome(event.outcome);
2424
+ const decision = event.accepted === false ? "rejected" : "accepted";
2425
+ const records = [
2426
+ {
2427
+ kind: "surfacing",
2428
+ ts: event.ts,
2429
+ path: event.path,
2430
+ source: "loop-review",
2431
+ decision,
2432
+ severity: strongestFinding(revision?.findings),
2433
+ reason: revision?.reason ?? event.outcome.summary ?? event.outcome.status
2434
+ }
2435
+ ];
2436
+ if (revision) {
2437
+ records.push({
2438
+ kind: "revision-routed",
2439
+ ts: event.ts,
2440
+ path: event.path,
2441
+ sourceEvent: "loop:review",
2442
+ decision,
2443
+ revision
2444
+ });
2445
+ }
2446
+ return records;
2447
+ }
2448
+ case "loop:end":
2449
+ return [
2450
+ {
2451
+ kind: "completion",
2452
+ ts: event.ts,
2453
+ path: event.path,
2454
+ unit: "loop",
2455
+ outcome: outcomeSummary(event.outcome),
2456
+ iterations: event.iterations
2457
+ }
2458
+ ];
2459
+ case "dag:kickback": {
2460
+ const at = [...event.path, event.to];
2461
+ const decision = event.accepted ? "accepted" : "rejected";
2462
+ return [
2463
+ {
2464
+ kind: "surfacing",
2465
+ ts: event.ts,
2466
+ path: at,
2467
+ source: "dag-kickback",
2468
+ decision,
2469
+ severity: "block",
2470
+ from: event.from,
2471
+ to: event.to,
2472
+ reason: event.reason,
2473
+ note: event.note
2474
+ },
2475
+ {
2476
+ kind: "revision-routed",
2477
+ ts: event.ts,
2478
+ path: at,
2479
+ sourceEvent: "dag:kickback",
2480
+ decision,
2481
+ revision: {
2482
+ target: event.to,
2483
+ reason: event.reason,
2484
+ source: event.from,
2485
+ rerun: event.accepted ? "target-and-dependents" : void 0
2486
+ }
2487
+ }
2488
+ ];
2489
+ }
2490
+ case "dag:end":
2491
+ return [
2492
+ {
2493
+ kind: "completion",
2494
+ ts: event.ts,
2495
+ path: event.path,
2496
+ unit: "dag",
2497
+ outcome: outcomeSummary(event.outcome)
2498
+ }
2499
+ ];
2500
+ default:
2501
+ return [];
2502
+ }
2503
+ }
2504
+ function makeSemanticRecorder(path) {
2505
+ try {
2506
+ ensureDir2(path);
2507
+ writeFileSync(path, "");
2508
+ } catch {
2509
+ return () => {
2510
+ };
2511
+ }
2512
+ return (event) => {
2513
+ const records = semanticRecordsFromEvent(event);
2514
+ if (!records.length) return;
2515
+ try {
2516
+ for (const record of records) {
2517
+ appendFileSync(path, `${JSON.stringify(record)}
2518
+ `);
2519
+ }
2520
+ } catch {
2521
+ }
2522
+ };
2523
+ }
1974
2524
  var NOISE = /* @__PURE__ */ new Set([
1975
2525
  "engine:text",
1976
2526
  "engine:thinking"
1977
2527
  ]);
2528
+ function runsHome() {
2529
+ const base = process.env.LOOPS_HOME ?? join(homedir(), ".loops");
2530
+ return join(base, "runs");
2531
+ }
2532
+ function slug(s) {
2533
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "run";
2534
+ }
2535
+ function newRunId(title) {
2536
+ return `${slug(title)}-${randomBytes(3).toString("hex")}`;
2537
+ }
2538
+ function startSupervisor(input) {
2539
+ const dir = join(runsHome(), input.runId);
2540
+ mkdirSync(dir, { recursive: true });
2541
+ const eventsPath = join(dir, "events.jsonl");
2542
+ const semanticPath = join(dir, "semantic.jsonl");
2543
+ const statusPath = join(dir, "status.json");
2544
+ try {
2545
+ writeFileSync(eventsPath, "");
2546
+ } catch {
2547
+ }
2548
+ const semanticSink = makeSemanticRecorder(semanticPath);
2549
+ const status = {
2550
+ runId: input.runId,
2551
+ pid: process.pid,
2552
+ cwd: input.cwd,
2553
+ title: input.title,
2554
+ startedAt: Date.now(),
2555
+ updatedAt: Date.now(),
2556
+ status: "running",
2557
+ shape: input.shape,
2558
+ live: {
2559
+ path: [],
2560
+ iteration: 0,
2561
+ usage: { inputTokens: 0, outputTokens: 0, calls: 0 }
2562
+ }
2563
+ };
2564
+ const writeStatus = () => {
2565
+ status.updatedAt = Date.now();
2566
+ try {
2567
+ writeFileSync(statusPath, JSON.stringify(status, null, 2));
2568
+ } catch {
2569
+ }
2570
+ };
2571
+ writeStatus();
2572
+ const sink = (event) => {
2573
+ if (!NOISE.has(event.kind)) {
2574
+ try {
2575
+ appendFileSync(eventsPath, `${JSON.stringify(event)}
2576
+ `);
2577
+ } catch {
2578
+ }
2579
+ semanticSink(event);
2580
+ }
2581
+ switch (event.kind) {
2582
+ case "loop:iteration":
2583
+ status.live.path = event.path;
2584
+ status.live.iteration = event.iteration;
2585
+ break;
2586
+ case "loop:condition":
2587
+ status.live.lastGate = {
2588
+ which: event.which,
2589
+ met: event.result.met,
2590
+ confidence: event.result.confidence,
2591
+ reason: event.result.reason
2592
+ };
2593
+ break;
2594
+ case "dag:node":
2595
+ status.live.path = [...event.path, event.node];
2596
+ break;
2597
+ case "loop:end":
2598
+ case "dag:end":
2599
+ case "job:end":
2600
+ status.live.lastOutcome = {
2601
+ status: event.outcome.status,
2602
+ summary: event.outcome.summary
2603
+ };
2604
+ status.live.path = event.path;
2605
+ break;
2606
+ case "engine:usage":
2607
+ status.live.usage.inputTokens += event.usage.inputTokens;
2608
+ status.live.usage.outputTokens += event.usage.outputTokens;
2609
+ status.live.usage.calls += 1;
2610
+ break;
2611
+ }
2612
+ if (!NOISE.has(event.kind)) writeStatus();
2613
+ };
2614
+ const finish = (outcome) => {
2615
+ status.status = outcome.status;
2616
+ status.endedAt = Date.now();
2617
+ status.live.lastOutcome = {
2618
+ status: outcome.status,
2619
+ summary: outcome.summary
2620
+ };
2621
+ writeStatus();
2622
+ };
2623
+ return { runId: input.runId, dir, sink, finish };
2624
+ }
2625
+ function isAlive(pid) {
2626
+ try {
2627
+ process.kill(pid, 0);
2628
+ return true;
2629
+ } catch (e) {
2630
+ return e.code === "EPERM";
2631
+ }
2632
+ }
2633
+ function readRunStatus(runId) {
2634
+ try {
2635
+ const raw = readFileSync(join(runsHome(), runId, "status.json"), "utf8");
2636
+ const s = JSON.parse(raw);
2637
+ s.alive = s.status === "running" ? isAlive(s.pid) : false;
2638
+ return s;
2639
+ } catch {
2640
+ return void 0;
2641
+ }
2642
+ }
2643
+ function listRuns() {
2644
+ const base = runsHome();
2645
+ if (!existsSync(base)) return [];
2646
+ const out = [];
2647
+ for (const id of readdirSync(base)) {
2648
+ const s = readRunStatus(id);
2649
+ if (s) out.push(s);
2650
+ }
2651
+ return out.sort((a, b) => b.startedAt - a.startedAt);
2652
+ }
2653
+ function runEventsPath(runId) {
2654
+ return join(runsHome(), runId, "events.jsonl");
2655
+ }
2656
+ function runSemanticRecordsPath(runId) {
2657
+ return join(runsHome(), runId, "semantic.jsonl");
2658
+ }
2659
+ function formatEvent(event) {
2660
+ const at = event.path.length ? `${event.path.join(" \u203A ")} ` : "";
2661
+ switch (event.kind) {
2662
+ case "loop:start":
2663
+ return `${at}\u25B8 loop${event.max ? ` (max ${event.max})` : ""}`;
2664
+ case "dag:start":
2665
+ return `${at}\u25B8 dag (${event.nodes.length} nodes)`;
2666
+ case "loop:iteration":
2667
+ return `${at}\xB7 iteration ${event.iteration}`;
2668
+ case "loop:condition":
2669
+ return `${at}\xB7 ${event.which} ${event.result.met ? "met" : "not met"}: ${event.result.reason}`;
2670
+ case "loop:review":
2671
+ return `${at}\xB7 review: ${event.outcome.status}`;
2672
+ case "loop:end":
2673
+ return `${at}\u25C2 ${event.outcome.status} (${event.iterations} iter)`;
2674
+ case "dag:node":
2675
+ return `${at}\xB7 node ${event.node}: ${event.phase}${event.outcome ? ` (${event.outcome.status})` : ""}`;
2676
+ case "dag:kickback":
2677
+ return `${at}\u21A9 kickback ${event.accepted ? "accepted" : "rejected"} ${event.from} -> ${event.to}: ${event.reason}${event.note ? ` (${event.note})` : ""}`;
2678
+ case "dag:end":
2679
+ return `${at}\u25C2 dag ${event.outcome.status}`;
2680
+ case "job:start":
2681
+ return `${at}\u2022 ${event.label}`;
2682
+ case "job:end":
2683
+ return `${at}\u2022 ${event.label}: ${event.outcome.status}`;
2684
+ case "engine:tool":
2685
+ return `${at} tool ${event.name} ${event.phase}`;
2686
+ case "engine:usage":
2687
+ return `${at} ${event.model}: ${event.usage.inputTokens}/${event.usage.outputTokens} tok`;
2688
+ case "limit:wait":
2689
+ return `${at}\u23F8 limit ${event.code}: waiting ${Math.round(event.waitMs / 1e3)}s`;
2690
+ case "limit:pause":
2691
+ return `${at}\u23F8 paused (${event.code}): ${event.reason}`;
2692
+ case "log":
2693
+ return `${at}${event.message}`;
2694
+ case "error":
2695
+ return `${at}\u2717 ${event.code}: ${event.message}`;
2696
+ default:
2697
+ return `${at}${event.kind}`;
2698
+ }
2699
+ }
2700
+ var NOISE2 = /* @__PURE__ */ new Set([
2701
+ "engine:text",
2702
+ "engine:thinking"
2703
+ ]);
1978
2704
  var CHECKPOINT_AT = /* @__PURE__ */ new Set([
1979
2705
  "loop:iteration",
1980
2706
  "loop:end",
1981
2707
  "dag:end",
1982
2708
  "job:end"
1983
2709
  ]);
1984
- function ensureDir2(path) {
2710
+ function ensureDir3(path) {
1985
2711
  const dir = dirname(path);
1986
2712
  if (dir && dir !== ".") mkdirSync(dir, { recursive: true });
1987
2713
  }
1988
2714
  function makeRecorder(path) {
1989
- ensureDir2(path);
2715
+ ensureDir3(path);
1990
2716
  writeFileSync(path, "");
1991
2717
  return (event) => {
1992
- if (NOISE.has(event.kind)) return;
2718
+ if (NOISE2.has(event.kind)) return;
1993
2719
  try {
1994
2720
  appendFileSync(path, `${JSON.stringify(event)}
1995
2721
  `);
@@ -1998,14 +2724,14 @@ function makeRecorder(path) {
1998
2724
  };
1999
2725
  }
2000
2726
  function makeCheckpointer(path, state) {
2001
- ensureDir2(path);
2727
+ ensureDir3(path);
2002
2728
  return (event) => {
2003
2729
  if (!CHECKPOINT_AT.has(event.kind)) return;
2004
2730
  flushCheckpoint(path, state);
2005
2731
  };
2006
2732
  }
2007
2733
  function flushCheckpoint(path, state) {
2008
- ensureDir2(path);
2734
+ ensureDir3(path);
2009
2735
  try {
2010
2736
  writeFileSync(path, JSON.stringify({ ts: Date.now(), state }, null, 2));
2011
2737
  } catch {
@@ -2051,10 +2777,23 @@ async function run(job, options = {}) {
2051
2777
  });
2052
2778
  }
2053
2779
  }
2780
+ const dir = options.cwd ?? process.cwd();
2054
2781
  const sinks = [];
2055
2782
  if (options.recordTo) sinks.push(makeRecorder(options.recordTo));
2056
2783
  if (options.checkpoint)
2057
2784
  sinks.push(makeCheckpointer(options.checkpoint, initialState));
2785
+ let supervisor;
2786
+ if (options.supervise) {
2787
+ const shape = jobMeta(job);
2788
+ const title = shape?.name ?? "run";
2789
+ supervisor = startSupervisor({
2790
+ runId: newRunId(title),
2791
+ cwd: dir,
2792
+ title,
2793
+ shape
2794
+ });
2795
+ sinks.push(supervisor.sink);
2796
+ }
2058
2797
  const emit = (event) => {
2059
2798
  stats.record(event);
2060
2799
  if (budget && event.kind === "engine:usage")
@@ -2063,7 +2802,6 @@ async function run(job, options = {}) {
2063
2802
  for (const sink of sinks) sink(event);
2064
2803
  };
2065
2804
  const resolveEngine = (ref) => registry.create(ref, defaultEngine);
2066
- const dir = options.cwd ?? process.cwd();
2067
2805
  const workspace = {
2068
2806
  dir,
2069
2807
  branch: await currentBranch({ cwd: dir, signal: controller.signal })
@@ -2081,18 +2819,21 @@ async function run(job, options = {}) {
2081
2819
  message: `environment "${options.environment.name}" failed to start: ${error.message}`,
2082
2820
  code: error.code
2083
2821
  });
2822
+ const failOutcome = {
2823
+ status: "fail",
2824
+ summary: `environment failed to start: ${error.message}`,
2825
+ error
2826
+ };
2827
+ supervisor?.finish(failOutcome);
2084
2828
  return {
2085
- outcome: {
2086
- status: "fail",
2087
- summary: `environment failed to start: ${error.message}`,
2088
- error
2089
- },
2829
+ outcome: failOutcome,
2090
2830
  stats: stats.snapshot(),
2091
2831
  budget: budget ? {
2092
2832
  limit: budget.limit,
2093
2833
  spent: budget.spent(),
2094
2834
  remaining: budget.remaining()
2095
- } : void 0
2835
+ } : void 0,
2836
+ runId: supervisor?.runId
2096
2837
  };
2097
2838
  }
2098
2839
  }
@@ -2133,6 +2874,7 @@ async function run(job, options = {}) {
2133
2874
  }
2134
2875
  if (outcome.status === "paused" && options.checkpoint)
2135
2876
  flushCheckpoint(options.checkpoint, initialState);
2877
+ supervisor?.finish(outcome);
2136
2878
  return {
2137
2879
  outcome,
2138
2880
  stats: stats.snapshot(),
@@ -2140,7 +2882,8 @@ async function run(job, options = {}) {
2140
2882
  limit: budget.limit,
2141
2883
  spent: budget.spent(),
2142
2884
  remaining: budget.remaining()
2143
- } : void 0
2885
+ } : void 0,
2886
+ runId: supervisor?.runId
2144
2887
  };
2145
2888
  }
2146
2889
  function exitCodeFor(outcome) {
@@ -2158,6 +2901,6 @@ function exitCodeFor(outcome) {
2158
2901
  }
2159
2902
  }
2160
2903
 
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
2904
+ export { Budget, EXIT_PAUSED, EngineRegistry, GhForge, MockForge, Stats, addWorktree, agentCheck, agentContract, 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, feedbackBlock, fnJob, forgeChecks, formatEvent, fromFile, gateJob, graphPositionBlock, groundingText, hasStagedChanges, headSha, isDirty, isForge, isRepo, isRequiredFeedbackSeverity, jobMeta, kickback, ledgerPath, listRuns, log, loop, mergeAbort, mergeBranch, mergeNoCommit, minConfidence, never, normalizeFeedbackSeverity, not, predicate, promptPath, push, quorum, readLedger, readPrompt, readRunStatus, removeWorktree, renderPlan, resetLedger, resetPrompt, resolveSystem, retrieveLedger, reviewContext, reviewPanel, revisionFromOutcome, revisionRequest, run, runEventsPath, runSemanticRecordsPath, runsHome, semanticRecordsFromEvent, setMeta, stageAll, toCondition };
2905
+ //# sourceMappingURL=chunk-WM5QVHM2.js.map
2906
+ //# sourceMappingURL=chunk-WM5QVHM2.js.map