@obversa/runtime 0.2.0 → 0.2.1

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/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { compileGraph } from './chunk-DEW5R23M.js';
2
2
  export { compileGraph } from './chunk-DEW5R23M.js';
3
- import { LoopError, needDecisionsOf, DEFAULT_FANOUT_CONCURRENCY, setMeta, jobMeta, describeConditions, Budget, currentBranch, assertDistinctSeats, seatIdentity, copyJobMeta, predicate, setLabel, resolveEnv, scrubCapture, withConditionPreparation, toCondition, prepareCondition, validateCallbackRequest, reportedUsage, validateIncompleteResultEvidence, engineSelection, validateAgentResult, requireFinalResultText, isRepo, addWorktree, childContext, stageAll, commit, mergeBranch, removeWorktree, deleteBranch, headSha, hasStagedChanges, reviewPanel, createCallbackGate, defineAgent, isInfrastructureError, validateCallbackResponse, gitRoot, captureGitWorkspaceSnapshot, workspaceFingerprint, revisionFromOutcome, agentJob, commandJob, approval, mergeNoCommit, mergeAbort, normalizeFeedbackSeverity, nodeJobContext, outcomeFromAgentText, requireNonEmptyFiles, requireNoFiles, panelReviewers, delegateNodeJob, branchCommits, RECORDED_ENGINE_USAGE, compareGitWorkspaceSnapshots } from './chunk-ZXVFWRW5.js';
4
- export { LoopError, RECORDED_ENGINE_USAGE, agentCheck, agentJob, all, always, any, approval, bodyPassed, callbackRequestDigest, commandJob, commandSucceeds, copyJobMeta, createCallbackGate, defineAgent, defineSkill, describeConditions, failed, finalResultPart, finalResultText, fnJob, fromFile, gateJob, jobMeta, kickback, minConfidence, never, not, passed, predicate, prove, quorum, renderPlan, reviewContext, reviewPanel, revisionRequest, toCondition, validateAgentResult, validateCallbackRequest, validateCallbackResponse, withEnv } from './chunk-ZXVFWRW5.js';
3
+ import { LoopError, needDecisionsOf, interactionIdentity, DEFAULT_FANOUT_CONCURRENCY, interactionDeclaration, setMeta, jobMeta, describeConditions, Budget, currentBranch, assertDistinctSeats, seatIdentity, copyJobMeta, predicate, setLabel, resolveEnv, scrubCapture, withConditionPreparation, toCondition, prepareCondition, validateCallbackRequest, reportedUsage, validateIncompleteResultEvidence, engineSelection, validateAgentResult, requireFinalResultText, isRepo, addWorktree, childContext, stageAll, commit, mergeBranch, removeWorktree, deleteBranch, headSha, hasStagedChanges, reviewPanel, createCallbackGate, defineAgent, isInfrastructureError, validateCallbackResponse, gitRoot, captureGitWorkspaceSnapshot, workspaceFingerprint, savedInteraction, revisionFromOutcome, checkpointInteraction, jsonSnapshot, outcomeSnapshot, agentJob, humanReview, commandJob, approval, requestInteraction, mergeNoCommit, mergeAbort, normalizeFeedbackSeverity, DEFAULT_INTERACTION, nodeJobContext, outcomeFromAgentText, requireNonEmptyFiles, requireNoFiles, panelReviewers, hasSavedInteraction, fnJob, kickback, delegateNodeJob, branchCommits, RECORDED_ENGINE_USAGE, compareGitWorkspaceSnapshots } from './chunk-G2OIKOUI.js';
4
+ export { LoopError, RECORDED_ENGINE_USAGE, agentCheck, agentJob, all, always, any, approval, bodyPassed, callbackRequestDigest, commandJob, commandSucceeds, copyJobMeta, createCallbackGate, defineAgent, defineSkill, describeConditions, failed, finalResultPart, finalResultText, fnJob, fromFile, gateJob, humanReview, jobMeta, kickback, minConfidence, never, not, passed, predicate, prove, quorum, renderPlan, reviewContext, reviewPanel, revisionRequest, toCondition, validateAgentResult, validateCallbackRequest, validateCallbackResponse, withEnv } from './chunk-G2OIKOUI.js';
5
5
  import { runRuntimeProcess, processText } from './chunk-NIBHM5I5.js';
6
6
  import { loadRunDefinition, StorageError, validateDomainEventBatch, validateNewDomainEvent, validateDomainEventEnvelope, validateDomainEventId, validateArtifactReference, validateStorageId } from './chunk-RZLMX3IA.js';
7
7
  export { StorageError, loadRunDefinition, persistRunDefinition, validateArtifactReference, validateArtifactScope, validateDomainEventBatch, validateDomainEventEnvelope, validateDomainEventId, validateEventStreamRef, validateNewArtifact, validateNewDomainEvent, validateRunDefinition, validateRunStartRecord, validateRunStoragePolicy, validateRunStorageRecord } from './chunk-RZLMX3IA.js';
@@ -14,7 +14,7 @@ import { randomUUID, createHash } from 'crypto';
14
14
  import { SUBAGENT_TOOLS, snapshotApprovalSubject, assertApprovalPermissionsAdmitted, validateAcceptedResultRecord, EngineIncompleteResultError, EngineError, classifyEngineFailure, LANE_DEAD_FAILURES, validateCallbackEvent, ApprovalSubjectError, isEngine, cloneFrozenJson as cloneFrozenJson$1, validateApprovalRecord, digestJson as digestJson$1, GraphExecutionError, validateApprovalSubject, modelIdentity, validateActionDecision } from '@obversa/api';
