@hicaru/pi-rlm 0.1.8 → 0.1.9

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Preflight critique for stage artifacts — port of codex-bais `model_critique`.
3
+ *
4
+ * Blocking truth comes from `stage.gate` (the same function `advance_phase` runs), so the
5
+ * two can never drift. `warnings` are advisory model-risk diagnostics that never block.
6
+ */
7
+ import {
8
+ countBulletsUnderHeading,
9
+ phasesMissingSuccessCriteria,
10
+ sectionHasNonEmptyBody,
11
+ } from "./gates.ts";
12
+ import type { StageDef, StageGateData } from "./pipeline.ts";
13
+
14
+ export interface Critique {
15
+ /** Blocking — `advance_phase` will reject while non-empty. */
16
+ readonly issues: readonly string[];
17
+ /** Advisory — surfaced to the model, never blocking. */
18
+ readonly warnings: readonly string[];
19
+ /**
20
+ * Convenience alias for `issues.length === 0` — do not set independently;
21
+ * always derive from `issues`.
22
+ */
23
+ readonly canAdvance: boolean;
24
+ /** Gate payload when `canAdvance` — reused by advance_phase (no second gate run). */
25
+ readonly gateData?: StageGateData;
26
+ }
27
+
28
+ const NO_STRINGS: readonly string[] = Object.freeze([]);
29
+
30
+ /** Advisory checks per artifact kind. Pure; returns a frozen array. */
31
+ function advisoriesFor(stage: StageDef, content: string): readonly string[] {
32
+ switch (stage.artifactKind) {
33
+ case "plan": {
34
+ const missing = phasesMissingSuccessCriteria(content);
35
+ if (missing.length > 0) {
36
+ return Object.freeze([
37
+ `${missing.join(", ")} ${missing.length === 1 ? "has" : "have"} no `
38
+ + "'### Success Criteria' — validate cannot check them",
39
+ ]);
40
+ }
41
+ return NO_STRINGS;
42
+ }
43
+ case "clarification": {
44
+ const open = countBulletsUnderHeading(content, "Open Questions");
45
+ if (open > 0) {
46
+ return Object.freeze([
47
+ `${open} Open Question(s) carried into blueprint — re-ask any that block the design`,
48
+ ]);
49
+ }
50
+ return NO_STRINGS;
51
+ }
52
+ case "research": {
53
+ if (!sectionHasNonEmptyBody(content, "Findings")) {
54
+ return Object.freeze(["research artifact has no '## Findings' section"]);
55
+ }
56
+ return NO_STRINGS;
57
+ }
58
+ default:
59
+ return NO_STRINGS;
60
+ }
61
+ }
62
+
63
+ export function critiqueArtifact(
64
+ stage: StageDef,
65
+ content: string,
66
+ path: string,
67
+ cwd: string,
68
+ ): Critique {
69
+ const gate = stage.gate(content, path, cwd);
70
+ const issues = gate.ok ? NO_STRINGS : Object.freeze([gate.error]);
71
+ return Object.freeze({
72
+ issues,
73
+ warnings: advisoriesFor(stage, content),
74
+ canAdvance: issues.length === 0,
75
+ gateData: gate.ok ? gate.value : undefined,
76
+ });
77
+ }
78
+
79
+ /** Human-readable renderer for the `save_artifact` return value. */
80
+ export function formatCritique(critique: Critique): string {
81
+ if (critique.issues.length === 0 && critique.warnings.length === 0) {
82
+ return "gate: clean — call advance_phase when ready.";
83
+ }
84
+ const lines = new Array<string>(critique.issues.length + critique.warnings.length);
85
+ let n = 0;
86
+ for (let i = 0; i < critique.issues.length; i++) lines[n++] = ` BLOCKER: ${critique.issues[i]}`;
87
+ for (let i = 0; i < critique.warnings.length; i++) lines[n++] = ` warning: ${critique.warnings[i]}`;
88
+ const head = critique.canAdvance
89
+ ? "gate: passes, with advisories —"
90
+ : "gate: WOULD REJECT — fix before advance_phase:";
91
+ return `${head}\n${lines.join("\n")}`;
92
+ }
@@ -7,40 +7,47 @@
7
7
  * back into `runRlm` at depth+1. Used for recursion and for headless/automation runs.
8
8
  *
