@obversa/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/api.d.ts +72 -0
- package/dist/api.js +9775 -0
- package/dist/api.js.map +1 -0
- package/dist/artifacts/conformance.d.ts +1 -0
- package/dist/artifacts/file-store.d.ts +8 -0
- package/dist/artifacts/store.d.ts +1 -0
- package/dist/callback/approval.d.ts +8 -0
- package/dist/callback/client.d.ts +38 -0
- package/dist/callback/gate.d.ts +1 -0
- package/dist/callback/stored-client.d.ts +5 -0
- package/dist/chunk-3L6YNPN6.js +3 -0
- package/dist/chunk-3L6YNPN6.js.map +1 -0
- package/dist/chunk-5GLEABOU.js +3 -0
- package/dist/chunk-5GLEABOU.js.map +1 -0
- package/dist/chunk-DEW5R23M.js +338 -0
- package/dist/chunk-DEW5R23M.js.map +1 -0
- package/dist/chunk-DV5P4QLI.js +3056 -0
- package/dist/chunk-DV5P4QLI.js.map +1 -0
- package/dist/chunk-NIBHM5I5.js +34 -0
- package/dist/chunk-NIBHM5I5.js.map +1 -0
- package/dist/chunk-RZLMX3IA.js +368 -0
- package/dist/chunk-RZLMX3IA.js.map +1 -0
- package/dist/core/agent-md.d.ts +36 -0
- package/dist/core/agent.d.ts +86 -0
- package/dist/core/approval-job.d.ts +43 -0
- package/dist/core/assert-graph.d.ts +34 -0
- package/dist/core/budget.d.ts +49 -0
- package/dist/core/concurrency.d.ts +2 -0
- package/dist/core/condition.d.ts +209 -0
- package/dist/core/context.d.ts +36 -0
- package/dist/core/cost.d.ts +59 -0
- package/dist/core/dag.d.ts +20 -0
- package/dist/core/decision.d.ts +29 -0
- package/dist/core/describe.d.ts +56 -0
- package/dist/core/engine-meta.d.ts +5 -0
- package/dist/core/env-overlay.d.ts +35 -0
- package/dist/core/errors.d.ts +46 -0
- package/dist/core/feedback.d.ts +68 -0
- package/dist/core/git.d.ts +152 -0
- package/dist/core/guards.d.ts +70 -0
- package/dist/core/isolated.d.ts +40 -0
- package/dist/core/job.d.ts +134 -0
- package/dist/core/limits.d.ts +22 -0
- package/dist/core/loop.d.ts +24 -0
- package/dist/core/merge.d.ts +30 -0
- package/dist/core/pipeline.d.ts +35 -0
- package/dist/core/process.d.ts +11 -0
- package/dist/core/progress.d.ts +82 -0
- package/dist/core/redact.d.ts +1 -0
- package/dist/core/stats.d.ts +63 -0
- package/dist/core/team.d.ts +34 -0
- package/dist/core/text.d.ts +8 -0
- package/dist/core/tournament.d.ts +25 -0
- package/dist/core/types.d.ts +650 -0
- package/dist/engines/command-runner.d.ts +1 -0
- package/dist/engines/conformance.d.ts +1 -0
- package/dist/engines/engine.d.ts +4 -0
- package/dist/engines/failure.d.ts +1 -0
- package/dist/engines/fallback.d.ts +35 -0
- package/dist/engines/message-map.d.ts +1 -0
- package/dist/engines/mock.d.ts +1 -0
- package/dist/engines/preflight.d.ts +36 -0
- package/dist/env/command.d.ts +50 -0
- package/dist/env/command.js +65 -0
- package/dist/env/command.js.map +1 -0
- package/dist/env/environment.d.ts +4 -0
- package/dist/env/mock.d.ts +24 -0
- package/dist/events/conformance.d.ts +1 -0
- package/dist/events/envelope.d.ts +1 -0
- package/dist/events/jsonl-store.d.ts +8 -0
- package/dist/events/store.d.ts +1 -0
- package/dist/graph/commands.d.ts +5 -0
- package/dist/graph/conformance.d.ts +32 -0
- package/dist/graph/kernel.d.ts +5 -0
- package/dist/graph/plan.d.ts +1 -0
- package/dist/graph/type.d.ts +7 -0
- package/dist/graph/value.d.ts +1 -0
- package/dist/graph-types/dag.d.ts +137 -0
- package/dist/graph-types/loop.d.ts +194 -0
- package/dist/graph-types/team.d.ts +68 -0
- package/dist/memory.d.ts +82 -0
- package/dist/memory.js +397 -0
- package/dist/memory.js.map +1 -0
- package/dist/proof/acceptance.d.ts +5 -0
- package/dist/proof/artifact.d.ts +6 -0
- package/dist/proof/cache.d.ts +4 -0
- package/dist/runtime/attempt.d.ts +19 -0
- package/dist/runtime/budget.d.ts +4 -0
- package/dist/runtime/engine-availability.d.ts +18 -0
- package/dist/runtime/graph-executor.d.ts +7 -0
- package/dist/runtime/monitor.d.ts +80 -0
- package/dist/runtime/node-lifecycle.d.ts +84 -0
- package/dist/runtime/paths.d.ts +2 -0
- package/dist/runtime/persist.d.ts +31 -0
- package/dist/runtime/preflight-record.d.ts +149 -0
- package/dist/runtime/process-tree.d.ts +1 -0
- package/dist/runtime/result-contract.d.ts +4 -0
- package/dist/runtime/result-parts.d.ts +1 -0
- package/dist/runtime/run-definition.d.ts +24 -0
- package/dist/runtime/run-event.d.ts +9 -0
- package/dist/runtime/runner.d.ts +139 -0
- package/dist/runtime/supervisor.d.ts +111 -0
- package/dist/runtime/team-rooms.d.ts +13 -0
- package/dist/runtime/workspace-policy.d.ts +25 -0
- package/dist/storage/error.d.ts +1 -0
- package/dist/storage/id.d.ts +1 -0
- package/dist/storage/local.d.ts +12 -0
- package/dist/storage/local.js +1492 -0
- package/dist/storage/local.js.map +1 -0
- package/dist/testing.d.ts +15 -0
- package/dist/testing.js +274 -0
- package/dist/testing.js.map +1 -0
- package/dist/workflow-agent-response.d.ts +3 -0
- package/dist/workflow-support.d.ts +36 -0
- package/dist/workflow-support.js +5 -0
- package/dist/workflow-support.js.map +1 -0
- package/dist/workflow.d.ts +73 -0
- package/dist/workspace/conformance.d.ts +1 -0
- package/dist/workspace/git-provider.d.ts +26 -0
- package/dist/workspace/provider.d.ts +2 -0
- package/package.json +91 -0
|
@@ -0,0 +1,3056 @@
|
|
|
1
|
+
import { runRuntimeProcess, processText } from './chunk-NIBHM5I5.js';
|
|
2
|
+
import { cloneFrozenJson } from './chunk-5GLEABOU.js';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { stat, readFile, lstat, readlink, realpath } from 'fs/promises';
|
|
5
|
+
import { relative, resolve, join, isAbsolute } from 'path';
|
|
6
|
+
import { readFileSync, existsSync, mkdtempSync } from 'fs';
|
|
7
|
+
import { setTimeout } from 'timers/promises';
|
|
8
|
+
import { EngineError, requireFinalResultText, createCallbackGate } from '@obversa/api';
|
|
9
|
+
export { callbackRequestDigest, createCallbackGate, engineSelection, finalResultPart, finalResultText, reportedUsage, requireFinalResultText, validateAgentResult, validateCallbackRequest, validateCallbackResponse, validateIncompleteResultEvidence } from '@obversa/api';
|
|
10
|
+
import { scrubCapture, redactEnvValues, redactSecrets } from '@obversa/core/command';
|
|
11
|
+
export { scrubCapture } from '@obversa/core/command';
|
|
12
|
+
import pLimit from 'p-limit';
|
|
13
|
+
import { tmpdir } from 'os';
|
|
14
|
+
|
|
15
|
+
// src/core/describe.ts
|
|
16
|
+
var META = /* @__PURE__ */ new WeakMap();
|
|
17
|
+
var LABEL = /* @__PURE__ */ new WeakMap();
|
|
18
|
+
function setMeta(target, meta) {
|
|
19
|
+
META.set(target, meta);
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
function jobMeta(job) {
|
|
23
|
+
return typeof job === "function" ? META.get(job) : void 0;
|
|
24
|
+
}
|
|
25
|
+
function copyJobMeta(target, source) {
|
|
26
|
+
const meta = jobMeta(source);
|
|
27
|
+
return meta ? setMeta(target, meta) : target;
|
|
28
|
+
}
|
|
29
|
+
function setLabel(cond, label) {
|
|
30
|
+
LABEL.set(cond, label);
|
|
31
|
+
return cond;
|
|
32
|
+
}
|
|
33
|
+
function condLabel(input) {
|
|
34
|
+
if (typeof input === "function") {
|
|
35
|
+
const l = LABEL.get(input);
|
|
36
|
+
if (l) return l;
|
|
37
|
+
}
|
|
38
|
+
return "check";
|
|
39
|
+
}
|
|
40
|
+
function describeConditions(input) {
|
|
41
|
+
if (input == null) return [];
|
|
42
|
+
if (Array.isArray(input)) return input.flatMap(describeConditions);
|
|
43
|
+
return [condLabel(input)];
|
|
44
|
+
}
|
|
45
|
+
var count = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
46
|
+
function renderContract(value) {
|
|
47
|
+
const c = value;
|
|
48
|
+
if (!c || typeof c !== "object") return void 0;
|
|
49
|
+
const bits = [];
|
|
50
|
+
if (c.tier) bits.push(`tier ${c.tier}`);
|
|
51
|
+
if (c.outputs?.length) bits.push(`outputs ${c.outputs.join(", ")}`);
|
|
52
|
+
if (c.capabilities?.length) bits.push(`capabilities ${c.capabilities.join(", ")}`);
|
|
53
|
+
if (c.requiresSkills?.length) bits.push(`requires ${c.requiresSkills.join(", ")}`);
|
|
54
|
+
if (c.usesSkills?.length) bits.push(`uses ${c.usesSkills.join(", ")}`);
|
|
55
|
+
if (c.failureModes?.length) bits.push(`failure modes ${c.failureModes.join(", ")}`);
|
|
56
|
+
return bits.join("; ");
|
|
57
|
+
}
|
|
58
|
+
function renderPlan(meta, indent = "") {
|
|
59
|
+
if (!meta) return [`${indent}(a runnable job, shape not introspectable)`];
|
|
60
|
+
const nm = meta.name ? ` "${meta.name}"` : "";
|
|
61
|
+
const out = [];
|
|
62
|
+
switch (meta.kind) {
|
|
63
|
+
case "loop": {
|
|
64
|
+
const caps = [];
|
|
65
|
+
if (typeof meta.max === "number") caps.push(`max ${meta.max}`);
|
|
66
|
+
if (typeof meta.noProgress === "number")
|
|
67
|
+
caps.push(`stall after ${meta.noProgress} flat`);
|
|
68
|
+
if (meta.checkFirst) caps.push("check first");
|
|
69
|
+
const max = caps.length ? ` (${caps.join("; ")})` : "";
|
|
70
|
+
out.push(`${indent}loop${nm}${max}`);
|
|
71
|
+
const start = meta.start;
|
|
72
|
+
const gate = meta.gate;
|
|
73
|
+
const stopOn = meta.stopOn;
|
|
74
|
+
if (start?.length) out.push(`${indent} start: ${start.join(", ")}`);
|
|
75
|
+
if (gate?.length) out.push(`${indent} gate: ${gate.join(", ")}`);
|
|
76
|
+
if (stopOn?.length) out.push(`${indent} stopOn: ${stopOn.join(", ")}`);
|
|
77
|
+
const tail = [meta.review ? "review" : null].filter(Boolean);
|
|
78
|
+
if (tail.length) out.push(`${indent} on convergence: ${tail.join(" + ")}`);
|
|
79
|
+
out.push(`${indent} body:`);
|
|
80
|
+
out.push(...renderPlan(meta.body, `${indent} `));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "dag": {
|
|
84
|
+
const nodes = meta.nodes ?? [];
|
|
85
|
+
out.push(`${indent}dag${nm} (${count(nodes.length, "node")})`);
|
|
86
|
+
const kickbacks = meta.maxKickbacks;
|
|
87
|
+
if (typeof kickbacks === "number") {
|
|
88
|
+
out.push(`${indent} kickbacks: ${kickbacks}`);
|
|
89
|
+
} else if (kickbacks && typeof kickbacks === "object" && !Array.isArray(kickbacks)) {
|
|
90
|
+
const limits = Object.entries(kickbacks).map(([target, limit]) => `${target} ${String(limit)}`).join(", ");
|
|
91
|
+
out.push(`${indent} kickbacks: ${limits}`);
|
|
92
|
+
}
|
|
93
|
+
for (const node of nodes) {
|
|
94
|
+
const bits = [];
|
|
95
|
+
if (node.needs?.length) bits.push(`needs ${node.needs.join(", ")}`);
|
|
96
|
+
if (node.optional) bits.push("optional");
|
|
97
|
+
if (node.when?.length) bits.push(`when: ${node.when.join(", ")}`);
|
|
98
|
+
if (node.isolate) bits.push("isolated");
|
|
99
|
+
out.push(`${indent} - ${node.name}${bits.length ? ` (${bits.join("; ")})` : ""}`);
|
|
100
|
+
if (node.desc) out.push(`${indent} desc: ${node.desc}`);
|
|
101
|
+
if (node.gate) out.push(`${indent} gate: ${node.gate}`);
|
|
102
|
+
out.push(...renderPlan(node.job, `${indent} `));
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "agent":
|
|
107
|
+
out.push(`${indent}agent${nm}`);
|
|
108
|
+
{
|
|
109
|
+
const contract = renderContract(meta.contract);
|
|
110
|
+
if (contract) out.push(`${indent} contract: ${contract}`);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "fn":
|
|
114
|
+
out.push(`${indent}fn${nm}`);
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
out.push(`${indent}${meta.kind}${nm}`);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/core/context.ts
|
|
123
|
+
function criterionFor(ctx) {
|
|
124
|
+
if (ctx.stageGate !== void 0) return ctx.stageGate ?? void 0;
|
|
125
|
+
if (ctx.reviewerGate !== void 0) return ctx.reviewerGate ?? void 0;
|
|
126
|
+
return ctx.graph?.gate;
|
|
127
|
+
}
|
|
128
|
+
function childContext(parent, over) {
|
|
129
|
+
return {
|
|
130
|
+
engine: parent.engine,
|
|
131
|
+
resolveEngine: parent.resolveEngine,
|
|
132
|
+
signal: parent.signal,
|
|
133
|
+
runId: parent.runId,
|
|
134
|
+
fingerprintExcludePaths: parent.fingerprintExcludePaths,
|
|
135
|
+
emit: parent.emit,
|
|
136
|
+
params: parent.params,
|
|
137
|
+
state: parent.state,
|
|
138
|
+
memory: parent.memory,
|
|
139
|
+
callbacks: parent.callbacks,
|
|
140
|
+
onCallback: parent.onCallback,
|
|
141
|
+
// A child inherits the parent's workspace by default; a concurrency
|
|
142
|
+
// boundary forks it into an isolated worktree by passing `workspace`.
|
|
143
|
+
workspace: over.workspace ?? parent.workspace,
|
|
144
|
+
environment: over.environment ?? parent.environment,
|
|
145
|
+
// Inherited, not override-only: pinning deliberately survives the dag
|
|
146
|
+
// worktree boundary, where a node ctx REPLACES `environment` with a
|
|
147
|
+
// per-team handle. An explicit `withEnv` wins over a per-team stack's vars.
|
|
148
|
+
envOverlay: over.envOverlay ?? parent.envOverlay,
|
|
149
|
+
budget: parent.budget,
|
|
150
|
+
onLimit: parent.onLimit,
|
|
151
|
+
maxWaitMs: parent.maxWaitMs,
|
|
152
|
+
log: parent.log,
|
|
153
|
+
depth: over.depth,
|
|
154
|
+
path: over.path,
|
|
155
|
+
graph: over.graph ?? parent.graph,
|
|
156
|
+
reviewerGate: over.reviewerGate !== void 0 ? over.reviewerGate : parent.reviewerGate,
|
|
157
|
+
stageGate: over.stageGate !== void 0 ? over.stageGate : parent.stageGate,
|
|
158
|
+
timeoutMs: over.timeoutMs ?? parent.timeoutMs,
|
|
159
|
+
timeoutGraceMs: over.timeoutGraceMs ?? parent.timeoutGraceMs,
|
|
160
|
+
// Inherit the enclosing iteration by default. A `loop` always passes one
|
|
161
|
+
// explicitly; a `dag`/`sequence` does not, so without this a node nested in a
|
|
162
|
+
// loop would reset to 0, the "Attempt 0" confound where a retry body could not
|
|
163
|
+
// see which attempt it was on. A top-level dag still gets 0 (the root's value).
|
|
164
|
+
iteration: over.iteration ?? parent.iteration,
|
|
165
|
+
lastOutcome: over.lastOutcome,
|
|
166
|
+
lastReview: over.lastReview,
|
|
167
|
+
lastGate: over.lastGate,
|
|
168
|
+
// Not inherited: a nested job sees its own dag node's needs, never an ancestor's.
|
|
169
|
+
needs: over.needs
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
var LoopError = class _LoopError extends Error {
|
|
173
|
+
code;
|
|
174
|
+
phase;
|
|
175
|
+
path;
|
|
176
|
+
iteration;
|
|
177
|
+
retryable;
|
|
178
|
+
/** Suggested wait before retry, in ms (e.g. a `retry-after` header). */
|
|
179
|
+
retryAfterMs;
|
|
180
|
+
/** When the limit resets, as epoch ms. The wait policy prefers this. */
|
|
181
|
+
resetAt;
|
|
182
|
+
constructor(init) {
|
|
183
|
+
super(
|
|
184
|
+
init.message,
|
|
185
|
+
init.cause !== void 0 ? { cause: init.cause } : void 0
|
|
186
|
+
);
|
|
187
|
+
this.name = "LoopError";
|
|
188
|
+
this.code = init.code;
|
|
189
|
+
this.phase = init.phase;
|
|
190
|
+
this.path = init.path;
|
|
191
|
+
this.iteration = init.iteration;
|
|
192
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
193
|
+
this.resetAt = init.resetAt;
|
|
194
|
+
this.retryable = init.retryable ?? defaultRetryable(init);
|
|
195
|
+
}
|
|
196
|
+
/** Wrap an arbitrary thrown value, preserving a `LoopError` as-is. */
|
|
197
|
+
static from(value, fallback) {
|
|
198
|
+
if (value instanceof _LoopError) return value;
|
|
199
|
+
if (value instanceof EngineError) {
|
|
200
|
+
return new _LoopError({
|
|
201
|
+
...fallback,
|
|
202
|
+
code: loopCodeForEngineFailure(value.kind, fallback.code),
|
|
203
|
+
message: value.message,
|
|
204
|
+
cause: value,
|
|
205
|
+
retryAfterMs: value.retryAfterMs,
|
|
206
|
+
resetAt: value.resetAt
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
210
|
+
return new _LoopError({ ...fallback, message, cause: value });
|
|
211
|
+
}
|
|
212
|
+
toJSON() {
|
|
213
|
+
return {
|
|
214
|
+
name: this.name,
|
|
215
|
+
code: this.code,
|
|
216
|
+
message: this.message,
|
|
217
|
+
phase: this.phase,
|
|
218
|
+
path: this.path,
|
|
219
|
+
iteration: this.iteration,
|
|
220
|
+
retryable: this.retryable,
|
|
221
|
+
retryAfterMs: this.retryAfterMs,
|
|
222
|
+
resetAt: this.resetAt
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
function loopCodeForEngineFailure(kind, fallback) {
|
|
227
|
+
switch (kind) {
|
|
228
|
+
case "rate-limit":
|
|
229
|
+
return "RATE_LIMIT";
|
|
230
|
+
case "quota":
|
|
231
|
+
return "QUOTA";
|
|
232
|
+
case "timeout":
|
|
233
|
+
return "TIMEOUT";
|
|
234
|
+
case "aborted":
|
|
235
|
+
return "ABORTED";
|
|
236
|
+
case "invalid-config":
|
|
237
|
+
return "CONFIG";
|
|
238
|
+
default:
|
|
239
|
+
return fallback;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
var INFRASTRUCTURE_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
243
|
+
"ENGINE",
|
|
244
|
+
"TIMEOUT",
|
|
245
|
+
"RATE_LIMIT",
|
|
246
|
+
"QUOTA",
|
|
247
|
+
"BUDGET"
|
|
248
|
+
]);
|
|
249
|
+
function isInfrastructureError(error) {
|
|
250
|
+
return error instanceof LoopError && INFRASTRUCTURE_ERROR_CODES.has(error.code);
|
|
251
|
+
}
|
|
252
|
+
function defaultRetryable(init) {
|
|
253
|
+
switch (init.code) {
|
|
254
|
+
case "ENGINE":
|
|
255
|
+
case "TIMEOUT":
|
|
256
|
+
case "RATE_LIMIT":
|
|
257
|
+
return true;
|
|
258
|
+
case "QUOTA":
|
|
259
|
+
return init.resetAt != null || init.retryAfterMs != null;
|
|
260
|
+
default:
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/core/approval-job.ts
|
|
266
|
+
var NODE_JOBS = /* @__PURE__ */ new WeakMap();
|
|
267
|
+
function nodeJobContext(ctx, job) {
|
|
268
|
+
const scoped = { ...ctx };
|
|
269
|
+
if (job !== void 0) NODE_JOBS.set(scoped, job);
|
|
270
|
+
return scoped;
|
|
271
|
+
}
|
|
272
|
+
function delegateNodeJob(ctx, owner, job, child) {
|
|
273
|
+
return job(nodeJobContext(child, NODE_JOBS.get(ctx) === owner ? job : void 0));
|
|
274
|
+
}
|
|
275
|
+
var RESPONSE_SCHEMA = {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: { approved: { type: "boolean" }, note: { type: "string" } },
|
|
278
|
+
required: ["approved"]
|
|
279
|
+
};
|
|
280
|
+
function aboutOf(ctx) {
|
|
281
|
+
if (ctx.needs !== void 0) {
|
|
282
|
+
return Object.fromEntries(
|
|
283
|
+
Object.entries(ctx.needs).map(([name, outcome]) => [name, outcome.summary ?? outcome.status])
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return ctx.lastOutcome?.summary ?? null;
|
|
287
|
+
}
|
|
288
|
+
function toAnswer(response) {
|
|
289
|
+
const record = response !== null && typeof response === "object" && !Array.isArray(response) ? response : void 0;
|
|
290
|
+
if (record === void 0 || typeof record.approved !== "boolean") {
|
|
291
|
+
throw new LoopError({ code: "VALIDATION", message: 'an approval answer needs a boolean "approved"' });
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
approved: record.approved,
|
|
295
|
+
...typeof record.note === "string" ? { note: record.note } : {}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function stateOf(events) {
|
|
299
|
+
let state = "absent";
|
|
300
|
+
let answer;
|
|
301
|
+
for (const event of events) {
|
|
302
|
+
switch (event.kind) {
|
|
303
|
+
case "callback-requested":
|
|
304
|
+
state = "pending";
|
|
305
|
+
break;
|
|
306
|
+
case "callback-claimed":
|
|
307
|
+
state = "claimed";
|
|
308
|
+
break;
|
|
309
|
+
case "callback-released":
|
|
310
|
+
state = "pending";
|
|
311
|
+
break;
|
|
312
|
+
case "callback-submitted":
|
|
313
|
+
state = "answered";
|
|
314
|
+
answer = toAnswer(event.response);
|
|
315
|
+
break;
|
|
316
|
+
case "callback-superseded":
|
|
317
|
+
state = "superseded";
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return answer === void 0 ? { state } : { state, answer };
|
|
322
|
+
}
|
|
323
|
+
function approval(label, opts) {
|
|
324
|
+
const job = async (ctx) => {
|
|
325
|
+
const path = [...ctx.path];
|
|
326
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path, label, timeoutMs: ctx.timeoutMs });
|
|
327
|
+
let outcome;
|
|
328
|
+
try {
|
|
329
|
+
outcome = await decide(ctx, label, opts, NODE_JOBS.get(ctx) === job);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
const error = LoopError.from(e, { code: "BODY", phase: "body", path: ctx.path, iteration: ctx.iteration });
|
|
332
|
+
outcome = { status: "fail", summary: error.message, error };
|
|
333
|
+
ctx.emit({ kind: "error", ts: Date.now(), path, message: error.message, code: error.code });
|
|
334
|
+
}
|
|
335
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
336
|
+
return outcome;
|
|
337
|
+
};
|
|
338
|
+
return setMeta(job, {
|
|
339
|
+
kind: "approval",
|
|
340
|
+
name: label,
|
|
341
|
+
question: opts.question,
|
|
342
|
+
...opts.target !== void 0 ? { target: opts.target } : {}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async function decide(ctx, label, opts, ownsNode) {
|
|
346
|
+
const client = ctx.callbacks;
|
|
347
|
+
if (client === void 0) {
|
|
348
|
+
throw new LoopError({
|
|
349
|
+
code: "VALIDATION",
|
|
350
|
+
message: `approval "${label}" needs the run's callbacks client; run it through run()`
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const request = createCallbackGate({
|
|
354
|
+
gateId: label,
|
|
355
|
+
gateVersion: 1,
|
|
356
|
+
decisionText: opts.question,
|
|
357
|
+
responseSchema: RESPONSE_SCHEMA,
|
|
358
|
+
input: opts.input ?? aboutOf(ctx)
|
|
359
|
+
});
|
|
360
|
+
let { state, answer } = stateOf(await client.history(request.requestId));
|
|
361
|
+
if (answer === void 0) {
|
|
362
|
+
await client.post(request);
|
|
363
|
+
({ state, answer } = stateOf(await client.history(request.requestId)));
|
|
364
|
+
if (answer === void 0 && state !== "pending" && state !== "claimed") {
|
|
365
|
+
return {
|
|
366
|
+
status: "fail",
|
|
367
|
+
summary: `the question "${opts.question}" is not pending and not answered after it was asked (${state}); nobody can answer it`,
|
|
368
|
+
data: request
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (answer === void 0 && opts.answer !== void 0) {
|
|
373
|
+
answer = await answerInProcess(client, request, `${label}:answer`, opts.answer);
|
|
374
|
+
}
|
|
375
|
+
const paused = { status: "paused", summary: `waiting for a person: ${opts.question}`, data: request };
|
|
376
|
+
if (ownsNode && answer === void 0 && ctx.onCallback === "wait" && !ctx.signal.aborted && ctx.graph !== void 0) {
|
|
377
|
+
ctx.emit({
|
|
378
|
+
kind: "dag:node",
|
|
379
|
+
ts: Date.now(),
|
|
380
|
+
path: ctx.graph.path.slice(0, -1),
|
|
381
|
+
node: ctx.graph.node,
|
|
382
|
+
phase: "done",
|
|
383
|
+
attempt: ctx.graph.attempt,
|
|
384
|
+
needs: [...ctx.graph.needs],
|
|
385
|
+
...ctx.graph.desc === void 0 ? {} : { desc: ctx.graph.desc },
|
|
386
|
+
...ctx.graph.gate === void 0 ? {} : { gate: ctx.graph.gate },
|
|
387
|
+
timeoutMs: ctx.timeoutMs,
|
|
388
|
+
outcome: paused
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
while (answer === void 0 && ctx.onCallback === "wait" && !ctx.signal.aborted && (state === "pending" || state === "claimed")) {
|
|
392
|
+
try {
|
|
393
|
+
await setTimeout(1e3, void 0, { signal: ctx.signal });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (!ctx.signal.aborted) throw error;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
({ state, answer } = stateOf(await client.history(request.requestId)));
|
|
399
|
+
}
|
|
400
|
+
if (answer === void 0) {
|
|
401
|
+
return paused;
|
|
402
|
+
}
|
|
403
|
+
if (answer.approved) {
|
|
404
|
+
const accepted = {
|
|
405
|
+
approved: true,
|
|
406
|
+
...typeof answer.note === "string" && answer.note !== "" ? { note: redactSecrets(answer.note) } : {}
|
|
407
|
+
};
|
|
408
|
+
return { status: "pass", summary: `approved: ${opts.question}`, data: accepted };
|
|
409
|
+
}
|
|
410
|
+
const note = typeof answer.note === "string" && answer.note !== "" ? redactSecrets(answer.note) : `refused: ${opts.question}`;
|
|
411
|
+
const scrubbed = { approved: false, note };
|
|
412
|
+
if (opts.target === void 0) {
|
|
413
|
+
return { status: "fail", summary: note, data: scrubbed };
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
status: "fail",
|
|
417
|
+
summary: note,
|
|
418
|
+
data: scrubbed,
|
|
419
|
+
revision: {
|
|
420
|
+
target: opts.target,
|
|
421
|
+
reason: note,
|
|
422
|
+
findings: [{ evidence: note, severity: "block" }],
|
|
423
|
+
rerun: "target-and-dependents"
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function answerInProcess(client, request, routerId, respond) {
|
|
428
|
+
const claim = await client.claim(request.requestId, routerId);
|
|
429
|
+
if (!claim.ok) {
|
|
430
|
+
throw new LoopError({
|
|
431
|
+
code: "VALIDATION",
|
|
432
|
+
message: `the question "${request.decisionText}" could not be claimed to answer it (${claim.kind})`
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
let response;
|
|
436
|
+
try {
|
|
437
|
+
response = await respond(request);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
await client.release(request.requestId, claim.claimToken);
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
442
|
+
const submission = {
|
|
443
|
+
approved: response.approved,
|
|
444
|
+
...typeof response.note === "string" ? { note: response.note } : {}
|
|
445
|
+
};
|
|
446
|
+
let submitted;
|
|
447
|
+
try {
|
|
448
|
+
submitted = await client.submit(
|
|
449
|
+
request.requestId,
|
|
450
|
+
claim.claimToken,
|
|
451
|
+
routerId,
|
|
452
|
+
request.digest,
|
|
453
|
+
submission
|
|
454
|
+
);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
await client.release(request.requestId, claim.claimToken);
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
if (!submitted.ok) {
|
|
460
|
+
await client.release(request.requestId, claim.claimToken);
|
|
461
|
+
throw new LoopError({
|
|
462
|
+
code: "VALIDATION",
|
|
463
|
+
message: `the answer to "${request.decisionText}" was refused: ${submitted.reason}`
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return toAnswer(submitted.response);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/core/env-overlay.ts
|
|
470
|
+
function mergeEnv(...layers) {
|
|
471
|
+
let merged;
|
|
472
|
+
for (const layer of layers) {
|
|
473
|
+
if (!layer) continue;
|
|
474
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
475
|
+
(merged ??= /* @__PURE__ */ Object.create(null))[k] = v;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return merged;
|
|
479
|
+
}
|
|
480
|
+
function resolveEnv(ctx, perCall) {
|
|
481
|
+
return mergeEnv(ctx.environment?.env, ctx.envOverlay, perCall);
|
|
482
|
+
}
|
|
483
|
+
function withEnv(overlay, job) {
|
|
484
|
+
if (!overlay || typeof overlay !== "object" || Array.isArray(overlay)) {
|
|
485
|
+
throw new LoopError({
|
|
486
|
+
code: "CONFIG",
|
|
487
|
+
message: `withEnv requires a plain object of string values (got ${Array.isArray(overlay) ? "an array" : typeof overlay})`
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
for (const [k, v] of Object.entries(overlay)) {
|
|
491
|
+
if (!k || k.includes("=")) {
|
|
492
|
+
throw new LoopError({
|
|
493
|
+
code: "CONFIG",
|
|
494
|
+
message: `withEnv key ${JSON.stringify(k)} is not a valid env var name (empty, or contains "=")`
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (typeof v !== "string") {
|
|
498
|
+
throw new LoopError({
|
|
499
|
+
code: "CONFIG",
|
|
500
|
+
message: `withEnv value for "${k}" must be a string (got ${typeof v})`
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const wrapper = (ctx) => delegateNodeJob(
|
|
505
|
+
ctx,
|
|
506
|
+
wrapper,
|
|
507
|
+
job,
|
|
508
|
+
// Transparent: same depth and path (no new tree segment, no events of
|
|
509
|
+
// its own), and the loop-feedback fields are carried through so wrapping
|
|
510
|
+
// a loop body does not hide the previous iteration from it.
|
|
511
|
+
childContext(ctx, {
|
|
512
|
+
depth: ctx.depth,
|
|
513
|
+
path: ctx.path,
|
|
514
|
+
lastOutcome: ctx.lastOutcome,
|
|
515
|
+
lastReview: ctx.lastReview,
|
|
516
|
+
lastGate: ctx.lastGate,
|
|
517
|
+
envOverlay: { ...ctx.envOverlay, ...overlay }
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
const meta = jobMeta(job);
|
|
521
|
+
return meta ? setMeta(wrapper, meta) : wrapper;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/core/budget.ts
|
|
525
|
+
var Budget = class {
|
|
526
|
+
limit;
|
|
527
|
+
headroom;
|
|
528
|
+
soft;
|
|
529
|
+
tokens = 0;
|
|
530
|
+
unknownCalls = 0;
|
|
531
|
+
constructor(config) {
|
|
532
|
+
this.limit = config.limit;
|
|
533
|
+
this.headroom = config.headroom ?? 0;
|
|
534
|
+
this.soft = config.soft ?? false;
|
|
535
|
+
}
|
|
536
|
+
/** Record consumed tokens. Non-finite or non-positive values are ignored. */
|
|
537
|
+
add(tokens) {
|
|
538
|
+
if (Number.isFinite(tokens) && tokens > 0) this.tokens += tokens;
|
|
539
|
+
}
|
|
540
|
+
addUsage(usage) {
|
|
541
|
+
if (usage.kind === "unknown") {
|
|
542
|
+
this.unknownCalls += 1;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.add(usage.inputTokens + usage.outputTokens);
|
|
546
|
+
}
|
|
547
|
+
unknownUsageCalls() {
|
|
548
|
+
return this.unknownCalls;
|
|
549
|
+
}
|
|
550
|
+
spent() {
|
|
551
|
+
return this.tokens;
|
|
552
|
+
}
|
|
553
|
+
remaining() {
|
|
554
|
+
return Math.max(0, this.limit - this.tokens);
|
|
555
|
+
}
|
|
556
|
+
/** True once the next call would breach the cap (accounting for headroom). */
|
|
557
|
+
exceeded() {
|
|
558
|
+
return this.tokens + this.headroom >= this.limit;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
function assertBudget(ctx) {
|
|
562
|
+
const budget = ctx.budget;
|
|
563
|
+
if (!budget) return;
|
|
564
|
+
if (budget.unknownUsageCalls() > 0) {
|
|
565
|
+
if (budget.soft) {
|
|
566
|
+
ctx.log(
|
|
567
|
+
`token usage is unknown for ${budget.unknownUsageCalls()} call(s) \u2014 continuing (soft)`,
|
|
568
|
+
"warn"
|
|
569
|
+
);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
throw new LoopError({
|
|
573
|
+
code: "BUDGET",
|
|
574
|
+
phase: "engine",
|
|
575
|
+
message: `token budget cannot continue: usage is unknown for ${budget.unknownUsageCalls()} call(s)`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (!budget.exceeded()) return;
|
|
579
|
+
if (budget.soft) {
|
|
580
|
+
ctx.log(
|
|
581
|
+
`token budget reached (${budget.spent()}/${budget.limit}) \u2014 continuing (soft)`,
|
|
582
|
+
"warn"
|
|
583
|
+
);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
throw new LoopError({
|
|
587
|
+
code: "BUDGET",
|
|
588
|
+
phase: "engine",
|
|
589
|
+
message: `token budget exhausted: ${budget.spent()}/${budget.limit} tokens spent`
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
function fromFile(path) {
|
|
593
|
+
return readFileSync(path, "utf8").trim();
|
|
594
|
+
}
|
|
595
|
+
function defineSkill(skill) {
|
|
596
|
+
if (!skill.name) throw new Error("defineSkill: `name` is required");
|
|
597
|
+
if (!skill.instructions?.trim()) throw new Error(`defineSkill "${skill.name}": empty instructions`);
|
|
598
|
+
return skill;
|
|
599
|
+
}
|
|
600
|
+
function skillRefName(ref) {
|
|
601
|
+
return typeof ref === "string" ? ref : ref.name;
|
|
602
|
+
}
|
|
603
|
+
function validateName(value, label) {
|
|
604
|
+
if (!value?.trim()) throw new Error(`${label}: \`name\` is required`);
|
|
605
|
+
}
|
|
606
|
+
function validateSkillRef(ref, label) {
|
|
607
|
+
if (typeof ref === "string") {
|
|
608
|
+
if (!ref.trim()) throw new Error(`${label}: empty skill name`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
defineSkill(ref);
|
|
612
|
+
}
|
|
613
|
+
function defineAgent(def) {
|
|
614
|
+
if (!def.name) throw new Error("defineAgent: `name` is required");
|
|
615
|
+
if (!def.system?.trim()) throw new Error(`defineAgent "${def.name}": empty system prompt`);
|
|
616
|
+
def.skills?.forEach((s) => defineSkill(s));
|
|
617
|
+
def.requiresSkills?.forEach(
|
|
618
|
+
(s) => validateSkillRef(s, `defineAgent "${def.name}" requiresSkills`)
|
|
619
|
+
);
|
|
620
|
+
def.usesSkills?.forEach(
|
|
621
|
+
(s) => validateSkillRef(s, `defineAgent "${def.name}" usesSkills`)
|
|
622
|
+
);
|
|
623
|
+
def.outputs?.forEach(
|
|
624
|
+
(o) => validateName(o.name, `defineAgent "${def.name}" outputs`)
|
|
625
|
+
);
|
|
626
|
+
def.failureModes?.forEach((f) => {
|
|
627
|
+
if (!f.mode?.trim())
|
|
628
|
+
throw new Error(`defineAgent "${def.name}" failureModes: \`mode\` is required`);
|
|
629
|
+
if (!f.recovery?.trim())
|
|
630
|
+
throw new Error(`defineAgent "${def.name}" failureModes "${f.mode}": \`recovery\` is required`);
|
|
631
|
+
});
|
|
632
|
+
return def;
|
|
633
|
+
}
|
|
634
|
+
function agentContract(agent) {
|
|
635
|
+
if (!agent) return void 0;
|
|
636
|
+
const summary = {};
|
|
637
|
+
if (agent.tier) summary.tier = agent.tier;
|
|
638
|
+
if (agent.capabilities?.length) summary.capabilities = [...agent.capabilities];
|
|
639
|
+
if (agent.outputs?.length) summary.outputs = agent.outputs.map((o) => o.name);
|
|
640
|
+
if (agent.requiresSkills?.length)
|
|
641
|
+
summary.requiresSkills = agent.requiresSkills.map(skillRefName);
|
|
642
|
+
if (agent.usesSkills?.length)
|
|
643
|
+
summary.usesSkills = agent.usesSkills.map(skillRefName);
|
|
644
|
+
if (agent.failureModes?.length)
|
|
645
|
+
summary.failureModes = agent.failureModes.map((f) => f.mode);
|
|
646
|
+
return Object.keys(summary).length ? summary : void 0;
|
|
647
|
+
}
|
|
648
|
+
function resolveSystem(agent) {
|
|
649
|
+
if (!agent.skills?.length) return agent.system;
|
|
650
|
+
const methods = agent.skills.map((s) => `### ${s.name}
|
|
651
|
+
|
|
652
|
+
${s.instructions.trim()}`).join("\n\n");
|
|
653
|
+
return `${agent.system.trim()}
|
|
654
|
+
|
|
655
|
+
## Methodologies you apply
|
|
656
|
+
|
|
657
|
+
${methods}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/core/text.ts
|
|
661
|
+
function oneLine(text) {
|
|
662
|
+
return text.replace(/\s+/g, " ").trim();
|
|
663
|
+
}
|
|
664
|
+
function truncate(s, max) {
|
|
665
|
+
if (s.length <= max) return s;
|
|
666
|
+
const marker = max > 1 ? "\n\u2026" : "\u2026".slice(0, Math.max(0, max));
|
|
667
|
+
return `${s.slice(0, Math.max(0, max - marker.length)).trimEnd()}${marker}`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/core/engine-meta.ts
|
|
671
|
+
function leafId(ctx, label) {
|
|
672
|
+
const raw = [...ctx.path, label, String(ctx.iteration)].join("/");
|
|
673
|
+
return raw.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/(^-+|-+$)/g, "") || "leaf";
|
|
674
|
+
}
|
|
675
|
+
function attemptRequestMeta(ctx, label) {
|
|
676
|
+
return {
|
|
677
|
+
leaf: true,
|
|
678
|
+
runId: ctx.runId,
|
|
679
|
+
leafId: leafId(ctx, label),
|
|
680
|
+
path: [...ctx.path],
|
|
681
|
+
label,
|
|
682
|
+
iteration: ctx.iteration
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function logEngineTransportFailure(ctx, result, env) {
|
|
686
|
+
if (!result.transportFailure) return;
|
|
687
|
+
ctx.log(scrubCapture(result.transportFailure.message, env, 1e3), "warn");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/core/condition.ts
|
|
691
|
+
var COMMAND_FAILURE_TAIL_MAX = 3e3;
|
|
692
|
+
var COMMAND_FAILURE_TAIL_MARKER = "\u2026 [output truncated]\n";
|
|
693
|
+
function commandFailureTail(text, env) {
|
|
694
|
+
const scrubbed = scrubCapture(text, env);
|
|
695
|
+
if (scrubbed.length <= COMMAND_FAILURE_TAIL_MAX) return scrubbed;
|
|
696
|
+
return COMMAND_FAILURE_TAIL_MARKER + scrubbed.slice(
|
|
697
|
+
-(COMMAND_FAILURE_TAIL_MAX - COMMAND_FAILURE_TAIL_MARKER.length)
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
function toCondition(input, combine = "all") {
|
|
701
|
+
if (Array.isArray(input)) {
|
|
702
|
+
const conds = input.map((i) => toCondition(i, combine));
|
|
703
|
+
return combine === "any" ? any(...conds) : all(...conds);
|
|
704
|
+
}
|
|
705
|
+
return coerceOne(input);
|
|
706
|
+
}
|
|
707
|
+
var conditionPreparations = /* @__PURE__ */ new WeakMap();
|
|
708
|
+
function withConditionPreparation(condition, prepare) {
|
|
709
|
+
conditionPreparations.set(condition, prepare);
|
|
710
|
+
return condition;
|
|
711
|
+
}
|
|
712
|
+
async function prepareCondition(input, ctx, combine = "all") {
|
|
713
|
+
if (Array.isArray(input)) {
|
|
714
|
+
const prepared = await Promise.all(
|
|
715
|
+
input.map((item) => prepareCondition(item, ctx, combine))
|
|
716
|
+
);
|
|
717
|
+
return combine === "any" ? any(...prepared) : all(...prepared);
|
|
718
|
+
}
|
|
719
|
+
const prepare = conditionPreparations.get(input);
|
|
720
|
+
return prepare ? prepare(ctx) : toCondition(input, combine);
|
|
721
|
+
}
|
|
722
|
+
function coerceOne(fn) {
|
|
723
|
+
return async (ctx, last) => {
|
|
724
|
+
const r = await fn(
|
|
725
|
+
ctx,
|
|
726
|
+
last
|
|
727
|
+
);
|
|
728
|
+
if (typeof r === "boolean") {
|
|
729
|
+
return { met: r, reason: `predicate: ${r}` };
|
|
730
|
+
}
|
|
731
|
+
if (r && typeof r === "object" && "met" in r) {
|
|
732
|
+
const res = r;
|
|
733
|
+
if (typeof res.met !== "boolean") {
|
|
734
|
+
throw new LoopError({
|
|
735
|
+
code: "VALIDATION",
|
|
736
|
+
message: `condition returned a non-boolean "met": ${String(res.met)}`
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
return r;
|
|
740
|
+
}
|
|
741
|
+
return { met: Boolean(r), reason: `coerced: ${String(r)}` };
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function predicate(fn, reason = "predicate") {
|
|
745
|
+
return async (ctx, last) => {
|
|
746
|
+
const met = await fn(ctx, last);
|
|
747
|
+
return { met, reason: met ? `${reason}: true` : `${reason}: false` };
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function bodyPassed() {
|
|
751
|
+
return async (_ctx, last) => ({
|
|
752
|
+
met: last?.status === "pass",
|
|
753
|
+
confidence: last?.confidence,
|
|
754
|
+
reason: `last body status = ${last?.status ?? "none"}`
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function minConfidence(threshold) {
|
|
758
|
+
return async (_ctx, last) => {
|
|
759
|
+
const c = last?.confidence ?? 0;
|
|
760
|
+
return {
|
|
761
|
+
met: c >= threshold,
|
|
762
|
+
confidence: c,
|
|
763
|
+
reason: `confidence ${c.toFixed(2)} ${c >= threshold ? ">=" : "<"} ${threshold}`
|
|
764
|
+
};
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function commandSucceeds(command, args = [], opts = {}) {
|
|
768
|
+
return setLabel(async (ctx) => {
|
|
769
|
+
try {
|
|
770
|
+
const env = resolveEnv(ctx, opts.env);
|
|
771
|
+
const r = await runRuntimeProcess({
|
|
772
|
+
executable: command,
|
|
773
|
+
args,
|
|
774
|
+
cwd: opts.cwd ?? ctx.workspace.dir,
|
|
775
|
+
timeoutMs: opts.timeoutMs,
|
|
776
|
+
signal: ctx.signal,
|
|
777
|
+
env
|
|
778
|
+
});
|
|
779
|
+
const stdout = processText(r.stdout);
|
|
780
|
+
const stderr = processText(r.stderr);
|
|
781
|
+
const all2 = `${stdout}${stderr}`;
|
|
782
|
+
if (r.exitCode === 0 && !r.timedOut) {
|
|
783
|
+
return { met: true, reason: `\`${command}\` exited 0` };
|
|
784
|
+
}
|
|
785
|
+
const baseReason = r.timedOut ? `\`${command}\` timed out after ${opts.timeoutMs} ms` : `\`${command}\` exited ${r.exitCode ?? "?"}`;
|
|
786
|
+
const tail = opts.captureOutput ? commandFailureTail(all2, env) : "";
|
|
787
|
+
return {
|
|
788
|
+
met: false,
|
|
789
|
+
reason: tail ? `${baseReason}
|
|
790
|
+
|
|
791
|
+
command output (tail):
|
|
792
|
+
${tail}` : baseReason,
|
|
793
|
+
// The evidence behind the failure, in the same shape `reviewContext`
|
|
794
|
+
// gives a judge. Scrubbed (`scrubCapture`) because it flows into JSONL
|
|
795
|
+
// event records — a failing command often echoes its config, and a
|
|
796
|
+
// pinned credential's shape is unknowable to pattern scrubbing.
|
|
797
|
+
output: `exit: ${r.exitCode ?? "(command did not run)"}
|
|
798
|
+
|
|
799
|
+
stdout:
|
|
800
|
+
${scrubCapture(stdout, env, 4e3)}
|
|
801
|
+
|
|
802
|
+
stderr:
|
|
803
|
+
${scrubCapture(stderr, env, 4e3)}`
|
|
804
|
+
};
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return {
|
|
807
|
+
met: false,
|
|
808
|
+
reason: `\`${command}\` failed to run: ${e instanceof Error ? e.message : String(e)}`
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}, `${command}${args.length ? ` ${args.join(" ")}` : ""}`);
|
|
812
|
+
}
|
|
813
|
+
var always = async () => ({ met: true, reason: "always" });
|
|
814
|
+
var never = async () => ({ met: false, reason: "never" });
|
|
815
|
+
function not(c) {
|
|
816
|
+
const cond = toCondition(c);
|
|
817
|
+
const condition = async (ctx, last) => {
|
|
818
|
+
const r = await cond(ctx, last);
|
|
819
|
+
return {
|
|
820
|
+
met: !r.met,
|
|
821
|
+
confidence: r.confidence,
|
|
822
|
+
reason: `not(${r.reason})`,
|
|
823
|
+
output: r.output
|
|
824
|
+
};
|
|
825
|
+
};
|
|
826
|
+
setMeta(condition, { kind: "condition", name: "not", inputs: [c] });
|
|
827
|
+
return withConditionPreparation(
|
|
828
|
+
condition,
|
|
829
|
+
async (ctx) => not(await prepareCondition(c, ctx))
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
function all(...inputs) {
|
|
833
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
834
|
+
const condition = async (ctx, last) => {
|
|
835
|
+
const results = [];
|
|
836
|
+
for (const c of conds) {
|
|
837
|
+
const r = await c(ctx, last);
|
|
838
|
+
results.push(r);
|
|
839
|
+
if (!r.met)
|
|
840
|
+
return {
|
|
841
|
+
met: false,
|
|
842
|
+
reason: `all -> failed: ${r.reason}`,
|
|
843
|
+
output: r.output
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
met: true,
|
|
848
|
+
reason: `all(${results.map((r) => r.reason).join(" & ")})`
|
|
849
|
+
};
|
|
850
|
+
};
|
|
851
|
+
setMeta(condition, { kind: "condition", name: "all", inputs });
|
|
852
|
+
return withConditionPreparation(
|
|
853
|
+
condition,
|
|
854
|
+
async (ctx) => all(...await Promise.all(inputs.map((input) => prepareCondition(input, ctx))))
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
function any(...inputs) {
|
|
858
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
859
|
+
const condition = async (ctx, last) => {
|
|
860
|
+
const reasons = [];
|
|
861
|
+
let output;
|
|
862
|
+
for (const c of conds) {
|
|
863
|
+
const r = await c(ctx, last);
|
|
864
|
+
reasons.push(r.reason);
|
|
865
|
+
if (r.met)
|
|
866
|
+
return {
|
|
867
|
+
met: true,
|
|
868
|
+
confidence: r.confidence,
|
|
869
|
+
reason: `any -> ${r.reason}`,
|
|
870
|
+
output: r.output
|
|
871
|
+
};
|
|
872
|
+
output ??= r.output;
|
|
873
|
+
}
|
|
874
|
+
return { met: false, reason: `any(${reasons.join(" | ")})`, output };
|
|
875
|
+
};
|
|
876
|
+
setMeta(condition, { kind: "condition", name: "any", inputs });
|
|
877
|
+
return withConditionPreparation(
|
|
878
|
+
condition,
|
|
879
|
+
async (ctx) => any(...await Promise.all(inputs.map((input) => prepareCondition(input, ctx))))
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
function quorum(k, ...inputs) {
|
|
883
|
+
if (k < 1 || k > inputs.length)
|
|
884
|
+
throw new LoopError({
|
|
885
|
+
code: "CONFIG",
|
|
886
|
+
message: `quorum requires 1 <= k <= inputs (got k=${k}, n=${inputs.length})`
|
|
887
|
+
});
|
|
888
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
889
|
+
const condition = setLabel(async (ctx, last) => {
|
|
890
|
+
const settled = await Promise.allSettled(conds.map((c) => c(ctx, last)));
|
|
891
|
+
const engineErrors = settled.filter(
|
|
892
|
+
(s) => s.status === "rejected" && isInfrastructureError(s.reason)
|
|
893
|
+
).map((s) => s.reason);
|
|
894
|
+
const results = settled.map((s) => {
|
|
895
|
+
if (s.status === "fulfilled") return s.value;
|
|
896
|
+
return {
|
|
897
|
+
met: false,
|
|
898
|
+
reason: `judge errored: ${s.reason instanceof Error ? s.reason.message : String(s.reason)}`
|
|
899
|
+
};
|
|
900
|
+
});
|
|
901
|
+
const held = results.filter((r) => r.met);
|
|
902
|
+
const output = results.find((r) => !r.met && r.output !== void 0)?.output;
|
|
903
|
+
if (held.length < k && held.length + engineErrors.length >= k) {
|
|
904
|
+
const first = engineErrors[0];
|
|
905
|
+
if (!output) throw first;
|
|
906
|
+
throw new LoopError({
|
|
907
|
+
code: first.code,
|
|
908
|
+
message: first.message,
|
|
909
|
+
phase: first.phase,
|
|
910
|
+
path: first.path,
|
|
911
|
+
iteration: first.iteration,
|
|
912
|
+
retryable: first.retryable,
|
|
913
|
+
retryAfterMs: first.retryAfterMs,
|
|
914
|
+
resetAt: first.resetAt,
|
|
915
|
+
cause: { error: first, output }
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
const confs = held.map((r) => r.confidence).filter((c) => typeof c === "number");
|
|
919
|
+
const confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : void 0;
|
|
920
|
+
const met = held.length >= k;
|
|
921
|
+
return {
|
|
922
|
+
met,
|
|
923
|
+
confidence,
|
|
924
|
+
reason: `quorum ${held.length}/${inputs.length} held (need ${k})`,
|
|
925
|
+
output: met ? void 0 : output
|
|
926
|
+
};
|
|
927
|
+
}, `quorum ${k}/${inputs.length}`);
|
|
928
|
+
setMeta(condition, { kind: "condition", name: "quorum", inputs });
|
|
929
|
+
return withConditionPreparation(
|
|
930
|
+
condition,
|
|
931
|
+
async (ctx) => quorum(
|
|
932
|
+
k,
|
|
933
|
+
...await Promise.all(inputs.map((input) => prepareCondition(input, ctx)))
|
|
934
|
+
)
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
function defaultContext(ctx, last) {
|
|
938
|
+
const parts = [];
|
|
939
|
+
if (last?.summary) parts.push(`Last outcome summary: ${last.summary}`);
|
|
940
|
+
if (last?.status) parts.push(`Last outcome status: ${last.status}`);
|
|
941
|
+
if (last?.data !== void 0)
|
|
942
|
+
parts.push(`Last outcome data: ${safeJson(last.data)}`);
|
|
943
|
+
const stateKeys = Object.keys(ctx.state);
|
|
944
|
+
if (stateKeys.length) parts.push(`Shared state: ${safeJson(ctx.state)}`);
|
|
945
|
+
return parts.join("\n") || "(no prior context)";
|
|
946
|
+
}
|
|
947
|
+
function safeJson(value, limit = 4e3) {
|
|
948
|
+
try {
|
|
949
|
+
const s = JSON.stringify(value, null, 2) ?? String(value);
|
|
950
|
+
return s.length > limit ? `${s.slice(0, limit)}\u2026 (truncated)` : s;
|
|
951
|
+
} catch {
|
|
952
|
+
return String(value);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
function* balancedObjects(text) {
|
|
956
|
+
let cursor = 0;
|
|
957
|
+
while (cursor < text.length) {
|
|
958
|
+
const start = text.indexOf("{", cursor);
|
|
959
|
+
if (start === -1) return;
|
|
960
|
+
let depth = 0;
|
|
961
|
+
let inString = false;
|
|
962
|
+
let escaped = false;
|
|
963
|
+
let end = -1;
|
|
964
|
+
for (let i = start; i < text.length; i += 1) {
|
|
965
|
+
const ch = text[i];
|
|
966
|
+
if (inString) {
|
|
967
|
+
if (escaped) escaped = false;
|
|
968
|
+
else if (ch === "\\") escaped = true;
|
|
969
|
+
else if (ch === '"') inString = false;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (ch === '"') inString = true;
|
|
973
|
+
else if (ch === "{") depth += 1;
|
|
974
|
+
else if (ch === "}" && --depth === 0) {
|
|
975
|
+
end = i;
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (end === -1) return;
|
|
980
|
+
yield text.slice(start, end + 1);
|
|
981
|
+
cursor = end + 1;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function toVerdict(obj) {
|
|
985
|
+
const verdict = obj.verdict === "yes" ? "yes" : "no";
|
|
986
|
+
const confidence = typeof obj.confidence === "number" ? clamp01(obj.confidence) : 0;
|
|
987
|
+
const reason = typeof obj.reason === "string" ? obj.reason : "(no reason given)";
|
|
988
|
+
return { verdict, confidence, reason };
|
|
989
|
+
}
|
|
990
|
+
function parseVerdict(text) {
|
|
991
|
+
let fallback;
|
|
992
|
+
for (const candidate of balancedObjects(text)) {
|
|
993
|
+
let parsed;
|
|
994
|
+
try {
|
|
995
|
+
parsed = JSON.parse(candidate);
|
|
996
|
+
} catch {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1000
|
+
continue;
|
|
1001
|
+
const obj = parsed;
|
|
1002
|
+
if ("verdict" in obj) return toVerdict(obj);
|
|
1003
|
+
fallback ??= obj;
|
|
1004
|
+
}
|
|
1005
|
+
if (fallback) return toVerdict(fallback);
|
|
1006
|
+
throw new LoopError({
|
|
1007
|
+
code: "VALIDATION",
|
|
1008
|
+
message: `validator returned no JSON verdict: ${text.slice(0, 200)}`
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
function clamp01(n) {
|
|
1012
|
+
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
1013
|
+
}
|
|
1014
|
+
var VALIDATOR_SYSTEM = 'You are a strict, sceptical evaluator. You judge whether a stated condition is truly met given the evidence. Do not be generous. The `confidence` field is MANDATORY: always include a number in 0..1 for how sure you are, and when in doubt give a LOW number \u2014 never omit it (an omitted confidence is treated as zero). Respond with ONLY a single JSON object and no other text:\n{"verdict":"yes"|"no","confidence":<number 0..1>,"reason":"<one sentence>"}';
|
|
1015
|
+
var CONFIDENCE_TAG_SYSTEM = "You are a rigorous, report-only reviewer. Do not edit anything and do not imply you will. Assess the evidence against the stated condition, listing each concern tied to a concrete location and a concrete failure scenario (not a vibe). Judge against the stated contract, not an ideal: do not penalise the absence of hardening the contract does not require, and when the evidence meets the contract and you cannot name a concrete fault, say so plainly. Close with a single final line and nothing after it: `<confidence>N%</confidence>` \u2014 N is an integer 0-100, where 100 means you found no genuine contract violation or real bug, and below 100 means at least one concrete, addressable concern is open.";
|
|
1016
|
+
function parseConfidenceTag(text) {
|
|
1017
|
+
const re = /<confidence>\s*([0-9]+(?:\.[0-9]+)?)\s*%?\s*<\/confidence>/gi;
|
|
1018
|
+
let m;
|
|
1019
|
+
let last = null;
|
|
1020
|
+
while ((m = re.exec(text)) !== null) last = m;
|
|
1021
|
+
if (!last) return null;
|
|
1022
|
+
let n = parseFloat(last[1]);
|
|
1023
|
+
if (n > 1) n = n / 100;
|
|
1024
|
+
return { confidence: clamp01(n), findings: text.slice(0, last.index).trim() };
|
|
1025
|
+
}
|
|
1026
|
+
function validatorScoreSystem(dimensions) {
|
|
1027
|
+
return `You are a strict, sceptical evaluator. Score how well the condition is met on EACH named dimension, from 0 (not at all) to 1 (fully). Do not be generous; when in doubt score low. Respond with ONLY a single JSON object and no other text:
|
|
1028
|
+
{"scores":{${dimensions.map((d) => `"${d}":<0..1>`).join(",")}},"reason":"<one sentence>"}`;
|
|
1029
|
+
}
|
|
1030
|
+
function geometricMean(values) {
|
|
1031
|
+
if (!values.length) return 0;
|
|
1032
|
+
if (values.some((v) => v <= 0)) return 0;
|
|
1033
|
+
return Math.exp(values.reduce((a, b) => a + Math.log(b), 0) / values.length);
|
|
1034
|
+
}
|
|
1035
|
+
var JUDGE_OUTPUT_CAP = 2e4;
|
|
1036
|
+
function judgeOutput(text) {
|
|
1037
|
+
return scrubCapture(text, void 0, JUDGE_OUTPUT_CAP);
|
|
1038
|
+
}
|
|
1039
|
+
function parseScores(text, dimensions) {
|
|
1040
|
+
for (const candidate of balancedObjects(text)) {
|
|
1041
|
+
let parsed;
|
|
1042
|
+
try {
|
|
1043
|
+
parsed = JSON.parse(candidate);
|
|
1044
|
+
} catch {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1048
|
+
continue;
|
|
1049
|
+
const raw = parsed.scores;
|
|
1050
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
1051
|
+
const scoreObj = raw;
|
|
1052
|
+
const scores = {};
|
|
1053
|
+
for (const d of dimensions) {
|
|
1054
|
+
const val = scoreObj[d];
|
|
1055
|
+
scores[d] = typeof val === "number" ? clamp01(val) : 0;
|
|
1056
|
+
}
|
|
1057
|
+
const reasonField = parsed.reason;
|
|
1058
|
+
return {
|
|
1059
|
+
score: geometricMean(dimensions.map((d) => scores[d])),
|
|
1060
|
+
scores,
|
|
1061
|
+
reason: typeof reasonField === "string" ? reasonField : "(no reason given)"
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
throw new LoopError({
|
|
1065
|
+
code: "VALIDATION",
|
|
1066
|
+
message: `validator returned no JSON scores: ${text.slice(0, 200)}`
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
function agentCheck(config) {
|
|
1070
|
+
const threshold = config.threshold ?? 0.8;
|
|
1071
|
+
const confidenceTag = config.confidenceTag === true;
|
|
1072
|
+
const maxReasonChars = config.maxReasonChars ?? 280;
|
|
1073
|
+
const dimensions = !confidenceTag && config.dimensions?.length ? config.dimensions : void 0;
|
|
1074
|
+
return setLabel(async (ctx, last) => {
|
|
1075
|
+
const engine = ctx.resolveEngine(config.engine);
|
|
1076
|
+
const contextText = await (config.context ?? defaultContext)(ctx, last);
|
|
1077
|
+
const acceptanceCriterion = ctx.reviewerGate ? `
|
|
1078
|
+
|
|
1079
|
+
ACCEPTANCE CRITERION:
|
|
1080
|
+
${ctx.reviewerGate}` : "";
|
|
1081
|
+
const closing = confidenceTag ? "Write your review now, then close with `<confidence>N%</confidence>`." : `Return the JSON ${dimensions ? "scores" : "verdict"} now.`;
|
|
1082
|
+
const prompt = `CONDITION TO EVALUATE:
|
|
1083
|
+
${config.question}
|
|
1084
|
+
|
|
1085
|
+
EVIDENCE:
|
|
1086
|
+
${contextText}${acceptanceCriterion}
|
|
1087
|
+
|
|
1088
|
+
` + closing;
|
|
1089
|
+
const baseSystem = confidenceTag ? CONFIDENCE_TAG_SYSTEM : dimensions ? validatorScoreSystem(dimensions) : VALIDATOR_SYSTEM;
|
|
1090
|
+
const system = config.agent ? `${resolveSystem(config.agent)}
|
|
1091
|
+
|
|
1092
|
+
${baseSystem}` : baseSystem;
|
|
1093
|
+
const env = resolveEnv(ctx);
|
|
1094
|
+
let result;
|
|
1095
|
+
try {
|
|
1096
|
+
assertBudget(ctx);
|
|
1097
|
+
result = await engine.run(
|
|
1098
|
+
{
|
|
1099
|
+
prompt,
|
|
1100
|
+
system,
|
|
1101
|
+
model: config.model ?? config.agent?.model,
|
|
1102
|
+
// A report-then-rate reviewer needs room for findings before the tag.
|
|
1103
|
+
maxTokens: config.maxTokens ?? (confidenceTag ? 2048 : 512),
|
|
1104
|
+
cwd: config.cwd,
|
|
1105
|
+
timeoutMs: config.timeoutMs ?? ctx.timeoutMs,
|
|
1106
|
+
timeoutGraceMs: ctx.timeoutGraceMs,
|
|
1107
|
+
env,
|
|
1108
|
+
leaf: true,
|
|
1109
|
+
attempt: attemptRequestMeta(ctx, config.agent?.name ?? "agent-check")
|
|
1110
|
+
},
|
|
1111
|
+
(e) => {
|
|
1112
|
+
if (e.type === "usage") {
|
|
1113
|
+
ctx.emit({
|
|
1114
|
+
kind: "engine:usage",
|
|
1115
|
+
ts: Date.now(),
|
|
1116
|
+
path: [...ctx.path],
|
|
1117
|
+
model: e.model,
|
|
1118
|
+
usage: e.usage
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
ctx.signal
|
|
1123
|
+
);
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
throw LoopError.from(e, { code: "ENGINE", path: ctx.path });
|
|
1126
|
+
}
|
|
1127
|
+
logEngineTransportFailure(ctx, result, env);
|
|
1128
|
+
const text = redactEnvValues(requireFinalResultText(result), env);
|
|
1129
|
+
if (confidenceTag) {
|
|
1130
|
+
const parsed = parseConfidenceTag(text);
|
|
1131
|
+
if (!parsed)
|
|
1132
|
+
return {
|
|
1133
|
+
met: false,
|
|
1134
|
+
confidence: 0,
|
|
1135
|
+
reason: `no <confidence> tag: ${redactSecrets(text).slice(0, 140)}`,
|
|
1136
|
+
output: judgeOutput(text)
|
|
1137
|
+
};
|
|
1138
|
+
const pct = Math.round(parsed.confidence * 100);
|
|
1139
|
+
const need = Math.round(threshold * 100);
|
|
1140
|
+
const findings = redactSecrets(parsed.findings);
|
|
1141
|
+
return {
|
|
1142
|
+
met: parsed.confidence >= threshold,
|
|
1143
|
+
confidence: parsed.confidence,
|
|
1144
|
+
reason: `confidence ${pct}% (need ${need}%)${findings ? ` \u2014 ${findings.slice(0, maxReasonChars)}` : ""}`,
|
|
1145
|
+
// The FULL findings — what lets a failing check brief a fix-up body
|
|
1146
|
+
// without re-running the judge. A tag-only reply has no evidence to carry.
|
|
1147
|
+
output: findings ? truncate(findings, JUDGE_OUTPUT_CAP) : void 0
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (dimensions) {
|
|
1151
|
+
let sv;
|
|
1152
|
+
try {
|
|
1153
|
+
sv = parseScores(text, dimensions);
|
|
1154
|
+
} catch {
|
|
1155
|
+
return {
|
|
1156
|
+
met: false,
|
|
1157
|
+
confidence: 0,
|
|
1158
|
+
reason: `unparseable scores: ${redactSecrets(text).slice(0, 120)}`,
|
|
1159
|
+
output: judgeOutput(text)
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
const detail = dimensions.map((d) => `${d}=${sv.scores[d].toFixed(2)}`).join(", ");
|
|
1163
|
+
return {
|
|
1164
|
+
met: sv.score >= threshold,
|
|
1165
|
+
confidence: sv.score,
|
|
1166
|
+
// The judge's free-prose reason gets the same scrub as `output` — it
|
|
1167
|
+
// flows into the same persisted records.
|
|
1168
|
+
reason: `geo ${sv.score.toFixed(2)} (need ${threshold}) [${detail}] \u2014 ${redactSecrets(sv.reason)}`,
|
|
1169
|
+
output: judgeOutput(text)
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
let v;
|
|
1173
|
+
try {
|
|
1174
|
+
v = parseVerdict(text);
|
|
1175
|
+
} catch {
|
|
1176
|
+
return {
|
|
1177
|
+
met: false,
|
|
1178
|
+
confidence: 0,
|
|
1179
|
+
reason: `unparseable verdict: ${redactSecrets(text).slice(0, 120)}`,
|
|
1180
|
+
output: judgeOutput(text)
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const met = v.verdict === "yes" && v.confidence >= threshold;
|
|
1184
|
+
return {
|
|
1185
|
+
met,
|
|
1186
|
+
confidence: v.confidence,
|
|
1187
|
+
reason: `${v.verdict} @ ${v.confidence.toFixed(2)} (need ${threshold}) \u2014 ${redactSecrets(v.reason)}`,
|
|
1188
|
+
output: judgeOutput(text)
|
|
1189
|
+
};
|
|
1190
|
+
}, `judge "${config.question}" >=${threshold}`);
|
|
1191
|
+
}
|
|
1192
|
+
function gateJob(label, condition, opts = {}) {
|
|
1193
|
+
const cond = toCondition(condition);
|
|
1194
|
+
return setMeta(async (ctx) => {
|
|
1195
|
+
ctx.emit({
|
|
1196
|
+
kind: "job:start",
|
|
1197
|
+
ts: Date.now(),
|
|
1198
|
+
path: [...ctx.path],
|
|
1199
|
+
label,
|
|
1200
|
+
timeoutMs: ctx.timeoutMs
|
|
1201
|
+
});
|
|
1202
|
+
const r = await cond(ctx, ctx.lastOutcome);
|
|
1203
|
+
ctx.emit({
|
|
1204
|
+
kind: "condition:result",
|
|
1205
|
+
ts: Date.now(),
|
|
1206
|
+
path: [...ctx.path],
|
|
1207
|
+
label,
|
|
1208
|
+
iteration: ctx.iteration,
|
|
1209
|
+
result: r
|
|
1210
|
+
});
|
|
1211
|
+
const outcome = !r.met && opts.target !== void 0 ? {
|
|
1212
|
+
status: "fail",
|
|
1213
|
+
confidence: r.confidence,
|
|
1214
|
+
summary: r.reason,
|
|
1215
|
+
data: r.output,
|
|
1216
|
+
revision: {
|
|
1217
|
+
target: opts.target,
|
|
1218
|
+
reason: r.reason,
|
|
1219
|
+
findings: [{ evidence: r.output ?? r.reason, severity: "block" }],
|
|
1220
|
+
rerun: "target-and-dependents"
|
|
1221
|
+
}
|
|
1222
|
+
} : {
|
|
1223
|
+
status: r.met ? "pass" : "fail",
|
|
1224
|
+
confidence: r.confidence,
|
|
1225
|
+
summary: r.reason
|
|
1226
|
+
};
|
|
1227
|
+
if (r.output !== void 0 && outcome.data === void 0) outcome.data = r.output;
|
|
1228
|
+
ctx.emit({
|
|
1229
|
+
kind: "job:end",
|
|
1230
|
+
ts: Date.now(),
|
|
1231
|
+
path: [...ctx.path],
|
|
1232
|
+
label,
|
|
1233
|
+
outcome
|
|
1234
|
+
});
|
|
1235
|
+
return outcome;
|
|
1236
|
+
}, { kind: "gate", name: label });
|
|
1237
|
+
}
|
|
1238
|
+
function commandJob(label, command, opts = {}) {
|
|
1239
|
+
const [executable, ...args] = splitCommand(command);
|
|
1240
|
+
return gateJob(
|
|
1241
|
+
label,
|
|
1242
|
+
commandSucceeds(executable, args, {
|
|
1243
|
+
...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
|
|
1244
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
|
|
1245
|
+
...opts.env !== void 0 ? { env: opts.env } : {},
|
|
1246
|
+
captureOutput: opts.capture ?? true
|
|
1247
|
+
}),
|
|
1248
|
+
opts.target !== void 0 ? { target: opts.target } : {}
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
function splitCommand(command) {
|
|
1252
|
+
if (typeof command === "string" && /["']/.test(command)) {
|
|
1253
|
+
throw new LoopError({
|
|
1254
|
+
code: "VALIDATION",
|
|
1255
|
+
message: `commandJob: "${command}" has a quote in it; pass the command as an array, one argument per entry`
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (typeof command === "string" && /[|&;<>$`()]/.test(command)) {
|
|
1259
|
+
throw new LoopError({
|
|
1260
|
+
code: "VALIDATION",
|
|
1261
|
+
message: `commandJob: "${command}" has shell syntax in it, and nothing here runs through a shell; give one command as an array, one argument per entry, or run a script`
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
const parts = typeof command === "string" ? command.trim().split(/\s+/) : [...command];
|
|
1265
|
+
const [executable, ...args] = parts;
|
|
1266
|
+
if (executable === void 0 || executable === "") {
|
|
1267
|
+
throw new LoopError({ code: "VALIDATION", message: "commandJob needs a command to run" });
|
|
1268
|
+
}
|
|
1269
|
+
return [executable, ...args];
|
|
1270
|
+
}
|
|
1271
|
+
function needOutcome(ctx, name, caller) {
|
|
1272
|
+
const outcome = ctx.needs?.[name];
|
|
1273
|
+
if (outcome === void 0) {
|
|
1274
|
+
throw new LoopError({
|
|
1275
|
+
code: "VALIDATION",
|
|
1276
|
+
message: `${caller}("${name}"): "${name}" is not a dependency of this node; add it to the node's needs`
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return outcome;
|
|
1280
|
+
}
|
|
1281
|
+
function passed(name) {
|
|
1282
|
+
const cond = async (ctx) => {
|
|
1283
|
+
const outcome = needOutcome(ctx, name, "passed");
|
|
1284
|
+
const met = outcome.status === "pass";
|
|
1285
|
+
return { met, reason: `${name} ${outcome.status}` };
|
|
1286
|
+
};
|
|
1287
|
+
setMeta(cond, { kind: "condition", name: "passed", need: name });
|
|
1288
|
+
return setLabel(cond, `passed ${name}`);
|
|
1289
|
+
}
|
|
1290
|
+
function failed(name) {
|
|
1291
|
+
const cond = async (ctx) => {
|
|
1292
|
+
const outcome = needOutcome(ctx, name, "failed");
|
|
1293
|
+
const met = outcome.status === "fail";
|
|
1294
|
+
return { met, reason: `${name} ${outcome.status}` };
|
|
1295
|
+
};
|
|
1296
|
+
setMeta(cond, { kind: "condition", name: "failed", need: name });
|
|
1297
|
+
return setLabel(cond, `failed ${name}`);
|
|
1298
|
+
}
|
|
1299
|
+
function needDecisionsOf(input) {
|
|
1300
|
+
if (Array.isArray(input)) return input.flatMap((item) => needDecisionsOf(item));
|
|
1301
|
+
if (typeof input !== "function") return [];
|
|
1302
|
+
const meta = jobMeta(input);
|
|
1303
|
+
if (meta?.kind !== "condition") return [];
|
|
1304
|
+
if ((meta.name === "passed" || meta.name === "failed") && typeof meta.need === "string") {
|
|
1305
|
+
return [{ on: meta.name, need: meta.need }];
|
|
1306
|
+
}
|
|
1307
|
+
if (meta.name === "all" && Array.isArray(meta.inputs)) {
|
|
1308
|
+
return meta.inputs.flatMap((item) => needDecisionsOf(item));
|
|
1309
|
+
}
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
var DEFAULT_FANOUT_CONCURRENCY = 4;
|
|
1313
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
1314
|
+
const limit = pLimit(concurrency);
|
|
1315
|
+
return Promise.all(items.map((item, index) => limit(() => fn(item, index))));
|
|
1316
|
+
}
|
|
1317
|
+
async function git(args, { cwd, signal }, input) {
|
|
1318
|
+
const r = await runRuntimeProcess({
|
|
1319
|
+
executable: "git",
|
|
1320
|
+
args,
|
|
1321
|
+
cwd,
|
|
1322
|
+
signal,
|
|
1323
|
+
...input === void 0 ? {} : { stdin: input }
|
|
1324
|
+
});
|
|
1325
|
+
return {
|
|
1326
|
+
stdout: processText(r.stdout).replace(/\r?\n$/u, ""),
|
|
1327
|
+
exitCode: r.exitCode ?? 1
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
async function isRepo(opts) {
|
|
1331
|
+
try {
|
|
1332
|
+
const r = await git(["rev-parse", "--is-inside-work-tree"], opts);
|
|
1333
|
+
return r.exitCode === 0 && r.stdout.trim() === "true";
|
|
1334
|
+
} catch {
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
async function currentBranch(opts) {
|
|
1339
|
+
const r = await git(["rev-parse", "--abbrev-ref", "HEAD"], opts);
|
|
1340
|
+
if (r.exitCode !== 0) return void 0;
|
|
1341
|
+
const name = r.stdout.trim();
|
|
1342
|
+
return name && name !== "HEAD" ? name : void 0;
|
|
1343
|
+
}
|
|
1344
|
+
async function gitRoot(opts) {
|
|
1345
|
+
const r = await git(["rev-parse", "--show-toplevel"], opts);
|
|
1346
|
+
return r.exitCode === 0 ? r.stdout.trim() || void 0 : void 0;
|
|
1347
|
+
}
|
|
1348
|
+
async function headSha(opts) {
|
|
1349
|
+
const r = await git(["rev-parse", "HEAD"], opts);
|
|
1350
|
+
return r.exitCode === 0 ? r.stdout.trim() || void 0 : void 0;
|
|
1351
|
+
}
|
|
1352
|
+
async function stageAll(opts) {
|
|
1353
|
+
await git(["add", "-A"], opts);
|
|
1354
|
+
}
|
|
1355
|
+
async function hasStagedChanges(opts) {
|
|
1356
|
+
const r = await git(["diff", "--cached", "--quiet"], opts);
|
|
1357
|
+
return r.exitCode === 1;
|
|
1358
|
+
}
|
|
1359
|
+
async function workspaceFingerprint(opts) {
|
|
1360
|
+
try {
|
|
1361
|
+
if (!await isRepo(opts)) return void 0;
|
|
1362
|
+
const excluded = (opts.excludePaths ?? []).map((p) => relative(opts.cwd, resolve(opts.cwd, p)).replace(/\\/g, "/")).filter((p) => p && !p.startsWith("..") && p !== ".");
|
|
1363
|
+
const included = [...new Set(opts.includePaths ?? [])].map((p) => p.trim()).filter(Boolean).sort();
|
|
1364
|
+
const scoped = included.length > 0;
|
|
1365
|
+
const pathspec = scoped || excluded.length ? [
|
|
1366
|
+
"--",
|
|
1367
|
+
...scoped ? included : ["."],
|
|
1368
|
+
...excluded.map((p) => `:(exclude)${p}`)
|
|
1369
|
+
] : [];
|
|
1370
|
+
const output = async (args) => {
|
|
1371
|
+
const result = await git(args, opts);
|
|
1372
|
+
if (result.exitCode !== 0)
|
|
1373
|
+
throw new Error(`git ${args[0] ?? "command"} failed`);
|
|
1374
|
+
return result.stdout;
|
|
1375
|
+
};
|
|
1376
|
+
const hash = createHash("sha256");
|
|
1377
|
+
const feed = (label, value) => {
|
|
1378
|
+
hash.update(label);
|
|
1379
|
+
hash.update("");
|
|
1380
|
+
hash.update(value);
|
|
1381
|
+
hash.update("");
|
|
1382
|
+
};
|
|
1383
|
+
if (scoped) {
|
|
1384
|
+
feed("scope", included.join("\n"));
|
|
1385
|
+
feed(
|
|
1386
|
+
"tracked",
|
|
1387
|
+
await output(["ls-files", "--stage", ...pathspec])
|
|
1388
|
+
);
|
|
1389
|
+
} else {
|
|
1390
|
+
feed("head", await headSha(opts) ?? "");
|
|
1391
|
+
}
|
|
1392
|
+
feed(
|
|
1393
|
+
"status",
|
|
1394
|
+
await output(["status", "--porcelain", ...pathspec])
|
|
1395
|
+
);
|
|
1396
|
+
feed("unstaged", await output(["diff", ...pathspec]));
|
|
1397
|
+
feed("staged", await output(["diff", "--cached", ...pathspec]));
|
|
1398
|
+
const untracked = (await output([
|
|
1399
|
+
"ls-files",
|
|
1400
|
+
"--others",
|
|
1401
|
+
...scoped ? [] : ["--exclude-standard"],
|
|
1402
|
+
...pathspec
|
|
1403
|
+
])).split("\n").filter(Boolean);
|
|
1404
|
+
for (let i = 0; i < untracked.length; i += 500) {
|
|
1405
|
+
const chunk = untracked.slice(i, i + 500);
|
|
1406
|
+
feed(`untracked-paths:${i}`, chunk.join("\n"));
|
|
1407
|
+
feed(
|
|
1408
|
+
`untracked-content:${i}`,
|
|
1409
|
+
await output(["hash-object", "--", ...chunk])
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
return hash.digest("hex");
|
|
1413
|
+
} catch {
|
|
1414
|
+
return void 0;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
function parseDirtyStatus(output) {
|
|
1418
|
+
const records = output.split("\0");
|
|
1419
|
+
const entries = [];
|
|
1420
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
1421
|
+
const record = records[index];
|
|
1422
|
+
if (!record) continue;
|
|
1423
|
+
if (record.length < 4 || record[2] !== " ") {
|
|
1424
|
+
throw new Error("git status returned an invalid porcelain record");
|
|
1425
|
+
}
|
|
1426
|
+
const status = record.slice(0, 2);
|
|
1427
|
+
entries.push({ status, path: record.slice(3) });
|
|
1428
|
+
if (status.includes("R") || status.includes("C")) {
|
|
1429
|
+
const source = records[index + 1];
|
|
1430
|
+
if (!source) throw new Error("git status omitted a rename source");
|
|
1431
|
+
entries.push({ status: `${status}:source`, path: source });
|
|
1432
|
+
index += 1;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return entries;
|
|
1436
|
+
}
|
|
1437
|
+
function parseIndexState(output) {
|
|
1438
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1439
|
+
for (const record of output.split("\0")) {
|
|
1440
|
+
if (!record) continue;
|
|
1441
|
+
const match = /^(\d{6}) ([0-9a-f]+) (\d)\t(.+)$/u.exec(record);
|
|
1442
|
+
if (!match) throw new Error("git index returned an invalid entry");
|
|
1443
|
+
const [, mode, oid, stage, path] = match;
|
|
1444
|
+
if (stage !== "0") {
|
|
1445
|
+
throw new Error(`git index has an unresolved entry at ${path}`);
|
|
1446
|
+
}
|
|
1447
|
+
if (entries.has(path)) {
|
|
1448
|
+
throw new Error(`git index returned a duplicate entry at ${path}`);
|
|
1449
|
+
}
|
|
1450
|
+
entries.set(path, { mode, oid });
|
|
1451
|
+
}
|
|
1452
|
+
return entries;
|
|
1453
|
+
}
|
|
1454
|
+
function lineDigests(bytes) {
|
|
1455
|
+
if (bytes.byteLength === 0) return Object.freeze([]);
|
|
1456
|
+
const lines = [];
|
|
1457
|
+
let start = 0;
|
|
1458
|
+
for (let index = 0; index < bytes.byteLength; index += 1) {
|
|
1459
|
+
if (bytes[index] !== 10) continue;
|
|
1460
|
+
lines.push(
|
|
1461
|
+
createHash("sha256").update(bytes.subarray(start, index + 1)).digest("hex")
|
|
1462
|
+
);
|
|
1463
|
+
start = index + 1;
|
|
1464
|
+
}
|
|
1465
|
+
if (start < bytes.byteLength) {
|
|
1466
|
+
lines.push(createHash("sha256").update(bytes.subarray(start)).digest("hex"));
|
|
1467
|
+
}
|
|
1468
|
+
return Object.freeze(lines);
|
|
1469
|
+
}
|
|
1470
|
+
async function dirtyState(root, entry) {
|
|
1471
|
+
const path = join(root, entry.path);
|
|
1472
|
+
try {
|
|
1473
|
+
const stat2 = await lstat(path);
|
|
1474
|
+
let bytes;
|
|
1475
|
+
let kind;
|
|
1476
|
+
if (stat2.isFile()) {
|
|
1477
|
+
kind = "file";
|
|
1478
|
+
bytes = await readFile(path);
|
|
1479
|
+
} else if (stat2.isSymbolicLink()) {
|
|
1480
|
+
kind = "symlink";
|
|
1481
|
+
bytes = await readlink(path, { encoding: "buffer" });
|
|
1482
|
+
} else {
|
|
1483
|
+
throw new Error(
|
|
1484
|
+
`git workspace contains an unsupported dirty entry at ${entry.path}`
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
status: entry.status,
|
|
1489
|
+
kind,
|
|
1490
|
+
mode: stat2.mode,
|
|
1491
|
+
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
1492
|
+
lines: lineDigests(bytes)
|
|
1493
|
+
};
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
const code = error.code;
|
|
1496
|
+
if (code === "ENOENT") {
|
|
1497
|
+
return {
|
|
1498
|
+
status: entry.status,
|
|
1499
|
+
kind: "missing",
|
|
1500
|
+
mode: 0,
|
|
1501
|
+
digest: null,
|
|
1502
|
+
lines: Object.freeze([])
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
throw error;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
async function captureGitWorkspaceSnapshot(opts) {
|
|
1509
|
+
const root = await gitRoot(opts);
|
|
1510
|
+
if (!root) throw new Error("not a git repository");
|
|
1511
|
+
const commandOpts = { ...opts, cwd: root };
|
|
1512
|
+
const included = [...new Set(opts.includePaths ?? [])].map((path) => path.trim()).filter(Boolean).sort();
|
|
1513
|
+
const pathspec = included.length > 0 ? ["--", ...included] : [];
|
|
1514
|
+
const [head, indexResult, statusResult] = await Promise.all([
|
|
1515
|
+
headSha(commandOpts),
|
|
1516
|
+
git([
|
|
1517
|
+
...pathspec.length === 0 ? ["--literal-pathspecs"] : [],
|
|
1518
|
+
"ls-files",
|
|
1519
|
+
"--stage",
|
|
1520
|
+
"-z",
|
|
1521
|
+
...pathspec
|
|
1522
|
+
], commandOpts),
|
|
1523
|
+
git(
|
|
1524
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all", ...pathspec],
|
|
1525
|
+
commandOpts
|
|
1526
|
+
)
|
|
1527
|
+
]);
|
|
1528
|
+
if (indexResult.exitCode !== 0) throw new Error("git index inspection failed");
|
|
1529
|
+
if (statusResult.exitCode !== 0) throw new Error("git status inspection failed");
|
|
1530
|
+
const index = parseIndexState(indexResult.stdout);
|
|
1531
|
+
const dirtyEntries = parseDirtyStatus(statusResult.stdout);
|
|
1532
|
+
const dirty = /* @__PURE__ */ new Map();
|
|
1533
|
+
for (const entry of dirtyEntries) {
|
|
1534
|
+
dirty.set(entry.path, await dirtyState(root, entry));
|
|
1535
|
+
}
|
|
1536
|
+
const paths = [.../* @__PURE__ */ new Set([...index.keys(), ...dirty.keys()])].sort();
|
|
1537
|
+
const files = paths.map((path) => Object.freeze({
|
|
1538
|
+
path,
|
|
1539
|
+
index: index.get(path) ?? null,
|
|
1540
|
+
dirty: dirty.get(path) ?? null
|
|
1541
|
+
}));
|
|
1542
|
+
return Object.freeze({
|
|
1543
|
+
root,
|
|
1544
|
+
head: head ?? null,
|
|
1545
|
+
files: Object.freeze(files)
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
function fileStateKey(value) {
|
|
1549
|
+
if (!value) return "[absent]";
|
|
1550
|
+
return JSON.stringify({ index: value.index, dirty: value.dirty });
|
|
1551
|
+
}
|
|
1552
|
+
function lineEditCount(before, after) {
|
|
1553
|
+
if (before.length === after.length && before.every((line, index) => line === after[index])) {
|
|
1554
|
+
return 0;
|
|
1555
|
+
}
|
|
1556
|
+
const maximum = before.length + after.length;
|
|
1557
|
+
let frontier = /* @__PURE__ */ new Map([[1, 0]]);
|
|
1558
|
+
for (let distance = 0; distance <= maximum; distance += 1) {
|
|
1559
|
+
const next = /* @__PURE__ */ new Map();
|
|
1560
|
+
for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
|
|
1561
|
+
const down = frontier.get(diagonal + 1) ?? -1;
|
|
1562
|
+
const right = (frontier.get(diagonal - 1) ?? -1) + 1;
|
|
1563
|
+
let x = diagonal === -distance || diagonal !== distance && right < down ? down : right;
|
|
1564
|
+
let y = x - diagonal;
|
|
1565
|
+
while (x < before.length && y < after.length && before[x] === after[y]) {
|
|
1566
|
+
x += 1;
|
|
1567
|
+
y += 1;
|
|
1568
|
+
}
|
|
1569
|
+
if (x >= before.length && y >= after.length) return distance;
|
|
1570
|
+
next.set(diagonal, x);
|
|
1571
|
+
}
|
|
1572
|
+
frontier = next;
|
|
1573
|
+
}
|
|
1574
|
+
return maximum;
|
|
1575
|
+
}
|
|
1576
|
+
async function blobLines(root, oid, cache, signal) {
|
|
1577
|
+
if (!oid) return Object.freeze([]);
|
|
1578
|
+
const cached = cache.get(oid);
|
|
1579
|
+
if (cached) return cached;
|
|
1580
|
+
const result = await runRuntimeProcess({
|
|
1581
|
+
executable: "git",
|
|
1582
|
+
args: ["cat-file", "blob", oid],
|
|
1583
|
+
cwd: root,
|
|
1584
|
+
signal
|
|
1585
|
+
});
|
|
1586
|
+
if (result.exitCode !== 0) throw new Error(`git cannot read workspace blob ${oid}`);
|
|
1587
|
+
const lines = lineDigests(result.stdout);
|
|
1588
|
+
cache.set(oid, lines);
|
|
1589
|
+
return lines;
|
|
1590
|
+
}
|
|
1591
|
+
async function worktreeLines(root, state, cache, signal) {
|
|
1592
|
+
if (!state) return Object.freeze([]);
|
|
1593
|
+
if (state.dirty) return state.dirty.lines;
|
|
1594
|
+
return await blobLines(root, state.index?.oid, cache, signal);
|
|
1595
|
+
}
|
|
1596
|
+
async function compareGitWorkspaceSnapshots(before, after, signal) {
|
|
1597
|
+
if (before.root !== after.root) {
|
|
1598
|
+
throw new Error("git workspace root changed during the attempt");
|
|
1599
|
+
}
|
|
1600
|
+
const beforeFiles = new Map(before.files.map((file) => [file.path, file]));
|
|
1601
|
+
const afterFiles = new Map(after.files.map((file) => [file.path, file]));
|
|
1602
|
+
const changedPaths = [.../* @__PURE__ */ new Set([
|
|
1603
|
+
...beforeFiles.keys(),
|
|
1604
|
+
...afterFiles.keys()
|
|
1605
|
+
])].filter(
|
|
1606
|
+
(path) => fileStateKey(beforeFiles.get(path)) !== fileStateKey(afterFiles.get(path))
|
|
1607
|
+
).sort();
|
|
1608
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1609
|
+
let linesChanged = 0;
|
|
1610
|
+
for (const path of changedPaths) {
|
|
1611
|
+
const beforeState = beforeFiles.get(path);
|
|
1612
|
+
const afterState = afterFiles.get(path);
|
|
1613
|
+
const [beforeWorktree, afterWorktree, beforeIndex, afterIndex] = await Promise.all([
|
|
1614
|
+
worktreeLines(before.root, beforeState, cache, signal),
|
|
1615
|
+
worktreeLines(after.root, afterState, cache, signal),
|
|
1616
|
+
blobLines(before.root, beforeState?.index?.oid, cache, signal),
|
|
1617
|
+
blobLines(after.root, afterState?.index?.oid, cache, signal)
|
|
1618
|
+
]);
|
|
1619
|
+
linesChanged += Math.max(
|
|
1620
|
+
lineEditCount(beforeWorktree, afterWorktree),
|
|
1621
|
+
lineEditCount(beforeIndex, afterIndex)
|
|
1622
|
+
);
|
|
1623
|
+
if (!Number.isSafeInteger(linesChanged)) {
|
|
1624
|
+
throw new Error("workspace line count exceeded the safe integer range");
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return Object.freeze({
|
|
1628
|
+
headChanged: before.head !== after.head,
|
|
1629
|
+
changedPaths: Object.freeze(changedPaths),
|
|
1630
|
+
filesChanged: changedPaths.length,
|
|
1631
|
+
linesChanged
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
async function commit(input, opts) {
|
|
1635
|
+
if (!input.allowEmpty && !await hasStagedChanges(opts)) return void 0;
|
|
1636
|
+
const message = input.body ? `${input.subject}
|
|
1637
|
+
|
|
1638
|
+
${input.body}
|
|
1639
|
+
` : `${input.subject}
|
|
1640
|
+
`;
|
|
1641
|
+
const args = ["commit", "-F", "-"];
|
|
1642
|
+
if (input.allowEmpty) args.push("--allow-empty");
|
|
1643
|
+
const r = await git(args, opts, message);
|
|
1644
|
+
if (r.exitCode !== 0) {
|
|
1645
|
+
throw new Error(
|
|
1646
|
+
`git commit failed (exit ${r.exitCode}): ${r.stdout}`.trim()
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
return headSha(opts);
|
|
1650
|
+
}
|
|
1651
|
+
async function branchCommits(opts) {
|
|
1652
|
+
const field = "";
|
|
1653
|
+
const record = "";
|
|
1654
|
+
const result = await git(
|
|
1655
|
+
["log", `-n${opts.max}`, `--format=%s${field}%b${record}`, opts.ref],
|
|
1656
|
+
opts
|
|
1657
|
+
);
|
|
1658
|
+
if (result.exitCode !== 0) return [];
|
|
1659
|
+
return result.stdout.split(record).map((value) => value.replace(/^\n+/, "").split(field)).filter((fields) => fields[0]?.trim()).map(([subject, body]) => ({
|
|
1660
|
+
subject: subject.trim(),
|
|
1661
|
+
body: (body ?? "").trim()
|
|
1662
|
+
}));
|
|
1663
|
+
}
|
|
1664
|
+
var worktreeQueues = /* @__PURE__ */ new Map();
|
|
1665
|
+
async function withWorktreeQueue(repoDir, signal, operation) {
|
|
1666
|
+
const common = await git(["rev-parse", "--git-common-dir"], { cwd: repoDir, signal });
|
|
1667
|
+
if (common.exitCode !== 0) throw new Error("git common directory unavailable");
|
|
1668
|
+
const key = await realpath(resolve(repoDir, common.stdout.trim()));
|
|
1669
|
+
const result = (worktreeQueues.get(key) ?? Promise.resolve()).then(operation);
|
|
1670
|
+
const tail = result.then(() => void 0, () => void 0);
|
|
1671
|
+
worktreeQueues.set(key, tail);
|
|
1672
|
+
try {
|
|
1673
|
+
return await result;
|
|
1674
|
+
} finally {
|
|
1675
|
+
if (worktreeQueues.get(key) === tail) worktreeQueues.delete(key);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async function addWorktree(repoDir, opts) {
|
|
1679
|
+
return withWorktreeQueue(repoDir, opts.signal, async () => {
|
|
1680
|
+
const dir = mkdtempSync(join(tmpdir(), "lines-wt-"));
|
|
1681
|
+
const r = await git(
|
|
1682
|
+
["worktree", "add", "-b", opts.branch, dir, opts.base ?? "HEAD"],
|
|
1683
|
+
{ cwd: repoDir, signal: opts.signal }
|
|
1684
|
+
);
|
|
1685
|
+
if (r.exitCode !== 0)
|
|
1686
|
+
throw new Error(
|
|
1687
|
+
`git worktree add failed (exit ${r.exitCode}): ${r.stdout}`.trim()
|
|
1688
|
+
);
|
|
1689
|
+
return { dir, branch: opts.branch };
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
async function removeWorktree(repoDir, dir, opts = {}) {
|
|
1693
|
+
await withWorktreeQueue(repoDir, opts.signal, () => git(["worktree", "remove", "--force", dir], {
|
|
1694
|
+
cwd: repoDir,
|
|
1695
|
+
signal: opts.signal
|
|
1696
|
+
}));
|
|
1697
|
+
}
|
|
1698
|
+
async function deleteBranch(repoDir, branch, opts = {}) {
|
|
1699
|
+
await git(["branch", "-D", branch], { cwd: repoDir, signal: opts.signal });
|
|
1700
|
+
}
|
|
1701
|
+
async function mergeBranch(repoDir, branch, opts = {}) {
|
|
1702
|
+
const r = await git(
|
|
1703
|
+
["merge", "--no-ff", "-m", opts.message ?? `merge ${branch}`, branch],
|
|
1704
|
+
{ cwd: repoDir, signal: opts.signal }
|
|
1705
|
+
);
|
|
1706
|
+
if (r.exitCode === 0) return { ok: true, conflict: false };
|
|
1707
|
+
await git(["merge", "--abort"], { cwd: repoDir, signal: opts.signal });
|
|
1708
|
+
return { ok: false, conflict: true };
|
|
1709
|
+
}
|
|
1710
|
+
async function mergeNoCommit(repoDir, branch, opts = {}) {
|
|
1711
|
+
const r = await git(["merge", "--no-ff", "--no-commit", branch], {
|
|
1712
|
+
cwd: repoDir,
|
|
1713
|
+
signal: opts.signal
|
|
1714
|
+
});
|
|
1715
|
+
if (r.exitCode === 0) return { clean: true, conflicted: [] };
|
|
1716
|
+
return { clean: false, conflicted: await conflictedFiles(repoDir, opts) };
|
|
1717
|
+
}
|
|
1718
|
+
async function conflictedFiles(repoDir, opts = {}) {
|
|
1719
|
+
const r = await git(["diff", "--name-only", "--diff-filter=U"], {
|
|
1720
|
+
cwd: repoDir,
|
|
1721
|
+
signal: opts.signal
|
|
1722
|
+
});
|
|
1723
|
+
return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1724
|
+
}
|
|
1725
|
+
async function mergeAbort(repoDir, opts = {}) {
|
|
1726
|
+
await git(["merge", "--abort"], { cwd: repoDir, signal: opts.signal });
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// src/core/feedback.ts
|
|
1730
|
+
function normalizeFeedbackSeverity(severity) {
|
|
1731
|
+
return severity ?? "block";
|
|
1732
|
+
}
|
|
1733
|
+
function findingLine(finding) {
|
|
1734
|
+
const reviewer = finding.reviewer ? `${finding.reviewer} ` : "";
|
|
1735
|
+
const severity = normalizeFeedbackSeverity(finding.severity);
|
|
1736
|
+
const decision = finding.decision ? ` Decision: ${finding.decision}.` : "";
|
|
1737
|
+
const recommendation = finding.recommendation ? ` Recommendation: ${oneLine(finding.recommendation)}` : "";
|
|
1738
|
+
return `- ${reviewer}[${severity}]: ${oneLine(finding.evidence)}${decision}${recommendation}`;
|
|
1739
|
+
}
|
|
1740
|
+
function defaultReason(findings) {
|
|
1741
|
+
if (!findings?.length) return "Revision requested";
|
|
1742
|
+
if (findings.length === 1) return oneLine(findings[0].evidence);
|
|
1743
|
+
return `${findings.length} findings require another pass`;
|
|
1744
|
+
}
|
|
1745
|
+
function normalizeRevision(input) {
|
|
1746
|
+
const reason = input.reason?.trim() || defaultReason(input.findings);
|
|
1747
|
+
return {
|
|
1748
|
+
reason,
|
|
1749
|
+
target: input.target,
|
|
1750
|
+
findings: input.findings,
|
|
1751
|
+
rerun: input.rerun ?? (input.target ? "target-and-dependents" : void 0),
|
|
1752
|
+
source: input.source,
|
|
1753
|
+
decision: input.decision
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function revisionRequest(input, over = {}) {
|
|
1757
|
+
const revision = normalizeRevision(input);
|
|
1758
|
+
return {
|
|
1759
|
+
status: over.status ?? "fail",
|
|
1760
|
+
confidence: over.confidence,
|
|
1761
|
+
summary: over.summary ?? revision.reason,
|
|
1762
|
+
data: over.data,
|
|
1763
|
+
error: over.error,
|
|
1764
|
+
revision
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
function kickback(to, reason, over = {}) {
|
|
1768
|
+
return revisionRequest({ target: to, reason }, over);
|
|
1769
|
+
}
|
|
1770
|
+
function revisionFromOutcome(outcome) {
|
|
1771
|
+
return outcome.revision;
|
|
1772
|
+
}
|
|
1773
|
+
function feedbackBlock(outcome) {
|
|
1774
|
+
const revision = revisionFromOutcome(outcome);
|
|
1775
|
+
const parts = [
|
|
1776
|
+
"## Feedback to address",
|
|
1777
|
+
"A review or downstream stage requested another pass. Address this before unrelated work."
|
|
1778
|
+
];
|
|
1779
|
+
if (revision?.target) parts.push(`Target: ${revision.target}`);
|
|
1780
|
+
if (revision?.source) parts.push(`Source: ${revision.source}`);
|
|
1781
|
+
if (revision?.decision) parts.push(`Caller decision: ${revision.decision}`);
|
|
1782
|
+
const reason = revision?.reason ?? outcome.summary;
|
|
1783
|
+
if (reason) parts.push(`Reason: ${reason}`);
|
|
1784
|
+
const findings = revision?.findings ?? [];
|
|
1785
|
+
if (findings.length) {
|
|
1786
|
+
parts.push("Findings:");
|
|
1787
|
+
parts.push(findings.map(findingLine).join("\n"));
|
|
1788
|
+
}
|
|
1789
|
+
return parts.join("\n\n");
|
|
1790
|
+
}
|
|
1791
|
+
function graphPositionBlock(graph, reviewerGate) {
|
|
1792
|
+
return [
|
|
1793
|
+
"## Graph position",
|
|
1794
|
+
`DAG: ${graph.dag}`,
|
|
1795
|
+
`Current node: ${graph.node}`,
|
|
1796
|
+
...graph.desc ? [`Description: ${graph.desc}`] : [],
|
|
1797
|
+
...reviewerGate ? [`Gate: ${reviewerGate}`] : [],
|
|
1798
|
+
`Path: ${graph.path.join(" > ")}`,
|
|
1799
|
+
`Depends on: ${graph.needs.length ? graph.needs.join(", ") : "none"}`,
|
|
1800
|
+
`Direct dependents: ${graph.dependents.length ? graph.dependents.join(", ") : "none"}`
|
|
1801
|
+
].join("\n");
|
|
1802
|
+
}
|
|
1803
|
+
var SHA256_FINGERPRINT = /^[0-9a-f]{64}$/;
|
|
1804
|
+
function reviewerCacheIdentity(reviewer, reviewerGate) {
|
|
1805
|
+
const invalidateOn = [...new Set(reviewer.invalidateOn ?? [])].map((path) => path.trim()).filter(Boolean).sort();
|
|
1806
|
+
return createHash("sha256").update(
|
|
1807
|
+
JSON.stringify({
|
|
1808
|
+
name: reviewer.name.trim(),
|
|
1809
|
+
cacheVersion: reviewer.cacheVersion.trim(),
|
|
1810
|
+
kind: "job" in reviewer ? "job" : "review",
|
|
1811
|
+
scope: reviewer.scope ?? null,
|
|
1812
|
+
invalidateOn,
|
|
1813
|
+
reviewerGate: reviewerGate ?? null
|
|
1814
|
+
})
|
|
1815
|
+
).digest("hex");
|
|
1816
|
+
}
|
|
1817
|
+
function reviewPassCacheKey(ctx, label) {
|
|
1818
|
+
return `lines:review-panel:${JSON.stringify([...ctx.path, label])}`;
|
|
1819
|
+
}
|
|
1820
|
+
function reviewPassCache(value) {
|
|
1821
|
+
const cache = {};
|
|
1822
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1823
|
+
return cache;
|
|
1824
|
+
for (const [name, entry] of Object.entries(value))
|
|
1825
|
+
Object.defineProperty(cache, name, {
|
|
1826
|
+
value: entry,
|
|
1827
|
+
enumerable: true,
|
|
1828
|
+
configurable: true,
|
|
1829
|
+
writable: true
|
|
1830
|
+
});
|
|
1831
|
+
return cache;
|
|
1832
|
+
}
|
|
1833
|
+
function setReviewPass(cache, name, pass) {
|
|
1834
|
+
Object.defineProperty(cache, name, {
|
|
1835
|
+
value: pass,
|
|
1836
|
+
enumerable: true,
|
|
1837
|
+
configurable: true,
|
|
1838
|
+
writable: true
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
function reusableReviewPass(value, minConfidence2, identity) {
|
|
1842
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1843
|
+
return false;
|
|
1844
|
+
const pass = value;
|
|
1845
|
+
return pass.identity === identity && typeof pass.fingerprint === "string" && SHA256_FINGERPRINT.test(pass.fingerprint) && typeof pass.confidence === "number" && Number.isFinite(pass.confidence) && pass.confidence >= minConfidence2 && pass.confidence <= 1;
|
|
1846
|
+
}
|
|
1847
|
+
var REVIEW_PANEL_DEFAULT_CONCURRENCY = DEFAULT_FANOUT_CONCURRENCY;
|
|
1848
|
+
function isReviewInfrastructureError(error) {
|
|
1849
|
+
return isInfrastructureError(error);
|
|
1850
|
+
}
|
|
1851
|
+
function isReviewBoundaryError(error) {
|
|
1852
|
+
return error?.code === "WRITE_BOUNDARY";
|
|
1853
|
+
}
|
|
1854
|
+
function findingFromOutput(reviewer, scope, output) {
|
|
1855
|
+
const evidence = output?.trim();
|
|
1856
|
+
if (!evidence) return void 0;
|
|
1857
|
+
return [{ reviewer, severity: "block", scope, evidence }];
|
|
1858
|
+
}
|
|
1859
|
+
function outputFromInfrastructureError(error) {
|
|
1860
|
+
const cause = error.cause;
|
|
1861
|
+
if (typeof cause !== "object" || cause === null) return void 0;
|
|
1862
|
+
const output = cause.output;
|
|
1863
|
+
return typeof output === "string" ? output : void 0;
|
|
1864
|
+
}
|
|
1865
|
+
async function runReviewer(reviewer, index, ctx) {
|
|
1866
|
+
const name = reviewer.name ?? `reviewer-${index + 1}`;
|
|
1867
|
+
const reviewerCtx = {
|
|
1868
|
+
...ctx,
|
|
1869
|
+
reviewerGate: criterionFor(ctx)
|
|
1870
|
+
};
|
|
1871
|
+
try {
|
|
1872
|
+
if ("job" in reviewer) {
|
|
1873
|
+
const outcome = await reviewer.job(reviewerCtx);
|
|
1874
|
+
const outcomeError = outcome.error;
|
|
1875
|
+
if (isReviewInfrastructureError(outcomeError) || isReviewBoundaryError(outcomeError)) {
|
|
1876
|
+
return {
|
|
1877
|
+
kind: "engine-error",
|
|
1878
|
+
name,
|
|
1879
|
+
scope: reviewer.scope,
|
|
1880
|
+
reason: outcome.summary ?? outcomeError.message,
|
|
1881
|
+
error: outcomeError,
|
|
1882
|
+
blocking: isReviewBoundaryError(outcomeError)
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
return {
|
|
1886
|
+
kind: "verdict",
|
|
1887
|
+
name,
|
|
1888
|
+
met: outcome.status === "pass",
|
|
1889
|
+
confidence: outcome.confidence,
|
|
1890
|
+
reason: outcome.summary ?? outcome.status,
|
|
1891
|
+
scope: reviewer.scope,
|
|
1892
|
+
findings: revisionFromOutcome(outcome)?.findings
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
const result = await toCondition(reviewer.review)(
|
|
1896
|
+
reviewerCtx,
|
|
1897
|
+
ctx.lastOutcome
|
|
1898
|
+
);
|
|
1899
|
+
return {
|
|
1900
|
+
kind: "verdict",
|
|
1901
|
+
name,
|
|
1902
|
+
met: result.met,
|
|
1903
|
+
confidence: result.confidence,
|
|
1904
|
+
reason: result.reason,
|
|
1905
|
+
scope: reviewer.scope,
|
|
1906
|
+
findings: result.met ? void 0 : findingFromOutput(name, reviewer.scope, result.output)
|
|
1907
|
+
};
|
|
1908
|
+
} catch (e) {
|
|
1909
|
+
if (ctx.signal.aborted) throw e;
|
|
1910
|
+
const error = e instanceof LoopError ? e : void 0;
|
|
1911
|
+
if (error && (isReviewInfrastructureError(error) || isReviewBoundaryError(error))) {
|
|
1912
|
+
return {
|
|
1913
|
+
kind: "engine-error",
|
|
1914
|
+
name,
|
|
1915
|
+
scope: reviewer.scope,
|
|
1916
|
+
reason: error.message,
|
|
1917
|
+
error,
|
|
1918
|
+
blocking: isReviewBoundaryError(error),
|
|
1919
|
+
findings: findingFromOutput(
|
|
1920
|
+
name,
|
|
1921
|
+
reviewer.scope,
|
|
1922
|
+
outputFromInfrastructureError(error)
|
|
1923
|
+
)
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
return {
|
|
1927
|
+
kind: "verdict",
|
|
1928
|
+
name,
|
|
1929
|
+
met: false,
|
|
1930
|
+
scope: reviewer.scope,
|
|
1931
|
+
reason: `reviewer errored: ${e instanceof Error ? e.message : String(e)}`
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
async function runPersistedReviewer(reviewer, index, ctx, cache, minConfidence2) {
|
|
1936
|
+
const name = reviewer.name;
|
|
1937
|
+
const identity = reviewerCacheIdentity(
|
|
1938
|
+
reviewer,
|
|
1939
|
+
criterionFor(ctx)
|
|
1940
|
+
);
|
|
1941
|
+
const before = await workspaceFingerprint({
|
|
1942
|
+
cwd: ctx.workspace.dir,
|
|
1943
|
+
signal: ctx.signal,
|
|
1944
|
+
excludePaths: ctx.fingerprintExcludePaths,
|
|
1945
|
+
includePaths: reviewer.invalidateOn
|
|
1946
|
+
});
|
|
1947
|
+
const cached = cache[name];
|
|
1948
|
+
if (before !== void 0 && reusableReviewPass(cached, minConfidence2, identity) && cached.fingerprint === before) {
|
|
1949
|
+
return {
|
|
1950
|
+
result: {
|
|
1951
|
+
kind: "verdict",
|
|
1952
|
+
name,
|
|
1953
|
+
met: true,
|
|
1954
|
+
confidence: cached.confidence,
|
|
1955
|
+
reason: "reused persisted pass",
|
|
1956
|
+
scope: reviewer.scope
|
|
1957
|
+
},
|
|
1958
|
+
reusedFingerprint: before
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
delete cache[name];
|
|
1962
|
+
const result = await runReviewer(reviewer, index, ctx);
|
|
1963
|
+
if (before === void 0 || result.kind !== "verdict" || !result.met || result.confidence === void 0 || !Number.isFinite(result.confidence) || result.confidence < minConfidence2 || result.confidence > 1)
|
|
1964
|
+
return { result };
|
|
1965
|
+
const after = await workspaceFingerprint({
|
|
1966
|
+
cwd: ctx.workspace.dir,
|
|
1967
|
+
signal: ctx.signal,
|
|
1968
|
+
excludePaths: ctx.fingerprintExcludePaths,
|
|
1969
|
+
includePaths: reviewer.invalidateOn
|
|
1970
|
+
});
|
|
1971
|
+
if (after !== void 0 && after === before)
|
|
1972
|
+
setReviewPass(cache, name, {
|
|
1973
|
+
identity,
|
|
1974
|
+
fingerprint: after,
|
|
1975
|
+
confidence: result.confidence
|
|
1976
|
+
});
|
|
1977
|
+
return { result };
|
|
1978
|
+
}
|
|
1979
|
+
async function settlePersistedReviewers(reviewers, initial, ctx, cache, minConfidence2, concurrency) {
|
|
1980
|
+
const staleIndices = (await mapWithConcurrency(initial, concurrency, async (run, index) => {
|
|
1981
|
+
if (run.reusedFingerprint === void 0) return void 0;
|
|
1982
|
+
const current = await workspaceFingerprint({
|
|
1983
|
+
cwd: ctx.workspace.dir,
|
|
1984
|
+
signal: ctx.signal,
|
|
1985
|
+
excludePaths: ctx.fingerprintExcludePaths,
|
|
1986
|
+
includePaths: reviewers[index].invalidateOn
|
|
1987
|
+
});
|
|
1988
|
+
return current === run.reusedFingerprint ? void 0 : index;
|
|
1989
|
+
})).filter((index) => index !== void 0);
|
|
1990
|
+
for (const index of staleIndices) delete cache[reviewers[index].name];
|
|
1991
|
+
const reruns = await mapWithConcurrency(
|
|
1992
|
+
staleIndices,
|
|
1993
|
+
concurrency,
|
|
1994
|
+
async (index) => ({
|
|
1995
|
+
index,
|
|
1996
|
+
run: await runPersistedReviewer(
|
|
1997
|
+
reviewers[index],
|
|
1998
|
+
index,
|
|
1999
|
+
ctx,
|
|
2000
|
+
cache,
|
|
2001
|
+
minConfidence2
|
|
2002
|
+
)
|
|
2003
|
+
})
|
|
2004
|
+
);
|
|
2005
|
+
const results = initial.map((run) => run.result);
|
|
2006
|
+
for (const { index, run } of reruns) results[index] = run.result;
|
|
2007
|
+
await mapWithConcurrency(results, concurrency, async (result, index) => {
|
|
2008
|
+
if (result.kind !== "verdict" || !result.met) return;
|
|
2009
|
+
const reviewer = reviewers[index];
|
|
2010
|
+
const name = reviewer.name;
|
|
2011
|
+
const cached = cache[name];
|
|
2012
|
+
if (!reusableReviewPass(
|
|
2013
|
+
cached,
|
|
2014
|
+
minConfidence2,
|
|
2015
|
+
reviewerCacheIdentity(
|
|
2016
|
+
reviewer,
|
|
2017
|
+
criterionFor(ctx)
|
|
2018
|
+
)
|
|
2019
|
+
))
|
|
2020
|
+
return;
|
|
2021
|
+
const current = await workspaceFingerprint({
|
|
2022
|
+
cwd: ctx.workspace.dir,
|
|
2023
|
+
signal: ctx.signal,
|
|
2024
|
+
excludePaths: ctx.fingerprintExcludePaths,
|
|
2025
|
+
includePaths: reviewer.invalidateOn
|
|
2026
|
+
});
|
|
2027
|
+
if (current !== cached.fingerprint) {
|
|
2028
|
+
delete cache[name];
|
|
2029
|
+
results[index] = {
|
|
2030
|
+
...result,
|
|
2031
|
+
met: false,
|
|
2032
|
+
reason: "review evidence changed during panel"
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
return results;
|
|
2037
|
+
}
|
|
2038
|
+
function reviewFindings(result) {
|
|
2039
|
+
const findings = result.findings?.length ? result.findings : [
|
|
2040
|
+
{
|
|
2041
|
+
reviewer: result.name,
|
|
2042
|
+
severity: "block",
|
|
2043
|
+
scope: result.scope,
|
|
2044
|
+
evidence: result.reason
|
|
2045
|
+
}
|
|
2046
|
+
];
|
|
2047
|
+
return findings.map((finding) => ({
|
|
2048
|
+
...finding,
|
|
2049
|
+
reviewer: finding.reviewer ?? result.name,
|
|
2050
|
+
severity: finding.severity ?? "block",
|
|
2051
|
+
scope: finding.scope ?? result.scope
|
|
2052
|
+
}));
|
|
2053
|
+
}
|
|
2054
|
+
function isActionableFinding(finding, actionableScopes) {
|
|
2055
|
+
if (!actionableScopes?.length) return true;
|
|
2056
|
+
return !finding.scope || actionableScopes.includes(finding.scope);
|
|
2057
|
+
}
|
|
2058
|
+
function escalatedFinding(finding) {
|
|
2059
|
+
return { ...finding, decision: "escalated" };
|
|
2060
|
+
}
|
|
2061
|
+
function findingSeverityCounts(findings) {
|
|
2062
|
+
const counts = {};
|
|
2063
|
+
for (const finding of findings) {
|
|
2064
|
+
const severity = normalizeFeedbackSeverity(finding.severity);
|
|
2065
|
+
counts[severity] = (counts[severity] ?? 0) + 1;
|
|
2066
|
+
}
|
|
2067
|
+
return counts;
|
|
2068
|
+
}
|
|
2069
|
+
function reviewPanel(config) {
|
|
2070
|
+
const label = config.label ?? "review-panel";
|
|
2071
|
+
if (!config.reviewers.length)
|
|
2072
|
+
throw new LoopError({
|
|
2073
|
+
code: "CONFIG",
|
|
2074
|
+
message: `reviewPanel "${label}": at least one reviewer is required`
|
|
2075
|
+
});
|
|
2076
|
+
if (typeof config.pass === "number" && (!Number.isInteger(config.pass) || config.pass < 1 || config.pass > config.reviewers.length))
|
|
2077
|
+
throw new LoopError({
|
|
2078
|
+
code: "CONFIG",
|
|
2079
|
+
message: `reviewPanel "${label}": pass must be an integer from 1 to ${config.reviewers.length} (got ${config.pass})`
|
|
2080
|
+
});
|
|
2081
|
+
const concurrency = config.concurrency ?? REVIEW_PANEL_DEFAULT_CONCURRENCY;
|
|
2082
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0)
|
|
2083
|
+
throw new LoopError({
|
|
2084
|
+
code: "CONFIG",
|
|
2085
|
+
message: `reviewPanel "${label}": concurrency must be a positive integer`
|
|
2086
|
+
});
|
|
2087
|
+
if (config.persistPasses) {
|
|
2088
|
+
const minConfidence2 = config.persistPasses.minConfidence;
|
|
2089
|
+
if (!Number.isFinite(minConfidence2) || minConfidence2 < 0 || minConfidence2 > 1)
|
|
2090
|
+
throw new LoopError({
|
|
2091
|
+
code: "CONFIG",
|
|
2092
|
+
message: `reviewPanel "${label}": persistPasses.minConfidence must be from 0 to 1`
|
|
2093
|
+
});
|
|
2094
|
+
if (!config.label?.trim())
|
|
2095
|
+
throw new LoopError({
|
|
2096
|
+
code: "CONFIG",
|
|
2097
|
+
message: "reviewPanel persistPasses requires an explicit label"
|
|
2098
|
+
});
|
|
2099
|
+
const names = config.reviewers.map((reviewer) => reviewer.name?.trim());
|
|
2100
|
+
if (names.some((name) => !name))
|
|
2101
|
+
throw new LoopError({
|
|
2102
|
+
code: "CONFIG",
|
|
2103
|
+
message: `reviewPanel "${label}" persistPasses requires every reviewer to have an explicit name`
|
|
2104
|
+
});
|
|
2105
|
+
if (new Set(names).size !== names.length)
|
|
2106
|
+
throw new LoopError({
|
|
2107
|
+
code: "CONFIG",
|
|
2108
|
+
message: `reviewPanel "${label}" persistPasses requires unique reviewer names`
|
|
2109
|
+
});
|
|
2110
|
+
if (config.reviewers.some((reviewer) => !reviewer.cacheVersion?.trim()))
|
|
2111
|
+
throw new LoopError({
|
|
2112
|
+
code: "CONFIG",
|
|
2113
|
+
message: `reviewPanel "${label}" persistPasses requires every reviewer to have an explicit cacheVersion`
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
const job = async (ctx) => {
|
|
2117
|
+
ctx.emit({
|
|
2118
|
+
kind: "job:start",
|
|
2119
|
+
ts: Date.now(),
|
|
2120
|
+
path: [...ctx.path],
|
|
2121
|
+
label,
|
|
2122
|
+
timeoutMs: ctx.timeoutMs
|
|
2123
|
+
});
|
|
2124
|
+
const cacheKey = config.persistPasses ? reviewPassCacheKey(ctx, label) : void 0;
|
|
2125
|
+
const cache = cacheKey ? reviewPassCache(ctx.state[cacheKey]) : void 0;
|
|
2126
|
+
if (cache) {
|
|
2127
|
+
const names = new Set(config.reviewers.map((reviewer) => reviewer.name));
|
|
2128
|
+
for (const name of Object.keys(cache))
|
|
2129
|
+
if (!names.has(name)) delete cache[name];
|
|
2130
|
+
}
|
|
2131
|
+
if (cacheKey && cache) ctx.state[cacheKey] = cache;
|
|
2132
|
+
let results;
|
|
2133
|
+
if (cache && config.persistPasses) {
|
|
2134
|
+
const initial = await mapWithConcurrency(
|
|
2135
|
+
config.reviewers,
|
|
2136
|
+
concurrency,
|
|
2137
|
+
(reviewer, i) => runPersistedReviewer(
|
|
2138
|
+
reviewer,
|
|
2139
|
+
i,
|
|
2140
|
+
ctx,
|
|
2141
|
+
cache,
|
|
2142
|
+
config.persistPasses.minConfidence
|
|
2143
|
+
)
|
|
2144
|
+
);
|
|
2145
|
+
results = await settlePersistedReviewers(
|
|
2146
|
+
config.reviewers,
|
|
2147
|
+
initial,
|
|
2148
|
+
ctx,
|
|
2149
|
+
cache,
|
|
2150
|
+
config.persistPasses.minConfidence,
|
|
2151
|
+
concurrency
|
|
2152
|
+
);
|
|
2153
|
+
} else {
|
|
2154
|
+
results = await mapWithConcurrency(
|
|
2155
|
+
config.reviewers,
|
|
2156
|
+
concurrency,
|
|
2157
|
+
(reviewer, i) => runReviewer(reviewer, i, ctx)
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
if (cacheKey && cache && !Object.keys(cache).length)
|
|
2161
|
+
delete ctx.state[cacheKey];
|
|
2162
|
+
const verdicts = results.filter(
|
|
2163
|
+
(r) => r.kind === "verdict"
|
|
2164
|
+
);
|
|
2165
|
+
const errors = results.filter(
|
|
2166
|
+
(r) => r.kind === "engine-error"
|
|
2167
|
+
);
|
|
2168
|
+
const blockingErrors = errors.filter((error) => error.blocking);
|
|
2169
|
+
const passedCount = verdicts.filter((r) => r.met).length;
|
|
2170
|
+
const required = config.pass === void 0 || config.pass === "all" ? results.length : config.pass;
|
|
2171
|
+
const rawFindings = [
|
|
2172
|
+
...verdicts.filter((r) => !r.met).flatMap(reviewFindings),
|
|
2173
|
+
...errors.flatMap((r) => r.findings ?? [])
|
|
2174
|
+
];
|
|
2175
|
+
const findings = rawFindings.filter(
|
|
2176
|
+
(f) => isActionableFinding(f, config.actionableScopes)
|
|
2177
|
+
);
|
|
2178
|
+
const escalatedFindings = rawFindings.filter((f) => !isActionableFinding(f, config.actionableScopes)).map(escalatedFinding);
|
|
2179
|
+
const passed2 = blockingErrors.length === 0 && verdicts.length > 0 && passedCount >= required;
|
|
2180
|
+
const summaryHead = `Review panel: ${passedCount}/${results.length} reviewer(s) cleared`;
|
|
2181
|
+
const summaryParts = [`${summaryHead}.`];
|
|
2182
|
+
if (findings.length) summaryParts.push(findings.map(findingLine).join("\n"));
|
|
2183
|
+
if (escalatedFindings.length)
|
|
2184
|
+
summaryParts.push(
|
|
2185
|
+
`Escalated:
|
|
2186
|
+
${escalatedFindings.map(findingLine).join("\n")}`
|
|
2187
|
+
);
|
|
2188
|
+
if (errors.length)
|
|
2189
|
+
summaryParts.push(
|
|
2190
|
+
`Engine errors:
|
|
2191
|
+
${errors.map((e) => `- ${e.name}: ${oneLine(e.reason)}`).join("\n")}`
|
|
2192
|
+
);
|
|
2193
|
+
const summary = summaryParts.join("\n");
|
|
2194
|
+
const scored = verdicts.map((r) => r.confidence).filter((c) => c != null);
|
|
2195
|
+
const confidence = scored.length ? scored.reduce((sum, c) => sum + c, 0) / scored.length : void 0;
|
|
2196
|
+
const data = {
|
|
2197
|
+
findings,
|
|
2198
|
+
escalatedFindings,
|
|
2199
|
+
errors,
|
|
2200
|
+
results,
|
|
2201
|
+
passed: passedCount,
|
|
2202
|
+
required,
|
|
2203
|
+
severityCounts: findingSeverityCounts(findings)
|
|
2204
|
+
};
|
|
2205
|
+
const blockedByInfrastructure = blockingErrors.length > 0 || errors.length > 0 && (config.pass === void 0 || config.pass === "all" ? true : passedCount < required && passedCount + errors.length >= required);
|
|
2206
|
+
let outcome;
|
|
2207
|
+
if (passed2) {
|
|
2208
|
+
outcome = { status: "pass", summary, confidence, data };
|
|
2209
|
+
} else if (blockedByInfrastructure || !findings.length && errors.length) {
|
|
2210
|
+
const first = errors[0];
|
|
2211
|
+
outcome = {
|
|
2212
|
+
status: "paused",
|
|
2213
|
+
summary,
|
|
2214
|
+
confidence,
|
|
2215
|
+
data,
|
|
2216
|
+
error: first.error ?? new LoopError({
|
|
2217
|
+
code: "ENGINE",
|
|
2218
|
+
phase: "review",
|
|
2219
|
+
message: first.reason
|
|
2220
|
+
})
|
|
2221
|
+
};
|
|
2222
|
+
} else if (!findings.length && escalatedFindings.length) {
|
|
2223
|
+
outcome = { status: "pass", summary, confidence, data };
|
|
2224
|
+
} else {
|
|
2225
|
+
outcome = revisionRequest(
|
|
2226
|
+
{
|
|
2227
|
+
target: config.target,
|
|
2228
|
+
// A clean one-line reason. The findings ride the `findings` array, so
|
|
2229
|
+
// feedbackBlock renders them once (not embedded in the reason too) and
|
|
2230
|
+
// the records/tail `reason` stays a single tidy line. The full
|
|
2231
|
+
// multi-line `summary` is kept on the outcome below for host logs.
|
|
2232
|
+
reason: `${summaryHead}.`,
|
|
2233
|
+
findings,
|
|
2234
|
+
rerun: config.rerun
|
|
2235
|
+
},
|
|
2236
|
+
{ summary, confidence, data }
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path: [...ctx.path], label, outcome });
|
|
2240
|
+
return outcome;
|
|
2241
|
+
};
|
|
2242
|
+
return setMeta(job, {
|
|
2243
|
+
kind: "reviewPanel",
|
|
2244
|
+
name: label,
|
|
2245
|
+
concurrency,
|
|
2246
|
+
actionableScopes: config.actionableScopes
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
async function gitOutput(cwd, args, signal) {
|
|
2250
|
+
const out = await runRuntimeProcess({
|
|
2251
|
+
executable: "git",
|
|
2252
|
+
args,
|
|
2253
|
+
cwd,
|
|
2254
|
+
signal
|
|
2255
|
+
});
|
|
2256
|
+
return processText(out.stdout).trim();
|
|
2257
|
+
}
|
|
2258
|
+
async function resolveFiles(ctx, patterns) {
|
|
2259
|
+
const fromGit = await gitOutput(
|
|
2260
|
+
ctx.workspace.dir,
|
|
2261
|
+
["ls-files", "--", ...patterns],
|
|
2262
|
+
ctx.signal
|
|
2263
|
+
).catch(() => "");
|
|
2264
|
+
const files = fromGit ? fromGit.split("\n").filter(Boolean) : [];
|
|
2265
|
+
if (files.length) return files;
|
|
2266
|
+
return patterns.filter((p) => existsSync(join(ctx.workspace.dir, p)));
|
|
2267
|
+
}
|
|
2268
|
+
function reviewContext(config) {
|
|
2269
|
+
return async (ctx, last) => {
|
|
2270
|
+
const max = config.maxChars ?? 6e3;
|
|
2271
|
+
const buildTests = async () => {
|
|
2272
|
+
if (!config.tests) return [];
|
|
2273
|
+
if (config.tests === true) {
|
|
2274
|
+
const lines = [];
|
|
2275
|
+
if (last?.status) lines.push(`Last outcome status: ${last.status}`);
|
|
2276
|
+
if (last?.summary) lines.push(`Last outcome summary: ${last.summary}`);
|
|
2277
|
+
if (last?.data !== void 0) {
|
|
2278
|
+
let rendered;
|
|
2279
|
+
try {
|
|
2280
|
+
rendered = JSON.stringify(last.data, null, 2);
|
|
2281
|
+
} catch {
|
|
2282
|
+
rendered = String(last.data);
|
|
2283
|
+
}
|
|
2284
|
+
lines.push(`Last outcome data: ${rendered}`);
|
|
2285
|
+
}
|
|
2286
|
+
return lines.length ? [`## Test and outcome context
|
|
2287
|
+
|
|
2288
|
+
${lines.join("\n")}`] : [];
|
|
2289
|
+
}
|
|
2290
|
+
const cwd = config.tests.cwd ?? ctx.workspace.dir;
|
|
2291
|
+
const result = await runRuntimeProcess({
|
|
2292
|
+
executable: config.tests.command,
|
|
2293
|
+
args: config.tests.args ?? [],
|
|
2294
|
+
cwd,
|
|
2295
|
+
signal: ctx.signal
|
|
2296
|
+
}).catch((e) => {
|
|
2297
|
+
if (ctx.signal.aborted) throw e;
|
|
2298
|
+
return void 0;
|
|
2299
|
+
});
|
|
2300
|
+
const exit = result?.exitCode ?? "(command did not run)";
|
|
2301
|
+
const stdout = result === void 0 ? "" : processText(result.stdout);
|
|
2302
|
+
const stderr = result === void 0 ? "" : processText(result.stderr);
|
|
2303
|
+
return [
|
|
2304
|
+
`## Test command
|
|
2305
|
+
|
|
2306
|
+
${config.tests.command} ${(config.tests.args ?? []).join(" ")}
|
|
2307
|
+
|
|
2308
|
+
exit: ${exit}
|
|
2309
|
+
|
|
2310
|
+
stdout:
|
|
2311
|
+
${truncate(stdout, max)}
|
|
2312
|
+
|
|
2313
|
+
stderr:
|
|
2314
|
+
${truncate(stderr, max)}`
|
|
2315
|
+
];
|
|
2316
|
+
};
|
|
2317
|
+
const buildDiff = async () => {
|
|
2318
|
+
if (!config.diff) return [];
|
|
2319
|
+
const diff2 = await gitOutput(
|
|
2320
|
+
ctx.workspace.dir,
|
|
2321
|
+
["diff", "HEAD", "--"],
|
|
2322
|
+
ctx.signal
|
|
2323
|
+
).catch(() => "");
|
|
2324
|
+
return diff2 ? [`## Git diff
|
|
2325
|
+
|
|
2326
|
+
${truncate(diff2, max)}`] : [];
|
|
2327
|
+
};
|
|
2328
|
+
const buildFiles = async () => {
|
|
2329
|
+
if (!config.files?.length) return [];
|
|
2330
|
+
const files2 = await resolveFiles(ctx, config.files);
|
|
2331
|
+
const out = [];
|
|
2332
|
+
for (const file of files2) {
|
|
2333
|
+
const path = join(ctx.workspace.dir, file);
|
|
2334
|
+
if (!existsSync(path)) continue;
|
|
2335
|
+
out.push(`## File: ${file}
|
|
2336
|
+
|
|
2337
|
+
${truncate(readFileSync(path, "utf8"), max)}`);
|
|
2338
|
+
}
|
|
2339
|
+
return out;
|
|
2340
|
+
};
|
|
2341
|
+
const [tests, diff, files] = await Promise.all([
|
|
2342
|
+
buildTests(),
|
|
2343
|
+
buildDiff(),
|
|
2344
|
+
buildFiles()
|
|
2345
|
+
]);
|
|
2346
|
+
const sections = [...tests, ...diff, ...files];
|
|
2347
|
+
return truncate(
|
|
2348
|
+
sections.join("\n\n---\n\n") || "(no review context)",
|
|
2349
|
+
max
|
|
2350
|
+
);
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// src/core/job.ts
|
|
2355
|
+
var RECORDED_ENGINE_USAGE = "obversa:recorded-engine-usage";
|
|
2356
|
+
function recordEngineUsage(ctx, model, path, recordAs) {
|
|
2357
|
+
const records = ctx.state[RECORDED_ENGINE_USAGE];
|
|
2358
|
+
const entry = {
|
|
2359
|
+
model,
|
|
2360
|
+
path: [...path],
|
|
2361
|
+
...recordAs === void 0 ? {} : { role: recordAs.role, stage: recordAs.stage }
|
|
2362
|
+
};
|
|
2363
|
+
if (records === void 0) {
|
|
2364
|
+
ctx.state[RECORDED_ENGINE_USAGE] = [entry];
|
|
2365
|
+
} else {
|
|
2366
|
+
records.push(entry);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
var TERMINAL = (text) => ({
|
|
2370
|
+
status: "pass",
|
|
2371
|
+
summary: text.trim().slice(0, 280),
|
|
2372
|
+
data: text
|
|
2373
|
+
});
|
|
2374
|
+
function advisorInstruction(maxCalls) {
|
|
2375
|
+
return `## Advisor consults
|
|
2376
|
+
When you hit a hard design fork, you may request at most ${maxCalls} advisor consult${maxCalls === 1 ? "" : "s"}. Do not shell out to another model. Instead, make your whole reply exactly this block:
|
|
2377
|
+
|
|
2378
|
+
<consult_advisor>
|
|
2379
|
+
<question>the precise question</question>
|
|
2380
|
+
<context>the minimum context the advisor needs</context>
|
|
2381
|
+
</consult_advisor>
|
|
2382
|
+
|
|
2383
|
+
The runtime will record the consult and return the advisor reply to you.`;
|
|
2384
|
+
}
|
|
2385
|
+
function withOperationalContext(ctx, userPrompt, config) {
|
|
2386
|
+
const parts = [userPrompt];
|
|
2387
|
+
if (config.consumeFeedback && ctx.lastReview) {
|
|
2388
|
+
parts.push(feedbackBlock(ctx.lastReview));
|
|
2389
|
+
}
|
|
2390
|
+
if (config.graphContext && ctx.graph) {
|
|
2391
|
+
parts.push(graphPositionBlock(ctx.graph, ctx.reviewerGate));
|
|
2392
|
+
}
|
|
2393
|
+
if (config.advisor) parts.push(advisorInstruction(config.advisor.maxCalls ?? 1));
|
|
2394
|
+
return parts.join("\n\n---\n\n");
|
|
2395
|
+
}
|
|
2396
|
+
function tagValue(text, tag) {
|
|
2397
|
+
const match = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*</${tag}>`, "i").exec(text);
|
|
2398
|
+
return match?.[1]?.trim() || void 0;
|
|
2399
|
+
}
|
|
2400
|
+
function parseAdvisorRequest(text) {
|
|
2401
|
+
const block = /^\s*<consult_advisor>\s*([\s\S]*?)\s*<\/consult_advisor>\s*$/i.exec(text)?.[1];
|
|
2402
|
+
if (!block) return void 0;
|
|
2403
|
+
const question = tagValue(block, "question") ?? block.trim();
|
|
2404
|
+
if (!question) return void 0;
|
|
2405
|
+
return { question, context: tagValue(block, "context") };
|
|
2406
|
+
}
|
|
2407
|
+
function advisorFollowup(call, request, reply) {
|
|
2408
|
+
return `Advisor consult #${call}
|
|
2409
|
+
|
|
2410
|
+
Question:
|
|
2411
|
+
${request.question}
|
|
2412
|
+
|
|
2413
|
+
${request.context ? `Context supplied:
|
|
2414
|
+
${request.context}
|
|
2415
|
+
|
|
2416
|
+
` : ""}Advisor reply:
|
|
2417
|
+
${reply}
|
|
2418
|
+
|
|
2419
|
+
Continue the original task. Do not ask the same question again.`;
|
|
2420
|
+
}
|
|
2421
|
+
async function runAdvisorConsult(ctx, label, config, call, request, inherited, redactionEnv) {
|
|
2422
|
+
assertBudget(ctx);
|
|
2423
|
+
const engine = ctx.resolveEngine(config.engine);
|
|
2424
|
+
const result = await engine.run(
|
|
2425
|
+
{
|
|
2426
|
+
prompt: `Question:
|
|
2427
|
+
${request.question}` + (request.context ? `
|
|
2428
|
+
|
|
2429
|
+
Context:
|
|
2430
|
+
${request.context}` : ""),
|
|
2431
|
+
system: config.systemPrompt,
|
|
2432
|
+
model: config.model,
|
|
2433
|
+
maxTokens: config.maxTokens ?? inherited.maxTokens,
|
|
2434
|
+
timeoutMs: config.timeoutMs ?? inherited.timeoutMs,
|
|
2435
|
+
timeoutGraceMs: config.timeoutGraceMs ?? inherited.timeoutGraceMs,
|
|
2436
|
+
cwd: ctx.workspace.dir,
|
|
2437
|
+
leaf: true,
|
|
2438
|
+
attempt: attemptRequestMeta(ctx, `${label}:advisor`)
|
|
2439
|
+
},
|
|
2440
|
+
(event) => {
|
|
2441
|
+
const ts = Date.now();
|
|
2442
|
+
if (event.type === "usage") {
|
|
2443
|
+
ctx.emit({
|
|
2444
|
+
kind: "engine:usage",
|
|
2445
|
+
ts,
|
|
2446
|
+
path: [...ctx.path],
|
|
2447
|
+
model: event.model,
|
|
2448
|
+
usage: event.usage
|
|
2449
|
+
});
|
|
2450
|
+
recordEngineUsage(ctx, event.model, ctx.path);
|
|
2451
|
+
} else if (event.type === "tool") {
|
|
2452
|
+
ctx.emit({
|
|
2453
|
+
kind: "engine:tool",
|
|
2454
|
+
ts,
|
|
2455
|
+
path: [...ctx.path],
|
|
2456
|
+
name: event.name,
|
|
2457
|
+
phase: event.phase
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
},
|
|
2461
|
+
ctx.signal
|
|
2462
|
+
);
|
|
2463
|
+
logEngineTransportFailure(ctx, result, redactionEnv);
|
|
2464
|
+
const reply = scrubCapture(
|
|
2465
|
+
requireFinalResultText(result),
|
|
2466
|
+
redactionEnv
|
|
2467
|
+
).trim();
|
|
2468
|
+
ctx.emit({
|
|
2469
|
+
kind: "advisor:consult",
|
|
2470
|
+
ts: Date.now(),
|
|
2471
|
+
path: [...ctx.path],
|
|
2472
|
+
label,
|
|
2473
|
+
call,
|
|
2474
|
+
question: request.question,
|
|
2475
|
+
reply,
|
|
2476
|
+
model: result.effective.model ?? void 0
|
|
2477
|
+
});
|
|
2478
|
+
return { reply, model: result.effective.model ?? void 0 };
|
|
2479
|
+
}
|
|
2480
|
+
function agentJob(config) {
|
|
2481
|
+
const job = async (ctx) => {
|
|
2482
|
+
const recordAs = "recordAs" in config ? config.recordAs : void 0;
|
|
2483
|
+
const path = [...ctx.path];
|
|
2484
|
+
const label = config.label ?? config.agent?.name ?? "agent";
|
|
2485
|
+
const defaultTimeoutMs = config.timeoutMs ?? ctx.timeoutMs;
|
|
2486
|
+
const defaultTimeoutGraceMs = config.timeoutGraceMs ?? ctx.timeoutGraceMs;
|
|
2487
|
+
ctx.emit({
|
|
2488
|
+
kind: "job:start",
|
|
2489
|
+
ts: Date.now(),
|
|
2490
|
+
path,
|
|
2491
|
+
label,
|
|
2492
|
+
timeoutMs: defaultTimeoutMs
|
|
2493
|
+
});
|
|
2494
|
+
const userPrompt = typeof config.prompt === "function" ? await config.prompt(ctx) : config.prompt;
|
|
2495
|
+
const contextualPrompt = withOperationalContext(ctx, userPrompt, config);
|
|
2496
|
+
const system = config.system !== void 0 ? typeof config.system === "function" ? config.system(ctx) : config.system : config.agent ? resolveSystem(config.agent) : void 0;
|
|
2497
|
+
const env = resolveEnv(ctx, config.env);
|
|
2498
|
+
const fallbacks = config.fallback ? Array.isArray(config.fallback) ? config.fallback : [config.fallback] : [];
|
|
2499
|
+
const routes = [
|
|
2500
|
+
{
|
|
2501
|
+
engine: config.engine,
|
|
2502
|
+
model: config.model ?? config.agent?.model,
|
|
2503
|
+
timeoutMs: defaultTimeoutMs,
|
|
2504
|
+
timeoutGraceMs: defaultTimeoutGraceMs
|
|
2505
|
+
},
|
|
2506
|
+
...fallbacks
|
|
2507
|
+
];
|
|
2508
|
+
const fallbackOn = new Set(
|
|
2509
|
+
config.fallbackOn ?? ["RATE_LIMIT", "QUOTA"]
|
|
2510
|
+
);
|
|
2511
|
+
let result;
|
|
2512
|
+
for (let i = 0; i < routes.length; i += 1) {
|
|
2513
|
+
const route = routes[i];
|
|
2514
|
+
const routeModel = route.model;
|
|
2515
|
+
const timeoutMs = route.timeoutMs ?? defaultTimeoutMs;
|
|
2516
|
+
const timeoutGraceMs = route.timeoutGraceMs ?? defaultTimeoutGraceMs;
|
|
2517
|
+
try {
|
|
2518
|
+
const basePrompt = contextualPrompt;
|
|
2519
|
+
const engine = ctx.resolveEngine(route.engine ?? config.engine);
|
|
2520
|
+
const maxAdvisorCalls = config.advisor?.maxCalls ?? 1;
|
|
2521
|
+
const advisorReplies = [];
|
|
2522
|
+
for (; ; ) {
|
|
2523
|
+
const prompt = advisorReplies.length ? `${basePrompt}
|
|
2524
|
+
|
|
2525
|
+
---
|
|
2526
|
+
|
|
2527
|
+
${advisorReplies.join("\n\n---\n\n")}` : basePrompt;
|
|
2528
|
+
assertBudget(ctx);
|
|
2529
|
+
result = await engine.run(
|
|
2530
|
+
{
|
|
2531
|
+
prompt,
|
|
2532
|
+
system,
|
|
2533
|
+
model: routeModel,
|
|
2534
|
+
maxTokens: config.maxTokens,
|
|
2535
|
+
tools: config.tools ?? config.agent?.tools,
|
|
2536
|
+
allowedTools: config.allowedTools,
|
|
2537
|
+
workspaceMode: config.workspaceMode,
|
|
2538
|
+
leaf: config.leaf ?? config.agent?.leaf,
|
|
2539
|
+
cwd: config.cwd ?? ctx.workspace.dir,
|
|
2540
|
+
timeoutMs,
|
|
2541
|
+
timeoutGraceMs,
|
|
2542
|
+
env,
|
|
2543
|
+
attempt: attemptRequestMeta(ctx, label)
|
|
2544
|
+
},
|
|
2545
|
+
(e) => {
|
|
2546
|
+
const ts = Date.now();
|
|
2547
|
+
switch (e.type) {
|
|
2548
|
+
case "text":
|
|
2549
|
+
ctx.emit({ kind: "engine:text", ts, path, delta: e.delta });
|
|
2550
|
+
break;
|
|
2551
|
+
case "thinking":
|
|
2552
|
+
ctx.emit({ kind: "engine:thinking", ts, path, delta: e.delta });
|
|
2553
|
+
break;
|
|
2554
|
+
case "tool":
|
|
2555
|
+
ctx.emit({
|
|
2556
|
+
kind: "engine:tool",
|
|
2557
|
+
ts,
|
|
2558
|
+
path,
|
|
2559
|
+
name: e.name,
|
|
2560
|
+
phase: e.phase
|
|
2561
|
+
});
|
|
2562
|
+
break;
|
|
2563
|
+
case "usage":
|
|
2564
|
+
ctx.emit({
|
|
2565
|
+
kind: "engine:usage",
|
|
2566
|
+
ts,
|
|
2567
|
+
path,
|
|
2568
|
+
model: e.model,
|
|
2569
|
+
usage: e.usage,
|
|
2570
|
+
...recordAs
|
|
2571
|
+
});
|
|
2572
|
+
recordEngineUsage(ctx, e.model, path, recordAs);
|
|
2573
|
+
break;
|
|
2574
|
+
}
|
|
2575
|
+
},
|
|
2576
|
+
ctx.signal
|
|
2577
|
+
);
|
|
2578
|
+
logEngineTransportFailure(ctx, result, env);
|
|
2579
|
+
const advisor = config.advisor;
|
|
2580
|
+
const capturedText = scrubCapture(
|
|
2581
|
+
requireFinalResultText(result),
|
|
2582
|
+
env
|
|
2583
|
+
);
|
|
2584
|
+
const consult = advisor ? parseAdvisorRequest(capturedText) : void 0;
|
|
2585
|
+
if (!advisor || !consult) break;
|
|
2586
|
+
if (advisorReplies.length >= maxAdvisorCalls) {
|
|
2587
|
+
throw new LoopError({
|
|
2588
|
+
code: "BUDGET",
|
|
2589
|
+
phase: "body",
|
|
2590
|
+
message: `${label} exceeded advisor consult cap (${maxAdvisorCalls})`
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
const call = advisorReplies.length + 1;
|
|
2594
|
+
const { reply } = await runAdvisorConsult(
|
|
2595
|
+
ctx,
|
|
2596
|
+
label,
|
|
2597
|
+
advisor,
|
|
2598
|
+
call,
|
|
2599
|
+
consult,
|
|
2600
|
+
{ maxTokens: config.maxTokens, timeoutMs, timeoutGraceMs },
|
|
2601
|
+
env
|
|
2602
|
+
);
|
|
2603
|
+
advisorReplies.push(advisorFollowup(call, consult, reply));
|
|
2604
|
+
}
|
|
2605
|
+
break;
|
|
2606
|
+
} catch (e) {
|
|
2607
|
+
const error = LoopError.from(e, {
|
|
2608
|
+
code: ctx.signal.aborted ? "ABORTED" : "ENGINE",
|
|
2609
|
+
phase: "body",
|
|
2610
|
+
path: ctx.path,
|
|
2611
|
+
iteration: ctx.iteration
|
|
2612
|
+
});
|
|
2613
|
+
if (i < routes.length - 1 && !ctx.signal.aborted && fallbackOn.has(error.code)) {
|
|
2614
|
+
ctx.log(
|
|
2615
|
+
`${label} primary route hit ${error.code}; trying fallback route ${i + 2}`,
|
|
2616
|
+
"warn"
|
|
2617
|
+
);
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
ctx.emit({
|
|
2621
|
+
kind: "error",
|
|
2622
|
+
ts: Date.now(),
|
|
2623
|
+
path,
|
|
2624
|
+
message: error.message,
|
|
2625
|
+
code: error.code
|
|
2626
|
+
});
|
|
2627
|
+
const outcome2 = {
|
|
2628
|
+
status: ctx.signal.aborted ? "aborted" : "fail",
|
|
2629
|
+
summary: error.message,
|
|
2630
|
+
error
|
|
2631
|
+
};
|
|
2632
|
+
ctx.emit({
|
|
2633
|
+
kind: "job:end",
|
|
2634
|
+
ts: Date.now(),
|
|
2635
|
+
path,
|
|
2636
|
+
label,
|
|
2637
|
+
outcome: outcome2
|
|
2638
|
+
});
|
|
2639
|
+
return outcome2;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
if (!result)
|
|
2643
|
+
throw new LoopError({
|
|
2644
|
+
code: "ENGINE",
|
|
2645
|
+
phase: "body",
|
|
2646
|
+
message: `${label} produced no engine result`
|
|
2647
|
+
});
|
|
2648
|
+
const text = scrubCapture(requireFinalResultText(result), env);
|
|
2649
|
+
const outcome = config.outcome ? await config.outcome(text, ctx) : TERMINAL(text);
|
|
2650
|
+
const finalOutcome = result.transportFailure?.kind === "timeout" && outcome.late !== true ? { ...outcome, late: true } : outcome;
|
|
2651
|
+
ctx.emit({
|
|
2652
|
+
kind: "job:end",
|
|
2653
|
+
ts: Date.now(),
|
|
2654
|
+
path,
|
|
2655
|
+
label,
|
|
2656
|
+
outcome: finalOutcome
|
|
2657
|
+
});
|
|
2658
|
+
return finalOutcome;
|
|
2659
|
+
};
|
|
2660
|
+
return setMeta(job, {
|
|
2661
|
+
kind: "agent",
|
|
2662
|
+
name: config.label ?? config.agent?.name ?? "agent",
|
|
2663
|
+
contract: agentContract(config.agent)
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
var OUTCOME_STATUSES = /* @__PURE__ */ new Set(["pass", "fail", "aborted", "exhausted", "paused"]);
|
|
2667
|
+
function isOutcome(value) {
|
|
2668
|
+
return typeof value === "object" && value !== null && OUTCOME_STATUSES.has(value.status);
|
|
2669
|
+
}
|
|
2670
|
+
function describeReturn(value) {
|
|
2671
|
+
if (value === null) return "null";
|
|
2672
|
+
if (typeof value === "object") {
|
|
2673
|
+
return `an object with status ${JSON.stringify(value.status)}`;
|
|
2674
|
+
}
|
|
2675
|
+
return `a ${typeof value}`;
|
|
2676
|
+
}
|
|
2677
|
+
function fnJob(label, fn) {
|
|
2678
|
+
const job = async (ctx) => {
|
|
2679
|
+
const path = [...ctx.path];
|
|
2680
|
+
ctx.emit({
|
|
2681
|
+
kind: "job:start",
|
|
2682
|
+
ts: Date.now(),
|
|
2683
|
+
path,
|
|
2684
|
+
label,
|
|
2685
|
+
timeoutMs: ctx.timeoutMs
|
|
2686
|
+
});
|
|
2687
|
+
let outcome;
|
|
2688
|
+
try {
|
|
2689
|
+
const returned = await fn(ctx);
|
|
2690
|
+
if (returned === void 0) {
|
|
2691
|
+
outcome = { status: "pass", summary: label };
|
|
2692
|
+
} else if (typeof returned === "string") {
|
|
2693
|
+
outcome = { status: "pass", summary: returned };
|
|
2694
|
+
} else if (isOutcome(returned)) {
|
|
2695
|
+
outcome = returned;
|
|
2696
|
+
} else {
|
|
2697
|
+
throw new LoopError({
|
|
2698
|
+
code: "VALIDATION",
|
|
2699
|
+
message: `fnJob "${label}" returned ${describeReturn(returned)}, which is not an outcome, a string or nothing`
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
} catch (e) {
|
|
2703
|
+
const error = LoopError.from(e, {
|
|
2704
|
+
code: "BODY",
|
|
2705
|
+
phase: "body",
|
|
2706
|
+
path: ctx.path,
|
|
2707
|
+
iteration: ctx.iteration
|
|
2708
|
+
});
|
|
2709
|
+
outcome = { status: "fail", summary: error.message, error };
|
|
2710
|
+
ctx.emit({
|
|
2711
|
+
kind: "error",
|
|
2712
|
+
ts: Date.now(),
|
|
2713
|
+
path,
|
|
2714
|
+
message: error.message,
|
|
2715
|
+
code: error.code
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
2719
|
+
return outcome;
|
|
2720
|
+
};
|
|
2721
|
+
return setMeta(job, { kind: "fn", name: label });
|
|
2722
|
+
}
|
|
2723
|
+
function validateProofArtifact(name, artifact) {
|
|
2724
|
+
if (!artifact || typeof artifact !== "object") {
|
|
2725
|
+
throw new LoopError({
|
|
2726
|
+
code: "VALIDATION",
|
|
2727
|
+
message: `prove "${name}" returned no artifact descriptor`
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
if (!["html", "image", "markdown", "table", "json"].includes(artifact.kind)) {
|
|
2731
|
+
throw new LoopError({
|
|
2732
|
+
code: "VALIDATION",
|
|
2733
|
+
message: `prove "${name}" returned unsupported kind "${String(artifact.kind)}"`
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
const hasPath = typeof artifact.path === "string" && artifact.path.trim() !== "";
|
|
2737
|
+
const hasData = artifact.data !== void 0;
|
|
2738
|
+
if (hasPath === hasData) {
|
|
2739
|
+
throw new LoopError({
|
|
2740
|
+
code: "VALIDATION",
|
|
2741
|
+
message: `prove "${name}" must return exactly one of path or data`
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
if (artifact.data !== void 0 && !isJsonValue(artifact.data)) {
|
|
2745
|
+
throw new LoopError({
|
|
2746
|
+
code: "VALIDATION",
|
|
2747
|
+
message: `prove "${name}" data must be JSON-serializable`
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
if (artifact.meta !== void 0 && !isJsonValue(artifact.meta)) {
|
|
2751
|
+
throw new LoopError({
|
|
2752
|
+
code: "VALIDATION",
|
|
2753
|
+
message: `prove "${name}" meta must be JSON-serializable`
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
function isJsonValue(value) {
|
|
2758
|
+
try {
|
|
2759
|
+
cloneFrozenJson(value);
|
|
2760
|
+
return true;
|
|
2761
|
+
} catch {
|
|
2762
|
+
return false;
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
function prove(name, producer) {
|
|
2766
|
+
const job = async (ctx) => {
|
|
2767
|
+
const path = [...ctx.path];
|
|
2768
|
+
ctx.emit({
|
|
2769
|
+
kind: "job:start",
|
|
2770
|
+
ts: Date.now(),
|
|
2771
|
+
path,
|
|
2772
|
+
label: name,
|
|
2773
|
+
timeoutMs: ctx.timeoutMs
|
|
2774
|
+
});
|
|
2775
|
+
let outcome;
|
|
2776
|
+
try {
|
|
2777
|
+
const artifact = await producer(ctx);
|
|
2778
|
+
validateProofArtifact(name, artifact);
|
|
2779
|
+
if (artifact.path) {
|
|
2780
|
+
const artifactPath = isAbsolute(artifact.path) ? artifact.path : resolve(ctx.workspace.dir, artifact.path);
|
|
2781
|
+
if (!existsSync(artifactPath)) {
|
|
2782
|
+
throw new LoopError({
|
|
2783
|
+
code: "VALIDATION",
|
|
2784
|
+
message: `prove "${name}" path does not exist: ${artifact.path}`
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
ctx.emit({ kind: "proof", ts: Date.now(), path, name, artifact });
|
|
2789
|
+
outcome = {
|
|
2790
|
+
status: "pass",
|
|
2791
|
+
summary: `proof registered: ${artifact.title ?? name}`,
|
|
2792
|
+
data: { proof: artifact }
|
|
2793
|
+
};
|
|
2794
|
+
} catch (e) {
|
|
2795
|
+
const error = LoopError.from(e, {
|
|
2796
|
+
code: "VALIDATION",
|
|
2797
|
+
phase: "body",
|
|
2798
|
+
path,
|
|
2799
|
+
iteration: ctx.iteration
|
|
2800
|
+
});
|
|
2801
|
+
outcome = { status: "fail", summary: error.message, error };
|
|
2802
|
+
ctx.emit({
|
|
2803
|
+
kind: "error",
|
|
2804
|
+
ts: Date.now(),
|
|
2805
|
+
path,
|
|
2806
|
+
message: error.message,
|
|
2807
|
+
code: error.code
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label: name, outcome });
|
|
2811
|
+
return outcome;
|
|
2812
|
+
};
|
|
2813
|
+
return setMeta(job, { kind: "prove", name });
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// src/workflow-agent-response.ts
|
|
2817
|
+
var INVALID_TEAM_DECISION = "The engine response was not a valid team decision JSON object.";
|
|
2818
|
+
function firstObject(text) {
|
|
2819
|
+
const start = text.indexOf("{");
|
|
2820
|
+
if (start === -1) return void 0;
|
|
2821
|
+
let depth = 0;
|
|
2822
|
+
let inString = false;
|
|
2823
|
+
let escaped = false;
|
|
2824
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2825
|
+
const character = text[index];
|
|
2826
|
+
if (inString) {
|
|
2827
|
+
if (escaped) escaped = false;
|
|
2828
|
+
else if (character === "\\") escaped = true;
|
|
2829
|
+
else if (character === '"') inString = false;
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
if (character === '"') inString = true;
|
|
2833
|
+
else if (character === "{") depth += 1;
|
|
2834
|
+
else if (character === "}" && --depth === 0) return text.slice(start, index + 1);
|
|
2835
|
+
}
|
|
2836
|
+
return void 0;
|
|
2837
|
+
}
|
|
2838
|
+
function parseDecision(text) {
|
|
2839
|
+
const trimmed = text.trim();
|
|
2840
|
+
const object = firstObject(trimmed);
|
|
2841
|
+
if (!object) return void 0;
|
|
2842
|
+
try {
|
|
2843
|
+
const value = JSON.parse(object);
|
|
2844
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
2845
|
+
const candidate = value;
|
|
2846
|
+
if (candidate.status !== "pass" && candidate.status !== "revise") return void 0;
|
|
2847
|
+
if (typeof candidate.summary !== "string" || !candidate.summary.trim()) return void 0;
|
|
2848
|
+
if (candidate.findings !== void 0 && !Array.isArray(candidate.findings)) return void 0;
|
|
2849
|
+
return {
|
|
2850
|
+
status: candidate.status,
|
|
2851
|
+
summary: candidate.summary,
|
|
2852
|
+
findings: candidate.findings ? [...candidate.findings] : void 0
|
|
2853
|
+
};
|
|
2854
|
+
} catch {
|
|
2855
|
+
return void 0;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
function outcomeFromAgentText(text, target) {
|
|
2859
|
+
const decision = parseDecision(text);
|
|
2860
|
+
if (!decision) {
|
|
2861
|
+
return {
|
|
2862
|
+
status: "fail",
|
|
2863
|
+
summary: INVALID_TEAM_DECISION,
|
|
2864
|
+
data: { response: text }
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
if (decision.status === "pass") {
|
|
2868
|
+
return { status: "pass", summary: decision.summary };
|
|
2869
|
+
}
|
|
2870
|
+
return revisionRequest({
|
|
2871
|
+
target,
|
|
2872
|
+
reason: decision.summary,
|
|
2873
|
+
findings: decision.findings ? [...decision.findings] : void 0
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// src/workflow-support.ts
|
|
2878
|
+
function seatIdentity(seat) {
|
|
2879
|
+
const identity = seat.identity;
|
|
2880
|
+
if (!identity || typeof identity.adapter !== "string" || typeof identity.provider !== "string" || typeof identity.modelFamily !== "string" || typeof identity.model !== "string" || !Array.isArray(identity.tools) || !identity.adapter.trim() || !identity.provider.trim() || !identity.model.trim() || !identity.modelFamily.trim()) {
|
|
2881
|
+
throw new TypeError("engine identity must contain adapter, provider, model family, and model");
|
|
2882
|
+
}
|
|
2883
|
+
return identity;
|
|
2884
|
+
}
|
|
2885
|
+
function assertDistinctSeats(seats) {
|
|
2886
|
+
const identities = seats.map((seat, index) => {
|
|
2887
|
+
try {
|
|
2888
|
+
return seatIdentity(seat);
|
|
2889
|
+
} catch (cause) {
|
|
2890
|
+
const message = cause instanceof Error ? cause.message : "invalid engine identity";
|
|
2891
|
+
throw new TypeError(`seat ${index + 1} has an invalid engine identity: ${message}`, { cause });
|
|
2892
|
+
}
|
|
2893
|
+
});
|
|
2894
|
+
for (let leftIndex = 0; leftIndex < identities.length; leftIndex += 1) {
|
|
2895
|
+
for (let rightIndex = leftIndex + 1; rightIndex < identities.length; rightIndex += 1) {
|
|
2896
|
+
const left = identities[leftIndex];
|
|
2897
|
+
const right = identities[rightIndex];
|
|
2898
|
+
const sameFamily = left.modelFamily === right.modelFamily;
|
|
2899
|
+
if (!sameFamily) continue;
|
|
2900
|
+
throw new TypeError(
|
|
2901
|
+
`seats ${leftIndex + 1} and ${rightIndex + 1} report the same model family ${left.modelFamily}; model family must be distinct per seat`
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
function requireNonEmptyFiles(label, job, workspace, files) {
|
|
2907
|
+
return async (ctx) => {
|
|
2908
|
+
const outcome = await job(ctx);
|
|
2909
|
+
if (outcome.status !== "pass") return outcome;
|
|
2910
|
+
for (const file of files) {
|
|
2911
|
+
const path = join(workspace, file);
|
|
2912
|
+
try {
|
|
2913
|
+
const details = await stat(path);
|
|
2914
|
+
if (!details.isFile() || details.size === 0) {
|
|
2915
|
+
return {
|
|
2916
|
+
status: "fail",
|
|
2917
|
+
summary: `${label} did not produce a non-empty file: ${file}`
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
} catch {
|
|
2921
|
+
return {
|
|
2922
|
+
status: "fail",
|
|
2923
|
+
summary: `${label} did not produce a non-empty file: ${file}`
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return outcome;
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
function requireNoFiles(label, job, workspace, files, phase = "review") {
|
|
2931
|
+
const wrapper = async (ctx) => {
|
|
2932
|
+
const before = /* @__PURE__ */ new Map();
|
|
2933
|
+
for (const file of files) {
|
|
2934
|
+
before.set(file, await snapshotFile(join(workspace, file)));
|
|
2935
|
+
}
|
|
2936
|
+
const outcome = await job(ctx);
|
|
2937
|
+
const changed = [];
|
|
2938
|
+
for (const file of files) {
|
|
2939
|
+
const after = await snapshotFile(join(workspace, file));
|
|
2940
|
+
const previous = before.get(file);
|
|
2941
|
+
if (previous.exists !== after.exists || previous.hash !== after.hash) {
|
|
2942
|
+
changed.push(file);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (changed.length) {
|
|
2946
|
+
const summary = `${label} wrote, changed or removed the implementation before its step completed: ${changed.join(", ")}`;
|
|
2947
|
+
return {
|
|
2948
|
+
status: "fail",
|
|
2949
|
+
summary,
|
|
2950
|
+
error: new LoopError({ code: "WRITE_BOUNDARY", phase, message: summary })
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
return outcome;
|
|
2954
|
+
};
|
|
2955
|
+
return copyJobMeta(wrapper, job);
|
|
2956
|
+
}
|
|
2957
|
+
async function snapshotFile(path) {
|
|
2958
|
+
try {
|
|
2959
|
+
const details = await stat(path);
|
|
2960
|
+
if (!details.isFile()) return { exists: true, hash: null };
|
|
2961
|
+
const contents = await readFile(path);
|
|
2962
|
+
return { exists: true, hash: createHash("sha256").update(contents).digest("hex") };
|
|
2963
|
+
} catch {
|
|
2964
|
+
return { exists: false, hash: null };
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
function rolePrompt(role, brief) {
|
|
2968
|
+
return [
|
|
2969
|
+
`Obversa team role: ${role}`,
|
|
2970
|
+
`Work brief: ${brief}`,
|
|
2971
|
+
"Work in the supplied cwd."
|
|
2972
|
+
].join("\n");
|
|
2973
|
+
}
|
|
2974
|
+
function teamAgent(label, seat, input, instructions, target, decisionFile, workspaceMode = "write", recordAs) {
|
|
2975
|
+
const identity = seatIdentity(seat);
|
|
2976
|
+
if (workspaceMode === "read" && identity.tools.length === 0) {
|
|
2977
|
+
throw new TypeError(`${label} cannot read the workspace: reviewer seat declares no tools`);
|
|
2978
|
+
}
|
|
2979
|
+
const agent = agentJob({
|
|
2980
|
+
label,
|
|
2981
|
+
engine: seat.engine,
|
|
2982
|
+
model: identity.model,
|
|
2983
|
+
tools: [...identity.tools],
|
|
2984
|
+
allowedTools: [...identity.tools],
|
|
2985
|
+
workspaceMode,
|
|
2986
|
+
cwd: input.workspace,
|
|
2987
|
+
consumeFeedback: target !== void 0,
|
|
2988
|
+
...recordAs === void 0 ? {} : { recordAs },
|
|
2989
|
+
prompt: (ctx) => `${rolePrompt(label, input.brief)}
|
|
2990
|
+
${typeof instructions === "function" ? instructions(ctx) : instructions}
|
|
2991
|
+
Return one JSON object: {"status":"pass"|"revise","summary":"...","findings":[{"evidence":"..."}]}`,
|
|
2992
|
+
outcome: (text) => outcomeFromAgentText(text, target)
|
|
2993
|
+
});
|
|
2994
|
+
if (!decisionFile) return agent;
|
|
2995
|
+
return async (ctx) => {
|
|
2996
|
+
const path = join(input.workspace, decisionFile);
|
|
2997
|
+
const before = await snapshotFile(path);
|
|
2998
|
+
const replyOutcome = await agent(ctx);
|
|
2999
|
+
const after = await snapshotFile(path);
|
|
3000
|
+
const changed = before.exists !== after.exists || before.hash !== after.hash;
|
|
3001
|
+
if (!changed || !after.exists || after.hash === null) return replyOutcome;
|
|
3002
|
+
try {
|
|
3003
|
+
const fileOutcome = outcomeFromAgentText(await readFile(path, "utf8"), target);
|
|
3004
|
+
return fileOutcome.status === "fail" && fileOutcome.summary === INVALID_TEAM_DECISION ? replyOutcome : fileOutcome;
|
|
3005
|
+
} catch {
|
|
3006
|
+
return replyOutcome;
|
|
3007
|
+
}
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
function panelReviewers(reviewers, input, reviewTarget, recordAs) {
|
|
3011
|
+
return reviewers.map((reviewer) => {
|
|
3012
|
+
const instructions = [
|
|
3013
|
+
`Review target: ${reviewTarget ?? "the supplied files and test evidence"}.`,
|
|
3014
|
+
"Judge only that target against this stage gate; do not require files from another stage.",
|
|
3015
|
+
reviewer.scope ? `Scope: ${reviewer.scope}` : void 0
|
|
3016
|
+
].filter(Boolean).join("\n");
|
|
3017
|
+
const review = teamAgent(
|
|
3018
|
+
reviewer.name,
|
|
3019
|
+
reviewer.seat,
|
|
3020
|
+
input,
|
|
3021
|
+
instructions,
|
|
3022
|
+
void 0,
|
|
3023
|
+
void 0,
|
|
3024
|
+
"read",
|
|
3025
|
+
recordAs
|
|
3026
|
+
);
|
|
3027
|
+
const retry = teamAgent(
|
|
3028
|
+
reviewer.name,
|
|
3029
|
+
reviewer.seat,
|
|
3030
|
+
input,
|
|
3031
|
+
`${instructions}
|
|
3032
|
+
Your previous response was not a valid decision. Return only the required JSON object.`,
|
|
3033
|
+
void 0,
|
|
3034
|
+
void 0,
|
|
3035
|
+
"read",
|
|
3036
|
+
recordAs
|
|
3037
|
+
);
|
|
3038
|
+
const job = async (ctx) => {
|
|
3039
|
+
const first = await review(ctx);
|
|
3040
|
+
if (first.status !== "fail" || first.summary !== INVALID_TEAM_DECISION) return first;
|
|
3041
|
+
const second = await retry(ctx);
|
|
3042
|
+
if (second.status !== "fail" || second.summary !== INVALID_TEAM_DECISION) return second;
|
|
3043
|
+
const summary = `reviewer ${reviewer.name} returned no decision`;
|
|
3044
|
+
return {
|
|
3045
|
+
status: "fail",
|
|
3046
|
+
summary,
|
|
3047
|
+
error: new LoopError({ code: "ENGINE", phase: "review", message: summary })
|
|
3048
|
+
};
|
|
3049
|
+
};
|
|
3050
|
+
return { name: reviewer.name, scope: reviewer.scope, job };
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
export { Budget, DEFAULT_FANOUT_CONCURRENCY, INVALID_TEAM_DECISION, LoopError, RECORDED_ENGINE_USAGE, addWorktree, agentCheck, agentJob, all, always, any, approval, assertDistinctSeats, bodyPassed, branchCommits, captureGitWorkspaceSnapshot, childContext, commandJob, commandSucceeds, commit, compareGitWorkspaceSnapshots, copyJobMeta, currentBranch, defineAgent, defineSkill, delegateNodeJob, deleteBranch, describeConditions, failed, fnJob, fromFile, gateJob, gitRoot, hasStagedChanges, headSha, isInfrastructureError, isRepo, jobMeta, kickback, mergeAbort, mergeBranch, mergeNoCommit, minConfidence, needDecisionsOf, never, nodeJobContext, not, outcomeFromAgentText, panelReviewers, passed, predicate, prepareCondition, prove, quorum, removeWorktree, renderPlan, requireNoFiles, requireNonEmptyFiles, resolveEnv, reviewContext, reviewPanel, revisionFromOutcome, revisionRequest, seatIdentity, setLabel, setMeta, stageAll, teamAgent, toCondition, withConditionPreparation, withEnv, workspaceFingerprint };
|
|
3055
|
+
//# sourceMappingURL=chunk-DV5P4QLI.js.map
|
|
3056
|
+
//# sourceMappingURL=chunk-DV5P4QLI.js.map
|