@smartmemory/stratum 0.3.3 → 0.3.4

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.
@@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
+ import { z } from "zod";
7
8
  import { runAgent } from "../connectors/runner.js";
8
9
  import { extractReferences } from "../ir/refs.js";
9
10
  import { validateSpec } from "../ir/validate.js";
@@ -11,6 +12,37 @@ import { BudgetLedger, validUsage } from "./ledger.js";
11
12
  import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
12
13
  import { StateStore } from "./state.js";
13
14
  const execFileAsync = promisify(execFile);
15
+ /**
16
+ * The fixed shape every S1 `evaluate:` step must return. Engine-owned and
17
+ * strict — an author's `out` contract governs what is *referenceable*, this
18
+ * schema governs what the data must *be*. It is the trust anchor: a transport
19
+ * failure can never be laundered into a `closed` verdict, and the cross-field
20
+ * invariants (`closed` ⇒ no children, `open` ⇒ ≥1 child) are enforced here.
21
+ */
22
+ export const evaluatorResultSchema = z.object({
23
+ status: z.enum(["closed", "open", "failed"]),
24
+ children: z.array(z.unknown()),
25
+ reason: z.string(),
26
+ score: z.number().optional(),
27
+ route: z.enum(["claude", "codex"]).optional(),
28
+ }).strict().superRefine((result, ctx) => {
29
+ if (result.status === "closed" && result.children.length > 0) {
30
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "a closed verdict must carry no children" });
31
+ }
32
+ if (result.status === "open" && result.children.length === 0) {
33
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["children"], message: "an open verdict must carry at least one child" });
34
+ }
35
+ });
36
+ /**
37
+ * The engine validates the runner's ENVELOPE at runtime, not just the verdict
38
+ * inside it — the `ok` discriminant is the runner's word for whether it even
39
+ * succeeded, and a malformed envelope must never let a `{status:"closed"}`
40
+ * payload reach the success path. Same trust posture as the judge verdict.
41
+ */
42
+ const evaluateRunResultSchema = z.discriminatedUnion("ok", [
43
+ z.object({ ok: z.literal(true), result: z.unknown() }),
44
+ z.object({ ok: z.literal(false), kind: z.enum(["exit", "timeout", "parse"]), reason: z.string() }),
45
+ ]);
14
46
  export class CheckpointOperationError extends Error {
15
47
  errorType;
16
48
  available;
@@ -33,6 +65,7 @@ export class StratumEngine {
33
65
  store;
34
66
  evaluator;
35
67
  judge;
68
+ evaluateRunner;
36
69
  connector;
37
70
  // Serializes load-modify-save per run: plan may hand out several ready steps, so
38
71
  // stepDone/resume can race in-process. The state root is owned by one engine process in v1.
@@ -51,6 +84,8 @@ export class StratumEngine {
51
84
  this.evaluator = options.evaluator;
52
85
  if (options.judge)
53
86
  this.judge = options.judge;
87
+ if (options.evaluateRunner)
88
+ this.evaluateRunner = options.evaluateRunner;
54
89
  this.connector = options.connector ?? defaultConnector;
55
90
  }
56
91
  async loadRun(runId) {
@@ -895,6 +930,74 @@ export class StratumEngine {
895
930
  break;
896
931
  }
897
932
  }
933
+ if (step.evaluate !== undefined) {
934
+ const attempt = state.attempts.length + 1;
935
+ const evaluate = step.evaluate;
936
+ // Deterministic, single-shot, and atomic: no intermediate `running`
937
+ // is persisted, so a crash mid-evaluate leaves the step `pending` and
938
+ // it re-runs on resume. `forceExhausted` terminalizes every failure —
939
+ // retrying a deterministic evaluator is pointless (backtrack is S3).
940
+ const evalFail = (reason) => this.failAttempt(run, spec, contracts, scope, step, state, attempt, reason, {}, undefined, undefined, true);
941
+ if (!this.evaluateRunner) {
942
+ await evalFail("evaluate: no evaluate runner configured");
943
+ changed = true;
944
+ break;
945
+ }
946
+ let input;
947
+ try {
948
+ input = evaluate.in === undefined ? undefined : this.renderValue(evaluate.in, scope);
949
+ }
950
+ catch (error) {
951
+ await evalFail(`evaluate: input render failed: ${message(error)}`);
952
+ changed = true;
953
+ break;
954
+ }
955
+ // Sandboxing (workspaceRoot jail) is deferred — see design open question 3.
956
+ let rawOutcome;
957
+ try {
958
+ rawOutcome = await this.evaluateRunner({ command: evaluate.command, input, timeoutMs: evaluate.timeout_ms }, {});
959
+ }
960
+ catch (error) {
961
+ await evalFail(`evaluate: runner threw: ${message(error)}`);
962
+ changed = true;
963
+ break;
964
+ }
965
+ const envelope = evaluateRunResultSchema.safeParse(rawOutcome);
966
+ if (!envelope.success) {
967
+ await evalFail(`evaluate: runner returned a malformed result envelope: ${envelope.error.message}`);
968
+ changed = true;
969
+ break;
970
+ }
971
+ const outcome = envelope.data;
972
+ if (!outcome.ok) {
973
+ const detail = outcome.reason;
974
+ const reason = outcome.kind === "exit" ? `evaluate: command exited with a non-zero status (${detail})`
975
+ : outcome.kind === "timeout" ? `evaluate: command timed out (${detail})`
976
+ : `evaluate: output was not valid JSON (${detail})`;
977
+ await evalFail(reason);
978
+ changed = true;
979
+ break;
980
+ }
981
+ const parsed = evaluatorResultSchema.safeParse(outcome.result);
982
+ if (!parsed.success) {
983
+ await evalFail(`evaluate: output failed the evaluator-result contract: ${parsed.error.message}`);
984
+ changed = true;
985
+ break;
986
+ }
987
+ const outError = this.contractError(step, parsed.data, contracts);
988
+ if (outError) {
989
+ await evalFail(`evaluate: output failed the ${step.out} contract: ${outError}`);
990
+ changed = true;
991
+ break;
992
+ }
993
+ state.status = "succeeded";
994
+ state.output = parsed.data;
995
+ state.attempts.push({ attempt, at: now(), result: parsed.data });
996
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, result: parsed.data });
997
+ await this.persist(run);
998
+ changed = true;
999
+ continue;
1000
+ }
898
1001
  if (step.do === undefined) {
899
1002
  await this.failScope(run, spec, contracts, scope, "construct is outside P1 engine scope");
900
1003
  break;
@@ -2231,6 +2334,8 @@ function stringLeaves(step) {
2231
2334
  // over "${prep.output.items}" must wait for prep, not fail at resolve time.
2232
2335
  if (step.with !== undefined)
2233
2336
  collect(step.with);
2337
+ if (step.evaluate?.in !== undefined)
2338
+ collect(step.evaluate.in);
2234
2339
  if (step.fanout !== undefined) {
2235
2340
  collect(step.fanout.over);
2236
2341
  for (const stage of step.fanout.steps) {