9
9
  * When `config.pipeline` is on at depth 0: goal capture, artifact-gated advance_phase,
10
- * serial implement fanout via child RLMs, history reset at phase boundaries, and
11
- * measured validate→blueprint corrective routing.
10
+ * history reset at phase boundaries, and measured validate→blueprint corrective routing.
11
+ * The pipeline is read-only by design: it produces a validated plan; accidental
12
+ * sandbox writes are blocked (steering, not a hard security boundary).
12
13
  */
13
14
 
14
15
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
15
16
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
16
17
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
17
18
  import { buildLibraryHandler } from "../bridge/library.ts";
19
+ import { mergeLibraryIntoContext } from "../context/library-context.ts";
18
20
  import { createLlmBridge } from "../bridge/llm-query.ts";
19
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
20
22
  import { createRlmHandlers } from "../bridge/rlm-query.ts";
21
23
  import { resolveModelId } from "../config/settings.ts";
22
24
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
23
25
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
24
- import { buildImplementPhasePrompt, phaseGuidance } from "../prompts/phases.ts";
26
+ import { phaseGuidance } from "../prompts/phases.ts";
25
27
  import type { RlmEmitter } from "../tool/rlm-events.ts";
26
28
  import { PythonSandbox } from "../sandbox/sandbox.ts";
27
29
  import {
28
30
  advancePhase as validatePhaseTransition,
29
31
  initialPhaseState,
32
+ isPhase,
30
33
  phaseGatePrompt,
34
+ PHASES,
35
+ reconcilePhase,
31
36
  routeAfterValidate,
32
37
  stageForArtifactKind,
33
38
  STAGES,
39
+ type ArtifactRef,
34
40
  type Phase,
35
41
  type PhaseState,
42
+ type SavedArtifact,
36
43
  type StageGateData,
37
44
  } from "./pipeline.ts";