15
15
  export { ApprovalSubjectError, EngineError, EngineIncompleteResultError, GraphExecutionError, LANE_DEAD_FAILURES, acceptedResultMatches, approvalSubjectDigest, classifyEngineFailure, createApprovalCallbackGate, validateAcceptedResultRecord, validateApprovalRecord, validateCallbackEvent } from '@obversa/api';
16
16
  import { readFileSync, mkdirSync, existsSync, writeFileSync, appendFileSync, lstatSync, realpathSync } from 'fs';
17
- import { mkdir, stat, open, rename, rm, realpath, readFile, lstat } from 'fs/promises';
17
+ import { mkdir, readFile, stat, open, rename, rm, realpath, lstat } from 'fs/promises';
18
18
  import { join, resolve, basename, extname, dirname, isAbsolute, relative, sep } from 'path';
19
19
  import pLimit from 'p-limit';
20
20
  import toposort from 'toposort';
@@ -150,12 +150,13 @@ function stopQuestions(what = "the draft") {
150
150
  },
151
151
  stop_reason: {
152
152
  type: "choice",
153
- instructions: "Are we at the point of diminishing returns? If so, which kind; if not, continue.",
153
+ instructions: "If a person must settle a product decision, choose product_decision. Otherwise, are we at the point of diminishing returns? If so, which kind; if not, continue.",
154
154
  criteria: {
155
155
  holds: `${what} holds for this use case.`,
156
156
  over_polishing: "The remaining findings are taste, nits or edge cases past the bar.",
157
157
  not_converging: "The same class of finding keeps returning, so another round will not fix it.",
158
- continue: "Another round is worth it."
158
+ continue: "Another round is worth it.",
159
+ product_decision: "A person must settle a product decision before this review can continue."
159
160
  }
160
161
  }
161
162
  };
@@ -164,7 +165,7 @@ function judge(seat, opts) {
164
165
  if (!Number.isSafeInteger(opts.cap) || opts.cap < 1) {
165
166
  throw new TypeError("judge cap must be a positive integer");
166
167
  }
167
- return { kind: "judge", seat, cap: opts.cap, questions: opts.questions ?? stopQuestions() };
168
+ return { kind: "judge", seat, cap: opts.cap, questions: opts.questions ?? stopQuestions(), ...opts.interaction ? { interaction: opts.interaction } : {} };
168
169
  }