38
- import type { PlanGateData, ValidationGateData } from "./gates.ts";
45
+ import { critiqueArtifact, formatCritique } from "./critique.ts";
46
+ import type { ValidationGateData } from "./gates.ts";
39
47
  import { captureGoal, readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
40
48
  import { previewStdout, previewText } from "../text/preview.ts";
41
- import { applyProposedEdits } from "../text/edits.ts";
42
49
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
43
- import { collectEdits, finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
50
+ import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
44
51
  import { compactHistory, shouldCompact } from "./compaction.ts";
45
52
  import { appendUserMessage } from "./history.ts";
46
53
  import { runTurn } from "./iteration.ts";
@@ -59,8 +66,7 @@ import {
59
66
  import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
60
67
  import type { PhaseRow, RunHeader } from "../state/rows.ts";
61
68
  import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
62
- import type { ProposedEdit } from "../sandbox/protocol.ts";
63
- import { formatError, isErrorText } from "../util/errors.ts";
69
+ import { formatError } from "../util/errors.ts";
64
70
 
65
71
 
66
72
  export interface EngineDeps extends InteractiveDeps {
@@ -84,8 +90,6 @@ export interface EngineDeps extends InteractiveDeps {
84
90
  export interface PhaseHistoryOptions {
85
91
  readonly goal?: GoalCapture;
86
92
  readonly validation?: ValidationGateData;
87
- /** Fanout summary embedded so implement-exit result survives the history wipe. */
88
- readonly implementSummary?: string;
89
93
  /** Engine notice folded into the first user message (no console I/O). */
90
94
  readonly notice?: string;
91
95
  }
@@ -100,7 +104,7 @@ export function resetHistoryForPhase(
100
104
  state: PhaseState,
101
105
  options: PhaseHistoryOptions = {},
102
106
  ): ChatMsg[] {
103
- const { goal, validation, implementSummary, notice } = options;
107
+ const { goal, validation, notice } = options;
104
108
  const parts: string[] = [
105
109
  `You are entering the '${state.current}' phase.`,
106
110
  ];
@@ -109,17 +113,19 @@ export function resetHistoryForPhase(
109
113
  parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
110
114
  parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
111
115
  }
112
- for (const [p, path] of Object.entries(state.artifacts)) {
113
- if (path !== undefined) parts.push(`Artifact from '${p}': ${path}`);
116
+ for (const [p, ref] of Object.entries(state.artifacts)) {
117
+ if (ref === undefined) continue;
118
+ parts.push(
119
+ ref.status === "superseded"
120
+ ? `Superseded artifact from '${p}' (rejected by validation): ${ref.path} — read it and the validation before re-planning; do not repeat its blockers.`
121
+ : `Artifact from '${p}': ${ref.path}`,
122
+ );
114
123
  }
115
124
  if (validation) {
116
125
  parts.push(
117
126
  `Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
118
127
  );
119
128
  }
120
- if (implementSummary) {
121
- parts.push("Implement fanout result:", implementSummary);
122
- }
123
129
  parts.push(phaseGuidance(state.current));
124
130
  parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
125
131
  return [
@@ -156,7 +162,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
156
162
  else emitter.emitStatus("error");
157
163
  return {
158
164
  answer: formatError(`unknown model override '${input.modelOverride}'`),
159
- edits: [],
160
165
  iterations: 0,
161
166
  costUsd: 0,
162
167
  inputTokens: 0,
@@ -215,9 +220,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
215
220
  let lastAnswer = "";
216
221
  let compactions = 0;
217
222
  let completedTurns = 0;
218
- let editsAcc: ProposedEdit[] = [];
219
223
  let phaseState: PhaseState | undefined;
220
- let lastSavedArtifact: Partial<Record<Phase, string>> = {};
224
+ /**
225
+ * Latest save per phase: path + optional gate memo (single record so they cannot desync).
226
+ * Invalidated by clearLastSaved on phase exit / loop-back.
227
+ */
228
+ let lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
229
+ /** Advisory warnings accumulated from save_artifact critiques (TUI). */
230
+ let pipelineWarnings: string[] = [];
221
231
  /** Serviced ask_user_question rounds in the current phase (session-only; reset on transition). */
222
232
  let askRoundsThisPhase = 0;
223
233
  let pendingHistoryReset: ChatMsg[] | undefined;
@@ -255,6 +265,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
255
265
  artifactPath: string | undefined,
256
266
  artifactPhase: Phase | undefined,
257
267
  gateData: StageGateData | undefined,
268
+ supersededPath?: string,
258
269
  ): Promise<void> => {
259
270
  if (!persistOn || !runId || !deps.runState) return;
260
271
  const row: PhaseRow = {
@@ -267,58 +278,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
267
278
  artifactPhase,
268
279
  blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
269
280
  backwardJumps: state.backwardJumps,
281
+ supersededPath,
270
282
  };
271
283
  const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
272
284
  if (!ok) persistOn = false;
273
285
  };
274
286
 
275
- /** Clear lastSaved entries so a re-entered stage cannot re-gate with a stale artifact. */
287
+ /** Clear lastSaved so a re-entered stage cannot re-gate with a stale artifact. */
276
288
  const clearLastSaved = (...phases: readonly Phase[]): void => {
277
- const next: Partial<Record<Phase, string>> = { ...lastSavedArtifact };
289
+ const next: Partial<Record<Phase, SavedArtifact>> = { ...lastSaved };
278
290
  for (const p of phases) delete next[p];
279
- lastSavedArtifact = next;
280
- };
281
-
282
- const runImplementFanout = async (planPath: string, plan: PlanGateData): Promise<string> => {
283
- // Fanout children need a real RLM (sandbox + stage_edit); depth-cap degradation is a no-op.
284
- if (input.depth + 1 >= deps.config.maxDepth) {
285
- return formatError(
286
- `implement fanout requires maxDepth >= ${input.depth + 2} so child RLMs can run (current maxDepth=${deps.config.maxDepth})`,
287
- );
288
- }
289
- const lines = new Array<string>(plan.phases.length);
290
- for (let i = 0; i < plan.phases.length; i++) {
291
- const r = plan.phases[i];
292
- if (r === undefined) continue;
293
- // Keep the root sandbox's exec watchdog alive across long serial fanout work.
294
- sandbox?.refreshWatchdog();
295
- const prompt = buildImplementPhasePrompt(planPath, r);
296
- const res = await rlm.childRun({
297
- rootPrompt: prompt,
298
- context: input.context,
299
- depth: input.depth + 1,
300
- label: `implement ${r.index + 1}/${r.total}: ${r.title}`,
301
- });
302
- sandbox?.refreshWatchdog();
303
- // Serial patch-series: a later phase EDITS files an earlier phase CREATES —
304
- // apply this child's edits BEFORE the next child starts.
305
- const childEdits = res.edits ?? [];
306
- const apply = await applyProposedEdits(childEdits, runCwd);
307
- if (!apply.ok) {
308
- return formatError(`implement halted at Phase ${r.n} (${r.title}): ${apply.error}`);
309
- }
310
- if (childEdits.length > 0) {
311
- const next = new Array<ProposedEdit>(editsAcc.length + childEdits.length);
312
- for (let j = 0; j < editsAcc.length; j++) next[j] = editsAcc[j];
313
- for (let j = 0; j < childEdits.length; j++) next[editsAcc.length + j] = childEdits[j];
314
- editsAcc = next;
315
- }
316
- lines[i] = `Phase ${r.n} (${r.title}): ${apply.applied} edit(s) applied — ${previewText(res.answer, 120)}`;
317
- if (isErrorText(res.answer)) {
318
- return formatError(`implement halted at Phase ${r.n}: ${res.answer}\n${lines.slice(0, i + 1).join("\n")}`);
319
- }
320
- }
321
- return `ok — implement complete (${plan.phases.length} phase(s), serial):\n${lines.join("\n")}\nNow advance_phase("validate").`;
291
+ lastSaved = next;
322
292
  };
323
293
 
324
294
  try {
@@ -346,7 +316,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
346
316
  if (stage === undefined) {
347
317
  return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
348
318
  }
349
- const current = phaseState?.current ?? "clarify";
319
+ const current = phaseState?.current ?? PHASES[0];
350
320
  if (stage.phase !== current) {
351
321
  return formatError(
352
322
  `artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`,
@@ -354,11 +324,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
354
324
  }
355
325
  const saved = saveArtifact(runCwd, stage.artifactDir, kind, content);
356
326
  if (!saved.ok) return formatError(saved.error);
357
- lastSavedArtifact = { ...lastSavedArtifact, [stage.phase]: saved.path };
358
- return `ok saved ${saved.path}. Call advance_phase when the artifact is complete (status: ready).`;
327
+ // Preflight: run the SAME gate advance_phase will run, now instead of a turn later.
328
+ const critique = critiqueArtifact(stage, content, saved.path, runCwd);
329
+ // One assignment: path + optional gate memo (undefined when gate failed).
330
+ lastSaved = {
331
+ ...lastSaved,
332
+ [stage.phase]: Object.freeze({
333
+ path: saved.path,
334
+ gateData: critique.gateData,
335
+ }),
336
+ };
337
+ if (critique.warnings.length > 0) {
338
+ const next = new Array<string>(pipelineWarnings.length + critique.warnings.length);
339
+ for (let i = 0; i < pipelineWarnings.length; i++) next[i] = pipelineWarnings[i];
340
+ for (let i = 0; i < critique.warnings.length; i++) {
341
+ next[pipelineWarnings.length + i] = critique.warnings[i];
342
+ }
343
+ pipelineWarnings = next;
344
+ emitter.emitWarnings(Object.freeze([...pipelineWarnings]));
345
+ }
346
+ return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
359
347
  },
360
348
  advancePhase: async (phase: string, summary: string | undefined): Promise<string> => {
361
- const current = phaseState?.current ?? "clarify";
349
+ const current = phaseState?.current ?? PHASES[0];
362
350
  const outcome = validatePhaseTransition(current, phase);
363
351
  if (!outcome.ok) return formatError(outcome.error);
364
352
 
@@ -372,9 +360,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
372
360
 
373
361
  // GATE: measure the CURRENT stage's latest save only (never fall back to
374
362
  // phaseState.artifacts — those are completed-channel paths and may be stale
375
- // across a corrective loop).
363
+ // across a corrective loop). Prefer the memo filled by save_artifact.
376
364
  const stage = STAGES[current];
377
- const artifactPath = lastSavedArtifact[current];
365
+ const savedEntry = lastSaved[current];
366
+ const artifactPath = savedEntry?.path;
378
367
  if (stage.artifactDir !== "" && artifactPath === undefined) {
379
368
  return formatError(
380
369
  `phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
@@ -382,28 +371,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
382
371
  }
383
372
  let gateData: StageGateData | undefined;
384
373
  if (stage.artifactDir !== "" && artifactPath !== undefined) {
385
- const content = readArtifact(runCwd, artifactPath);
386
- if (!content.ok) return formatError(content.error);
387
- const gate = stage.gate(content.value, artifactPath, runCwd);
388
- if (!gate.ok) return formatError(gate.error);
389
- gateData = gate.value;
390
- }
391
-
392
- // Implement fanout runs BEFORE committing the transition: on failure the
393
- // phase stays put and the error remains visible as the advance_phase return.
394
- let implementSummary: string | undefined;
395
- if (outcome.phase === "implement" && gateData?.kind === "plan") {
396
- const planPath = artifactPath ?? "";
397
- implementSummary = await runImplementFanout(planPath, gateData.plan);
398
- if (isErrorText(implementSummary)) {
399
- return implementSummary;
374
+ if (savedEntry?.gateData !== undefined) {
375
+ gateData = savedEntry.gateData;
376
+ } else {
377
+ const content = readArtifact(runCwd, artifactPath);
378
+ if (!content.ok) return formatError(content.error);
379
+ const gate = stage.gate(content.value, artifactPath, runCwd);
380
+ if (!gate.ok) return formatError(gate.error);
381
+ gateData = gate.value;
382
+ lastSaved = {
383
+ ...lastSaved,
384
+ [current]: Object.freeze({ path: artifactPath, gateData }),
385
+ };
400
386
  }
401
387
  }
402
388
 
403
389
  // Transition accepted: persist row, schedule root history reset (fresh session).
404
390
  const prevArtifacts = phaseState?.artifacts ?? {};
405
- const nextArtifacts: Partial<Record<Phase, string>> = { ...prevArtifacts };
406
- if (artifactPath !== undefined) nextArtifacts[current] = artifactPath;
391
+ const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...prevArtifacts };
392
+ if (artifactPath !== undefined) {
393
+ nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
394
+ }
407
395
  phaseState = {
408
396
  current: outcome.phase,
409
397
  advancedAt: completedTurns,
@@ -412,20 +400,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
412
400
  backwardJumps: phaseState?.backwardJumps ?? 0,
413
401
  };
414
402
  await persistPhaseRow(phaseState, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
415
- // Leaving a stage: clear its lastSaved so a future re-entry must re-save.
416
403
  clearLastSaved(current);
417
- // Session-only ask counter (like lastSavedArtifact): reset on every accepted transition.
418
404
  askRoundsThisPhase = 0;
419
- pendingHistoryReset = resetHistoryForPhase(system, phaseState, {
420
- goal,
421
- implementSummary,
422
- });
423
-
424
- if (implementSummary !== undefined) {
425
- return implementSummary;
426
- }
427
- const prevLabel = `was '${current}'`;
428
- return `ok — phase advanced to '${outcome.phase}' (${prevLabel}${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
405
+ pendingHistoryReset = resetHistoryForPhase(system, phaseState, { goal });
406
+ return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
429
407
  },
430
408
  }
431
409
  : {};
@@ -455,6 +433,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
455
433
  const restoredSlots = input.resume && deps.runState && runId
456
434
  ? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
457
435
  : [];
436
+ // Seed host-side idempotency from restored sidecars so re-load is a no-op.
437
+ const restoredPrefixes: string[] = [];
438
+ for (const slot of restoredSlots) {
439
+ if (!Array.isArray(slot.payload) || slot.payload.length === 0) continue;
440
+ const first = slot.payload[0];
441
+ if (first === null || typeof first !== "object") continue;
442
+ const path = typeof (first as { path?: unknown }).path === "string"
443
+ ? (first as { path: string }).path
444
+ : "";
445
+ const m = path.match(/^(lib\/[^/]+\/)/);
446
+ if (m?.[1] !== undefined) restoredPrefixes.push(m[1]);
447
+ }
458
448
  const libraryHandlers = deps.config.libraryLoader
459
449
  ? buildLibraryHandler({
460
450
  cwd: runCwd,
@@ -462,6 +452,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
462
452
  parentId: selfReportId,
463
453
  signal: deps.signal,
464
454
  startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
455
+ loadedPrefixes: restoredPrefixes,
465
456
  onLoaded: async (index, payload) => {
466
457
  if (!persistOn || !runId || !deps.runState) return;
467
458
  await writeContextSidecar(
@@ -480,6 +471,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
480
471
  signal: deps.signal,
481
472
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
482
473
  maxPromptChars: deps.config.maxPromptChars,
474
+ // Pipeline at depth 0 is read-only: guard open() write modes in the worker.
475
+ readOnly: pipelineOn,
483
476
  handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
484
477
  });
485
478
 
@@ -489,24 +482,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
489
482
  if (input.resume) {
490
483
  limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
491
484
  best = input.resume.best;
492
- editsAcc = [];
493
485
  compactions = input.resume.compactions;
494
486
  completedTurns = input.resume.completedTurns;
495
487
  if (input.resume.phase) {
496
488
  const resumePhase = input.resume.phase;
497
- const artifacts: Partial<Record<Phase, string>> = {};
489
+ const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
498
490
  if (resumePhase.artifacts) {
499
491
  for (const [k, v] of Object.entries(resumePhase.artifacts)) {
500
- if (
501
- v !== undefined
502
- && (k === "clarify" || k === "research" || k === "blueprint" || k === "implement" || k === "validate")
503
- ) {
504
- artifacts[k] = v;
492
+ if (v !== undefined && isPhase(k)) {
493
+ artifacts[k] = Object.freeze({
494
+ path: v.path,
495
+ status: v.superseded ? "superseded" as const : "active" as const,
496
+ });
505
497
  }
506
498
  }
507
499
  }
508
500
  phaseState = {
509
- current: resumePhase.current as Phase,
501
+ current: reconcilePhase(resumePhase.current),
510
502
  advancedAt: resumePhase.advancedAt,
511
503
  summary: resumePhase.summary,
512
504
  artifacts,
@@ -514,8 +506,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
514
506
  };
515
507
  // lastSaved is session-only: never rehydrate from trail (would re-gate stale
516
508
  // plan/validation after loop-back / mid-stage resume without a fresh save).
517
- lastSavedArtifact = {};
518
- // askRoundsThisPhase is session-only (like lastSavedArtifact): a resume mid-clarify
509
+ lastSaved = {};
510
+ // askRoundsThisPhase is session-only (like lastSaved): a resume mid-clarify
519
511
  // restarts the interview count so the model must ask again in this process.
520
512
  askRoundsThisPhase = 0;
521
513
  }
@@ -541,13 +533,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
541
533
  }
542
534
 
543
535
  // Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
544
- const contextValue = typeof input.context === "object" && input.context !== null && "files" in input.context
545
- ? serializeForSandbox(input.context as ContextBundle)
546
- : input.context;
547
- await sandbox.loadContext(contextValue);
536
+ // Resume: merge library sidecars into the single `context` list (no context_N slots).
537
+ let contextValue: unknown =
538
+ typeof input.context === "object" && input.context !== null && "files" in input.context
539
+ ? serializeForSandbox(input.context as ContextBundle)
540
+ : input.context;
548
541
  for (const slot of restoredSlots) {
549
- await sandbox.loadContext(slot.payload, slot.index); // re-injects context_N for resumed runs
542
+ contextValue = mergeLibraryIntoContext(contextValue, slot.payload);
550
543
  }
544
+ await sandbox.loadContext(contextValue);
551
545
  if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
552
546
  await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
553
547
  for (let i = startTurn; i < deps.config.maxIterations; i++) {
@@ -629,14 +623,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
629
623
  if (answerContent) best = answerContent;
630
624
  else if (!best && turn.response.trim()) best = turn.response;
631
625
  completedTurns = i + 1;
632
- const proposedEdits = collectEdits(turn.results);
633
- if (proposedEdits.length > 0) editsAcc = proposedEdits;
634
626
  const final = finalAnswerOf(turn.results);
635
627
  if (final != null) {
636
628
  // Validate-phase finalize: measure THIS turn's validation save only (lastSaved),
637
629
  // never fall back to phaseState.artifacts (stale after a prior loop).
638
630
  if (pipelineOn && phaseState?.current === "validate") {
639
- const vPath = lastSavedArtifact.validate;
631
+ const vPath = lastSaved.validate?.path;
640
632
  if (vPath === undefined) {
641
633
  // Reject finalize — push error into next turn.
642
634
  history.push({ role: "assistant", content: turn.response });
@@ -669,14 +661,20 @@ export function createEngine(deps: EngineDeps): RunRlm {
669
661
  deps.config.maxBackwardJumps,
670
662
  );
671
663
  if (route.kind === "loop-back") {
672
- // Keep clarify/research; record validate for the reset message; DROP blueprint so
673
- // the model must write a new plan. Clear lastSaved so gates cannot re-use
674
- // round-1 plan/validation without a fresh save_artifact.
675
- const nextArtifacts: Partial<Record<Phase, string>> = {
676
- clarify: phaseState.artifacts.clarify,
677
- research: phaseState.artifacts.research,
678
- validate: vPath,
664
+ // Keep all prior artifacts; mark blueprint as superseded by this validation.
665
+ // lastSaved is still cleared so the gate must see a genuinely FRESH plan.
666
+ const prior = phaseState.artifacts.blueprint;
667
+ const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
668
+ ...phaseState.artifacts,
669
+ validate: Object.freeze({ path: vPath, status: "active" }),
679
670
  };
671
+ if (prior !== undefined) {
672
+ nextArtifacts.blueprint = Object.freeze({
673
+ path: prior.path,
674
+ status: "superseded",
675
+ supersededBy: vPath,
676
+ });
677
+ }
680
678
  phaseState = {
681
679
  current: "blueprint",
682
680
  advancedAt: completedTurns,
@@ -684,7 +682,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
684
682
  artifacts: nextArtifacts,
685
683
  backwardJumps: phaseState.backwardJumps + 1,
686
684
  };
687
- await persistPhaseRow(phaseState, vPath, "validate", gate.value);
685
+ await persistPhaseRow(phaseState, vPath, "validate", gate.value, prior?.path);
688
686
  clearLastSaved("blueprint", "validate");
689
687
  askRoundsThisPhase = 0;
690
688
  history = resetHistoryForPhase(system, phaseState, { goal, validation });
@@ -694,14 +692,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
694
692
  }
695
693
  if (route.kind === "halt") {
696
694
  const report = `${route.reason}\n\n${final}`;
697
- const halted = result(report, i + 1, limits, editsAcc);
695
+ const halted = result(report, i + 1, limits);
698
696
  await recordTerminal("completed", halted);
699
697
  lastAnswer = halted.answer;
700
698
  return halted;
701
699
  }
702
700
  // route.kind === "done" — accept final answer
703
701
  }
704
- const done = result(final, i + 1, limits, editsAcc);
702
+ const done = result(final, i + 1, limits);
705
703
  await recordTerminal("completed", done);
706
704
  lastAnswer = done.answer;
707
705
  return done;
@@ -733,7 +731,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
733
731
  // Trail keeps the real REPL output even when history was reset (issue #9).
734
732
  replOutputs: turnReplOutputs || undefined,
735
733
  answerContent: answerContent || undefined,
736
- edits: proposedEdits.length > 0 ? proposedEdits : undefined,
737
734
  error: turnHadError(turn.results),
738
735
  usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
739
736
  cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
@@ -744,21 +741,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
744
741
  }
745
742
  }
746
743
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
747
- const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits, editsAcc);
744
+ const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
748
745
  await recordTerminal("finalized", finalized);
749
746
  lastAnswer = finalized.answer;
750
747
  return finalized;
751
748
  } catch (err) {
752
749
  // Abort is a user action — resolve with the best partial, not an error.
753
750
  if (deps.signal?.aborted) {
754
- const aborted = result(best.trim() || "(aborted)", completedTurns, limits, editsAcc);
751
+ const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
755
752
  await recordTerminal("aborted", aborted);
756
753
  lastAnswer = aborted.answer;
757
754
  return aborted;
758
755
  }
759
756
  if (err instanceof LimitError) {
760
757
  nodeStatus = "error";
761
- const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits, editsAcc);
758
+ const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
762
759
  await recordTerminal("stopped", stopped);
763
760
  lastAnswer = stopped.answer;
764
761
  return stopped;
@@ -775,7 +772,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
775
772
  });
776
773
  } else {
777
774
  if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
778
- emitter.emitEdits(editsAcc.length > 0 ? editsAcc : []);
779
775
  emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
780
776
  }
781
777
  await sandbox?.dispose();
@@ -784,9 +780,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
784
780
  return run;
785
781
  }
786
782
 
787
- function result(answer: string, iterations: number, limits: LimitGuard, edits: ProposedEdit[] = []): RlmResult {
783
+ function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
788
784
  const u = limits.usage();
789
- return { answer, edits, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
785
+ return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
790
786
  }
791
787
 
792
788
  /** Out of turns: ask the model for its best final answer (plain text). */