169
170
  function isJudge(value) {
170
171
  return typeof value === "object" && value !== null && value.kind === "judge";
@@ -183,17 +184,21 @@ function judgeDecision(answers) {
183
184
  const doing = answers.worth_doing?.noul ?? answers.worth_doing?.probability;
184
185
  const holds = answers.holds?.noul ?? answers.holds?.probability;
185
186
  const reason = answers.stop_reason?.choice ?? "unknown";
187
+ if (reason === "product_decision") return { again: false, stop: "product_decision", reason: "the judge requested a product decision" };
188
+ if (reason === "holds" || reason === "over_polishing") {
189
+ return { again: false, stop: "ship", reason: `the judge chose ${reason}` };
190
+ }
186
191
  if (reason !== "unknown" && reason !== "continue") {
187
- return { again: false, reason: `the judge chose ${reason}` };
192
+ return { again: false, stop: "fail", reason: `the judge chose ${reason}` };
188
193
  }
189
194
  if (typeof holds === "number" && holds >= 0.5) {
190
- return { again: false, reason: `the judge says it holds (${holds.toFixed(2)})` };
195
+ return { again: false, stop: "ship", reason: `the judge says it holds (${holds.toFixed(2)})` };
191
196
  }
192
197
  if (typeof doing === "number" && doing < 0.5) {
193
- return { again: false, reason: `the judge says the findings are not worth doing (${doing.toFixed(2)})` };
198
+ return { again: false, stop: "ship", reason: `the judge says the findings are not worth doing (${doing.toFixed(2)})` };
194
199
  }
195
200
  if (typeof worth === "number" && worth < 0.5) {
196
- return { again: false, reason: `the judge says another round is not worth it (${worth.toFixed(2)})` };
201
+ return { again: false, stop: "fail", reason: `the judge says another round is not worth it (${worth.toFixed(2)})` };
197
202
  }
198
203
  return {
199
204
  again: true,
@@ -225,6 +230,29 @@ async function askJudge(cfg, state, ctx, path) {
225
230
  ctx.emit({ kind: "refine:judge", ts: Date.now(), path: [...path], answers, reason: decision.reason });
226
231
  return { answers, decision };
227
232
  }
233
+ async function consultJudge(cfg, initialState, ctx, path, options) {
234
+ let state = initialState;
235
+ let pending = options.pending;
236
+ for (; ; ) {
237
+ if (pending) {
238
+ options.save(state);
239
+ const answer2 = await requestInteraction(
240
+ cfg.interaction ?? DEFAULT_INTERACTION,
241
+ "What product decision should guide this review?",
242
+ jsonSnapshot({
243
+ requester: { path, identity: options.identity, engine: cfg.seat.identity, round: state.round },
244
+ material: state
245
+ }),
246
+ ctx
247
+ );
248
+ if ("paused" in answer2) return { state, paused: answer2.paused };
249
+ state = { ...state, productFeedback: [...state.productFeedback ?? [], answer2.response] };
250
+ }
251
+ const { decision } = await askJudge(cfg, state, ctx, path);
252
+ if (decision.stop !== "product_decision") return { state, decision };
253
+ pending = true;
254
+ }
255
+ }
228
256
 
229
257
  // src/core/dag.ts
230
258
  function slug(s) {
@@ -339,7 +367,7 @@ function dag(config) {
339
367
  return isJudge(budget) ? budget.cap : budget;
340
368
  };
341
369
  const routeKickbacks = config.maxKickbacks !== void 0 && (perTargetBudget || maxKickbacks > 0);
342
- const judgeHistory = /* @__PURE__ */ new Map();
370
+ const identity = interactionIdentity(config);
343
371
  const dependents = new Map(names.map((n) => [n, []]));
344
372
  for (const [dep, name] of edges) dependents.get(dep).push(name);
345
373
  const ancestorsOf = (name) => {
@@ -371,12 +399,17 @@ function dag(config) {
371
399
  const path = [...parent.path, config.name];
372
400
  const depth = parent.depth + 1;
373
401
  const ts = () => Date.now();
374
- const targetCounts = /* @__PURE__ */ new Map();
402
+ const checkpointPath = [...path, "@judge-kickback"];
403
+ const saved = savedInteraction(parent, checkpointPath, identity);
404
+ let pending = saved?.pending;
405
+ const targetCounts = new Map(Object.entries(saved?.targetCounts ?? {}));
406
+ const judgeHistory = new Map(Object.entries(saved?.history ?? {}));
407
+ const productFeedback = new Map(Object.entries(saved?.productFeedback ?? {}));
375
408
  parent.emit({ kind: "dag:start", ts: ts(), path, depth, nodes: names });
376
409
  const limit2 = pLimit(limitN);
377
- const results = /* @__PURE__ */ new Map();
378
- const memo = /* @__PURE__ */ new Map();
379
- const attempts = /* @__PURE__ */ new Map();
410
+ const results = new Map(Object.entries(saved?.results ?? {}));
411
+ const memo = new Map([...results].map(([name, outcome2]) => [name, Promise.resolve(outcome2)]));
412
+ const attempts = new Map(Object.entries(saved?.attempts ?? {}));
380
413
  let stopped = false;
381
414
  const pendingKickback = /* @__PURE__ */ new Map();
382
415
  const nodeContext = (name) => {
@@ -615,8 +648,8 @@ function dag(config) {
615
648
  };
616
649
  await Promise.all(names.map(run2));
617
650
  if (routeKickbacks) {
618
- let used = 0;
619
- const rejected = /* @__PURE__ */ new Set();
651
+ let used = Number(saved?.used ?? 0);
652
+ const rejected = new Set(saved?.rejected ?? []);
620
653
  const emitKickback = (from, to, reason, accepted, count, limit3, note) => parent.emit({
621
654
  kind: "dag:kickback",
622
655
  ts: ts(),
@@ -641,7 +674,7 @@ function dag(config) {
641
674
  const request = revisionFromOutcome(results.get(from));
642
675
  const to = request.target;
643
676
  const { reason } = request;
644
- const count = (targetCounts.get(to) ?? 0) + 1;
677
+ const count = pending?.from === from ? pending.count : (targetCounts.get(to) ?? 0) + 1;
645
678
  targetCounts.set(to, count);
646
679
  const limit3 = targetLimit(to);
647
680
  const allow = nodes.get(from).acceptsKickbackTo;
@@ -657,20 +690,61 @@ function dag(config) {
657
690
  let effectiveReason = reason;
658
691
  if (cfgJudge !== void 0 && withinBudget && !hasBlockFinding(requestFindings)) {
659
692
  const history = judgeHistory.get(to) ?? [];
660
- const state = {
693
+ const state = pending?.from === from ? pending.state : {
694
+ ...productFeedback.has(to) ? { productFeedback: productFeedback.get(to) } : {},
661
695
  latestFindings: requestFindings,
662
696
  rounds: history,
663
697
  round: count,
664
698
  cap: cfgJudge.cap
665
699
  };
666
- const { decision } = await askJudge(cfgJudge, state, parent, path);
700
+ const result = await consultJudge(cfgJudge, state, parent, path, {
701
+ identity: interactionIdentity({ identity, from, to }),
702
+ pending: pending?.from === from,
703
+ save: (questionState) => checkpointInteraction(parent, checkpointPath, identity, jsonSnapshot({
704
+ pending: { from, count, state: questionState },
705
+ results: Object.fromEntries([...results].map(([name, outcome2]) => [name, outcomeSnapshot(outcome2)])),
706
+ attempts: Object.fromEntries(attempts),
707
+ targetCounts: Object.fromEntries(targetCounts),
708
+ history: Object.fromEntries(judgeHistory),
709
+ productFeedback: Object.fromEntries(productFeedback),
710
+ used,
711
+ rejected: [...rejected]
712
+ }))
713
+ });
714
+ if ("paused" in result) {
715
+ record4(from, result.paused, "done");
716
+ break;
717
+ }
718
+ checkpointInteraction(parent, checkpointPath, identity, null);
719
+ pending = void 0;
720
+ productFeedback.set(to, result.state.productFeedback ?? []);
721
+ const { decision } = result;
667
722
  judgeHistory.set(to, [
668
723
  ...history,
669
724
  { round: count, findings: requestFindings, counts: countBySeverity(requestFindings) }
670
725
  ]);
671
726
  if (!decision.again) {
672
- rejected.add(from);
673
727
  emitKickback(from, to, `${reason} (${decision.reason})`, false, count, limit3, decision.reason);
728
+ if (decision.stop === "ship") {
729
+ const shipped = record4(from, {
730
+ status: "pass",
731
+ confidence: results.get(from).confidence,
732
+ summary: decision.reason,
733
+ data: results.get(from).data
734
+ }, "done");
735
+ memo.set(from, Promise.resolve(shipped));
736
+ const unblocked = dirtyFrom(from);
737
+ unblocked.delete(from);
738
+ for (const d of unblocked) {
739
+ memo.delete(d);
740
+ results.delete(d);
741
+ rejected.delete(d);
742
+ }
743
+ stopped = false;
744
+ await Promise.all(names.map(run2));
745
+ continue;
746
+ }
747
+ rejected.add(from);
674
748
  continue;
675
749
  }
676
750
  effectiveReason = `${reason} (${decision.reason})`;
@@ -750,7 +824,7 @@ function dag(config) {
750
824
  parent.emit({ kind: "dag:end", ts: ts(), path, outcome });
751
825
  return outcome;
752
826
  };
753
- return setMeta(job, {
827
+ return interactionDeclaration(setMeta(job, {
754
828
  kind: "dag",
755
829
  name: config.name,
756
830
  ...config.maxKickbacks !== void 0 ? { maxKickbacks: config.maxKickbacks } : {},
@@ -771,7 +845,7 @@ function dag(config) {
771
845
  job: jobMeta(nodeJob)
772
846
  };
773
847
  })
774
- });
848
+ }), config);
775
849
  }
776
850
  function sequence(name, ...jobs) {
777
851
  const nodes = {};
@@ -944,9 +1018,15 @@ function loop(config) {
944
1018
  const path = [...parent.path, config.name];
945
1019
  const depth = parent.depth + 1;
946
1020
  const ts = () => Date.now();
947
- let lastReview;
948
- let lastGate;
949
- let iteration = 0;
1021
+ const checkpointPath = [...path, "@interaction-loop"];
1022
+ const identity = interactionIdentity({ config, params: parent.params });
1023
+ const saved = savedInteraction(parent, checkpointPath, identity);
1024
+ let checkpointed = saved !== void 0;
1025
+ let resumePhase = saved?.phase;
1026
+ let lastReview = saved?.lastReview ?? parent.lastReview;
1027
+ let lastGate = saved?.lastGate;
1028
+ let iteration = typeof saved?.iteration === "number" ? saved.iteration : 0;
1029
+ if (resumePhase === "body") iteration -= 1;
950
1030
  const ctxAt = (iter, lastOutcome) => childContext(parent, {
951
1031
  depth,
952
1032
  path,
@@ -957,6 +1037,7 @@ function loop(config) {
957
1037
  });
958
1038
  parent.emit({ kind: "loop:start", ts: ts(), path, depth, max: config.max });
959
1039
  const finish = async (outcome, iterations) => {
1040
+ if (checkpointed && outcome.status !== "paused" && outcome.status !== "aborted") checkpointInteraction(parent, checkpointPath, identity, null);
960
1041
  parent.emit({ kind: "loop:end", ts: ts(), path, outcome, iterations });
961
1042
  if (config.onComplete) {
962
1043
  try {
@@ -1003,7 +1084,7 @@ function loop(config) {
1003
1084
  config.until ? prepareCondition(config.until, entryContext) : void 0,
1004
1085
  config.stopOn ? prepareCondition(config.stopOn, entryContext) : void 0
1005
1086
  ]);
1006
- if (start) {
1087
+ if (start && !saved) {
1007
1088
  const r = await gate(start, "start", ctxAt(0), void 0);
1008
1089
  parent.emit({
1009
1090
  kind: "loop:condition",
@@ -1019,11 +1100,30 @@ function loop(config) {
1019
1100
  0
1020
1101
  );
1021
1102
  }
1022
- let last;
1023
- let consecutiveErrors = 0;
1024
- let consecutiveReviewFails = 0;
1103
+ let last = saved?.last;
1104
+ let consecutiveErrors = Number(saved?.consecutiveErrors ?? 0);
1105
+ let consecutiveReviewFails = Number(saved?.consecutiveReviewFails ?? 0);
1025
1106
  const tracker = noProgress ? new ProgressTracker(noProgress) : void 0;
1107
+ const samples = [...saved?.samples ?? []];
1108
+ for (const sample of samples) tracker?.record(sample);
1026
1109
  let warnedInert = false;
1110
+ const checkpointContext = (ctx, phase) => ({
1111
+ ...ctx,
1112
+ interactionCheckpoint() {
1113
+ checkpointed = true;
1114
+ parent.interactionCheckpoint?.();
1115
+ checkpointInteraction(parent, checkpointPath, identity, jsonSnapshot({
1116
+ phase,
1117
+ iteration,
1118
+ consecutiveErrors,
1119
+ consecutiveReviewFails,
1120
+ samples,
1121
+ ...last ? { last: outcomeSnapshot(last) } : {},
1122
+ ...lastReview ? { lastReview: outcomeSnapshot(lastReview) } : {},
1123
+ ...lastGate ? { lastGate: Object.fromEntries(Object.entries(lastGate).filter(([, value]) => value !== void 0)) } : {}
1124
+ }));
1125
+ }
1126
+ });
1027
1127
  const evaluateConvergence = async (at, gateContext, bodyOutcome) => {
1028
1128
  const conv = until ? await gate(until, "until", gateContext, bodyOutcome) : {
1029
1129
  met: bodyOutcome.status === "pass",
@@ -1068,7 +1168,7 @@ function loop(config) {
1068
1168
  }
1069
1169
  let reviewOutcome;
1070
1170
  try {
1071
- reviewOutcome = await config.review(ctxAt(at, bodyOutcome));
1171
+ reviewOutcome = await config.review(checkpointContext(ctxAt(at, bodyOutcome), "review"));
1072
1172
  } catch (e) {
1073
1173
  throw LoopError.from(e, {
1074
1174
  code: "VALIDATION",
@@ -1135,7 +1235,7 @@ function loop(config) {
1135
1235
  }
1136
1236
  return { conv, turnReview: reviewOutcome };
1137
1237
  };
1138
- if (config.checkFirst) {
1238
+ if (config.checkFirst && !saved) {
1139
1239
  await yieldToLoop();
1140
1240
  if (parent.signal.aborted)
1141
1241
  return finish(
@@ -1152,7 +1252,7 @@ function loop(config) {
1152
1252
  { status: "aborted", summary: "aborted by signal" },
1153
1253
  iteration
1154
1254
  );
1155
- if (config.max != null && iteration >= config.max) {
1255
+ if (resumePhase !== "review" && config.max != null && iteration >= config.max) {
1156
1256
  return finish(
1157
1257
  {
1158
1258
  status: "exhausted",
@@ -1164,13 +1264,15 @@ function loop(config) {
1164
1264
  iteration
1165
1265
  );
1166
1266
  }
1167
- iteration += 1;
1267
+ const resumingReview = resumePhase === "review";
1268
+ resumePhase = void 0;
1269
+ if (!resumingReview) iteration += 1;
1168
1270
  const ctx = ctxAt(iteration, last);
1169
1271
  let turnReview;
1170
- parent.emit({ kind: "loop:iteration", ts: ts(), path, iteration });
1272
+ if (!resumingReview) parent.emit({ kind: "loop:iteration", ts: ts(), path, iteration });
1171
1273
  let bodyThrew = false;
1172
1274
  try {
1173
- last = await config.body(ctx);
1275
+ if (!resumingReview) last = await config.body(checkpointContext(ctx, "body"));
1174
1276
  consecutiveErrors = 0;
1175
1277
  } catch (e) {
1176
1278
  bodyThrew = true;
@@ -1261,7 +1363,7 @@ function loop(config) {
1261
1363
  iteration
1262
1364
  );
1263
1365
  }
1264
- if (config.onIteration) {
1366
+ if (config.onIteration && !resumingReview) {
1265
1367
  try {
1266
1368
  await config.onIteration(last, ctx);
1267
1369
  } catch (e) {
@@ -1273,7 +1375,7 @@ function loop(config) {
1273
1375
  });
1274
1376
  }
1275
1377
  }
1276
- if (stopOn) {
1378
+ if (stopOn && !resumingReview) {
1277
1379
  const r = await gate(stopOn, "stopOn", ctx, last);
1278
1380
  parent.emit({
1279
1381
  kind: "loop:condition",
@@ -1321,7 +1423,7 @@ function loop(config) {
1321
1423
  });
1322
1424
  }
1323
1425
  }
1324
- const report = tracker.record({
1426
+ const sample = {
1325
1427
  iteration,
1326
1428
  fingerprint,
1327
1429
  signal: signalValue,
@@ -1330,7 +1432,9 @@ function loop(config) {
1330
1432
  gate: noProgress.gate && until && !conv.met ? conv.output : void 0,
1331
1433
  confidence: turnReview?.confidence ?? conv.confidence ?? last.confidence,
1332
1434
  reason: turnReview ? turnReview.summary ?? "review rejected" : conv.reason
1333
- });
1435
+ };
1436
+ samples.push(Object.fromEntries(Object.entries(sample).filter(([, value]) => value !== void 0)));
1437
+ const report = tracker.record(sample);
1334
1438
  if (!warnedInert && tracker.isInert()) {
1335
1439
  warnedInert = true;
1336
1440
  parent.log(
@@ -1386,7 +1490,7 @@ function loop(config) {
1386
1490
  );
1387
1491
  }
1388
1492
  };
1389
- return setMeta(job, {
1493
+ return interactionDeclaration(setMeta(job, {
1390
1494
  kind: "loop",
1391
1495
  name: config.name,
1392
1496
  max: config.max,
@@ -1398,7 +1502,7 @@ function loop(config) {
1398
1502
  review: !!config.review,
1399
1503
  maxReviewRestarts: config.maxReviewRestarts,
1400
1504
  body: jobMeta(config.body)
1401
- });
1505
+ }), config);
1402
1506
  }
1403
1507
  function delay(ms, signal) {
1404
1508
  if (signal.aborted) return Promise.resolve();
@@ -1617,11 +1721,13 @@ var NOISE = /* @__PURE__ */ new Set([
1617
1721
  "engine:thinking"
1618
1722
  ]);
1619
1723
  function readResumeRecord(path) {
1724
+ const receipts = [];
1725
+ const interactions = /* @__PURE__ */ new Map();
1620
1726
  const anchors = /* @__PURE__ */ new Map();
1621
1727
  const stages = /* @__PURE__ */ new Map();
1622
1728
  const usage2 = /* @__PURE__ */ new Map();
1623
1729
  const started = /* @__PURE__ */ new Set();
1624
- if (!existsSync(path)) return { outcomes: { anchors, stages }, usage: usage2 };
1730
+ if (!existsSync(path)) return { outcomes: { anchors, stages, interactions }, usage: usage2, receipts };
1625
1731
  const lines = readFileSync(path, "utf8").split(/\r?\n/);
1626
1732
  for (const [lineNumber, line] of lines.entries()) {
1627
1733
  if (!line) continue;
@@ -1631,11 +1737,19 @@ function readResumeRecord(path) {
1631
1737
  } catch {
1632
1738
  continue;
1633
1739
  }
1634
- if (event.kind === "workflow:start") {
1740
+ if (event.kind === "engine:usage") receipts.push(event.usage);
1741
+ if (event.kind === "interaction:checkpoint") {
1742
+ const key = event.path.join("/");
1743
+ if (event.data === null) interactions.delete(key);
1744
+ else interactions.set(key, { identity: event.identity, workspace: event.workspace, data: cloneFrozenJson(event.data) });
1745
+ } else if (event.kind === "workflow:start") {
1635
1746
  const key = event.path.join("/");
1636
1747
  const prior = anchors.get(key);
1637
1748
  if (prior?.identity !== event.identity || prior.workspace !== event.workspace) {
1638
1749
  const prefix = key ? `${key}/` : "";
1750
+ for (const stage2 of interactions.keys()) {
1751
+ if (stage2.startsWith(prefix)) interactions.delete(stage2);
1752
+ }
1639
1753
  for (const stage2 of stages.keys()) {
1640
1754
  if (stage2.startsWith(prefix)) stages.delete(stage2);
1641
1755
  }
@@ -1671,7 +1785,7 @@ function readResumeRecord(path) {
1671
1785
  }
1672
1786
  }
1673
1787
  }
1674
- return { outcomes: { anchors, stages }, usage: usage2 };
1788
+ return { outcomes: { anchors, stages, interactions }, usage: usage2, receipts };
1675
1789
  }
1676
1790
  function ensureDir(path) {
1677
1791
  const dir = dirname(path);
@@ -2370,7 +2484,10 @@ async function run(job, options = {}) {
2370
2484
  thin: options.recordTo === "auto",
2371
2485
  ...resumed === void 0 ? {} : { resume: true }
2372
2486
  }));
2373
- if (resumed !== void 0 && resumed.outcomes.anchors.size > 0) {
2487
+ if (resumed !== void 0) {
2488
+ if (resumed.outcomes.interactions.size > 0) {
2489
+ for (const receipt of resumed.receipts) budget?.addUsage(receipt);
2490
+ }
2374
2491
  initialState[RESUME_STAGE_OUTCOMES] = resumed.outcomes;
2375
2492
  initialState[RESUME_RECORDED_USAGE] = resumed.usage;
2376
2493
  }
@@ -2601,8 +2718,8 @@ function parseFrontMatter(source) {
2601
2718
  function briefFromFile(path) {
2602
2719
  return parseFrontMatter(readFileSync(path, "utf8"));
2603
2720
  }
2604
- function person(question) {
2605
- return { kind: "person", question: text(question, "person question") };
2721
+ function person(question, options = {}) {
2722
+ return { kind: "person", question: text(question, "person question"), ...options };
2606
2723
  }
2607
2724
  function stage(name, config) {
2608
2725
  return { name: text(name, "stage name"), config: { ...config } };
@@ -2782,6 +2899,7 @@ function recordedFamilyGate(panel, reviewers, writerStageNames, writerFamilies,
2782
2899
  try {
2783
2900
  const all2 = recordedUsage(ctx);
2784
2901
  const beforeLength = all2.length;
2902
+ const resumingInteraction = hasSavedInteraction(ctx, ctx.path);
2785
2903
  const writerSide = (records) => records.filter(
2786
2904
  (record4) => record4.role === "writer" && record4.stage !== void 0 && writerStageNames.includes(record4.stage)
2787
2905
  );
@@ -2793,7 +2911,7 @@ function recordedFamilyGate(panel, reviewers, writerStageNames, writerFamilies,
2793
2911
  const current = recordedUsage(ctx);
2794
2912
  const after = current.slice(beforeLength);
2795
2913
  const afterWriters = writerSide(after);
2796
- if (writerRunsInsidePanel && afterWriters.length === 0) {
2914
+ if (writerRunsInsidePanel && afterWriters.length === 0 && !resumingInteraction) {
2797
2915
  throw new LoopError({
2798
2916
  code: "BODY",
2799
2917
  phase: "review",
@@ -2801,7 +2919,7 @@ function recordedFamilyGate(panel, reviewers, writerStageNames, writerFamilies,
2801
2919
  });
2802
2920
  }
2803
2921
  assertRecordedFamilies(afterWriters, reviewerDeclarations);
2804
- const reviewerSide = after.filter((record4) => record4.role === "reviewer");
2922
+ const reviewerSide = (resumingInteraction ? current : after).filter((record4) => record4.role === "reviewer" && record4.stage === panelStage);
2805
2923
  if (reviewerSide.length === 0) {
2806
2924
  throw new LoopError({
2807
2925
  code: "BODY",
@@ -2906,6 +3024,26 @@ function guardedAgent(brief, named, seat, files, declaredFiles) {
2906
3024
  return requireNoFiles(named.name, required, ctx.workspace.dir, forbidden, "body")(ctx);
2907
3025
  };
2908
3026
  }
3027
+ function guardedFn(fn, named, declaredFiles) {
3028
+ const writes = writesOf(named.config);
3029
+ const target = named.config.sendsBackTo;
3030
+ const body = fnJob(named.name, async (ctx) => {
3031
+ const outcome = await fn(ctx);
3032
+ if (outcome.status === "fail" && target !== void 0 && revisionFromOutcome(outcome)?.target === void 0) {
3033
+ return kickback(target, outcome.summary ?? `${named.name} failed`, {
3034
+ confidence: outcome.confidence,
3035
+ data: outcome.data,
3036
+ error: outcome.error
3037
+ });
3038
+ }
3039
+ return outcome;
3040
+ });
3041
+ const forbidden = declaredFiles.filter((file) => !writes.includes(file));
3042
+ return async (ctx) => {
3043
+ const required = writes.length ? requireNonEmptyFiles(named.name, body, ctx.workspace.dir, writes) : body;
3044
+ return requireNoFiles(named.name, required, ctx.workspace.dir, forbidden, "body")(ctx);
3045
+ };
3046
+ }
2909
3047
  function unchangedNoteGuard(label, job, writes) {
2910
3048
  const previousHashKey = `declarativePreviousHash:${label}`;
2911
3049
  return async (ctx) => {
@@ -2949,7 +3087,11 @@ function judgedReview(brief, config, cfgJudge, panel) {
2949
3087
  const file = writesOf(config)[0];
2950
3088
  const history = [];
2951
3089
  let previousDraft;
3090
+ let productFeedback = [];
3091
+ const identity = interactionIdentity({ brief, config, cfgJudge });
2952
3092
  return async (ctx) => {
3093
+ const checkpointPath = [...ctx.path, "@judge-review"];
3094
+ let saved = savedInteraction(ctx, checkpointPath, identity);
2953
3095
  let draft;
2954
3096
  let changedLines;
2955
3097
  if (file !== void 0) {
@@ -2960,7 +3102,13 @@ function judgedReview(brief, config, cfgJudge, panel) {
2960
3102
  }
2961
3103
  previousDraft = draft;
2962
3104
  }
2963
- const panelOutcome = await panel({
3105
+ if (saved && saved.state.draft !== draft) saved = void 0;
3106
+ if (saved) {
3107
+ history.splice(0, history.length, ...saved.state.rounds);
3108
+ productFeedback = saved.state.productFeedback ?? [];
3109
+ previousDraft = draft;
3110
+ }
3111
+ const panelOutcome = saved ? saved.panel : await panel({
2964
3112
  ...ctx,
2965
3113
  depth: ctx.depth + 1,
2966
3114
  path: [...ctx.path, "review-panel"]
@@ -2969,7 +3117,8 @@ function judgedReview(brief, config, cfgJudge, panel) {
2969
3117
  const findings = revisionFromOutcome(panelOutcome)?.findings ?? [];
2970
3118
  if (hasBlockFinding(findings)) return panelOutcome;
2971
3119
  const round2 = history.length + 1;
2972
- const state = {
3120
+ const state = saved ? saved.state : {
3121
+ ...productFeedback.length ? { productFeedback } : {},
2973
3122
  ...useCase !== void 0 ? { useCase } : {},
2974
3123
  ...file !== void 0 ? { file } : {},
2975
3124
  ...draft !== void 0 ? { draft } : {},
@@ -2978,7 +3127,15 @@ function judgedReview(brief, config, cfgJudge, panel) {
2978
3127
  round: round2,
2979
3128
  cap: cfgJudge.cap
2980
3129
  };
2981
- const { decision } = await askJudge(cfgJudge, state, ctx, ctx.path);
3130
+ const result = await consultJudge(cfgJudge, state, ctx, ctx.path, {
3131
+ identity,
3132
+ pending: saved !== void 0,
3133
+ save: (questionState) => checkpointInteraction(ctx, checkpointPath, identity, jsonSnapshot({ state: questionState, panel: outcomeSnapshot(panelOutcome) }))
3134
+ });
3135
+ if ("paused" in result) return result.paused;
3136
+ checkpointInteraction(ctx, checkpointPath, identity, null);
3137
+ productFeedback = result.state.productFeedback ?? [];
3138
+ const { decision } = result;
2982
3139
  history.push({
2983
3140
  round: round2,
2984
3141
  findings,
@@ -2986,6 +3143,9 @@ function judgedReview(brief, config, cfgJudge, panel) {
2986
3143
  ...changedLines !== void 0 ? { changedLines } : {}
2987
3144
  });
2988
3145
  if (!decision.again) {
3146
+ if (decision.stop === "fail") {
3147
+ throw new LoopError({ code: "VALIDATION", phase: "review", path: ctx.path, message: decision.reason });
3148
+ }
2989
3149
  return { status: "pass", confidence: panelOutcome.confidence, summary: decision.reason, data: panelOutcome.data };
2990
3150
  }
2991
3151
  return { ...panelOutcome, summary: `${panelOutcome.summary} (${decision.reason})` };
@@ -2998,6 +3158,24 @@ function stageJob(brief, named, roles, files, declaredFiles, targetFiles, family
2998
3158
  const guarded = guardedAgent(brief, named, seatRole(roles, config.agent), files, declaredFiles);
2999
3159
  const job = config.reviewedBy === void 0 ? guarded : unchangedNoteGuard(named.name, guarded, writes);
3000
3160
  if (config.reviewedBy === void 0) return job;
3161
+ const reviewRole = role(roles, config.reviewedBy);
3162
+ if (!Array.isArray(reviewRole) && "kind" in reviewRole && reviewRole.kind === "person") {
3163
+ if (!reviewRole.interaction) throw new TypeError("a human reviewer needs an interaction binding");
3164
+ const humanLoop = loop({
3165
+ name: `${named.name}-review`,
3166
+ body: job,
3167
+ max: refineCap(refineOf(config)) + 1,
3168
+ review: humanReview(named.name, {
3169
+ question: reviewRole.question,
3170
+ interaction: reviewRole.interaction,
3171
+ input: async (ctx) => Object.fromEntries(await Promise.all(writes.map(async (file) => [file, await readFile(join(ctx.workspace.dir, file), "utf8")])))
3172
+ })
3173
+ });
3174
+ return interactionDeclaration(copyJobMeta(async (ctx) => {
3175
+ const outcome = await humanLoop(ctx);
3176
+ return outcome.status === "exhausted" ? { ...outcome, status: "fail" } : outcome;
3177
+ }, humanLoop), { humanLoop });
3178
+ }
3001
3179
  const reviewers = panelRole(roles, config.reviewedBy);
3002
3180
  const panel = reviewerPanel(brief, named, reviewers, files, declaredFiles, void 0);
3003
3181
  const refine = refineOf(config);
@@ -3058,9 +3236,27 @@ function stageJob(brief, named, roles, files, declaredFiles, targetFiles, family
3058
3236
  }
3059
3237
  if ("input" in config && config.input !== void 0) {
3060
3238
  const personRole = inputRole(roles, config.input);
3061
- return approval(named.name, { question: personRole.question, target: config.sendsBackTo });
3239
+ if (!personRole.interaction) return approval(named.name, { question: personRole.question, target: config.sendsBackTo });
3240
+ return async (ctx) => {
3241
+ const identity = interactionIdentity({ brief, named, personRole });
3242
+ const checkpointPath = [...ctx.path, "@person-interaction"];
3243
+ const input = jsonSnapshot({ requester: { path: ctx.path, identity }, material: brief });
3244
+ const result = await requestInteraction(personRole.interaction, personRole.question, input, {
3245
+ ...ctx,
3246
+ interactionCheckpoint() {
3247
+ ctx.interactionCheckpoint?.();
3248
+ checkpointInteraction(ctx, checkpointPath, identity, input);
3249
+ }
3250
+ });
3251
+ if ("paused" in result) return result.paused;
3252
+ checkpointInteraction(ctx, checkpointPath, identity, null);
3253
+ return { status: "pass", summary: result.response.prompt, data: result.response };
3254
+ };
3062
3255
  }
3063
- throw new TypeError(`stage ${named.name} must declare agent, run, panel or input`);
3256
+ if ("fn" in config && config.fn !== void 0) {
3257
+ return guardedFn(config.fn, named, declaredFiles);
3258
+ }
3259
+ throw new TypeError(`stage ${named.name} must declare agent, run, panel, input or fn`);
3064
3260
  }
3065
3261
  function workflowRecord(outcome) {
3066
3262
  return {
@@ -3085,26 +3281,13 @@ function panelReviewFiles(stages, index) {
3085
3281
  const targetWrites = target === void 0 ? [] : writesOf(target.config);
3086
3282
  return targetWrites.length ? targetWrites : preceding === void 0 ? [] : writesOf(preceding.config);
3087
3283
  }
3088
- function canonicalForDigest(value) {
3089
- if (typeof value === "function") return `fn:${value.toString()}`;
3090
- if (Array.isArray(value)) return value.map(canonicalForDigest);
3091
- if (value !== null && typeof value === "object") {
3092
- return Object.fromEntries(
3093
- Object.keys(value).sort().map((key) => [
3094
- key,
3095
- canonicalForDigest(value[key])
3096
- ])
3097
- );
3098
- }
3099
- return value;
3100
- }
3101
3284
  function resumeIdentity(name, config) {
3102
3285
  const declared = {
3103
3286
  name,
3104
3287
  brief: config.brief,
3105
3288
  stages: config.stages.map((named) => ({
3106
3289
  name: named.name,
3107
- config: canonicalForDigest(named.config)
3290
+ config: interactionIdentity(named.config)
3108
3291
  })),
3109
3292
  roles: Object.fromEntries(Object.entries(config.roles).map(([key, value]) => [
3110
3293
  key,
@@ -3139,6 +3322,10 @@ function resumeGuard(job, identity, label, retrySafe) {
3139
3322
  const parentPath = ctx.path.slice(0, -1).join("/");
3140
3323
  const anchor = resumed?.anchors.get(parentPath);
3141
3324
  const recorded = anchor?.identity === identity && anchor.workspace === ctx.workspace.dir ? resumed?.stages.get(ctx.path.join("/")) : void 0;
3325
+ if (anchor?.identity === identity && anchor.workspace === ctx.workspace.dir && hasSavedInteraction(ctx, ctx.path)) {
3326
+ restoreRecordedUsage(ctx);
3327
+ return delegateNodeJob(ctx, guarded, job, ctx);
3328
+ }
3142
3329
  if (ctx.graph?.attempt === 1 && recorded !== void 0) {
3143
3330
  if (recorded.kind === "interrupted") {
3144
3331
  if (!retrySafe) {
@@ -3162,7 +3349,7 @@ function resumeGuard(job, identity, label, retrySafe) {
3162
3349
  }
3163
3350
  return delegateNodeJob(ctx, guarded, job, ctx);
3164
3351
  };
3165
- return guarded;
3352
+ return interactionDeclaration(guarded, { identity, label, job });
3166
3353
  }
3167
3354
  async function reconcileInterrupted(ctx, owner, job, label, identity, recordId, startLine) {
3168
3355
  const question = approval(`reconcile ${label}`, {
@@ -3233,10 +3420,12 @@ function workflow(name, config) {
3233
3420
  for (const [index, named] of config.stages.entries()) {
3234
3421
  const stageConfig = named.config;
3235
3422
  if ("agent" in stageConfig && stageConfig.agent !== void 0 && stageConfig.reviewedBy !== void 0) {
3236
- assertDistinctSeats([
3423
+ const reviewer = role(config.roles, stageConfig.reviewedBy);
3424
+ if (Array.isArray(reviewer)) assertDistinctSeats([
3237
3425
  seatRole(config.roles, stageConfig.agent),
3238
3426
  ...panelRole(config.roles, stageConfig.reviewedBy)
3239
3427
  ]);
3428
+ else if (!("kind" in reviewer) || reviewer.kind !== "person") throw new TypeError("reviewedBy must name a reviewer panel or person");
3240
3429
  }
3241
3430
  if ("panel" in stageConfig && stageConfig.panel !== void 0) {
3242
3431
  const preceding = precedingAgent(config.stages, index);