@hicaru/pi-rlm 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
package/src/core/gates.ts CHANGED
@@ -78,6 +78,35 @@ export function countHeadingsOutsideFences(content: string, re: RegExp): number
78
78
  return count;
79
79
  }
80
80
 
81
+ /** Count newlines without materialising a line array. */
82
+ export function lineCountOf(text: string): number {
83
+ let count = 1;
84
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++;
85
+ return count;
86
+ }
87
+
88
+ /** Names of `## Phase N:` sections that contain no `### Success Criteria` heading. */
89
+ export function phasesMissingSuccessCriteria(content: string): readonly string[] {
90
+ const missing: string[] = [];
91
+ let currentPhase: string | undefined;
92
+ let sawCriteria = false;
93
+ const flush = (): void => {
94
+ if (currentPhase !== undefined && !sawCriteria) missing.push(currentPhase);
95
+ };
96
+ forEachLineOutsideFences(content, (line) => {
97
+ const phase = /^## Phase (\d+):/.exec(line);
98
+ if (phase) {
99
+ flush();
100
+ currentPhase = `Phase ${phase[1] ?? "?"}`;
101
+ sawCriteria = false;
102
+ return;
103
+ }
104
+ if (currentPhase !== undefined && /^### Success Criteria/.test(line)) sawCriteria = true;
105
+ });
106
+ flush();
107
+ return Object.freeze(missing);
108
+ }
109
+
81
110
  /**
82
111
  * Fence-aware count of top-level (column-0) `- ` bullets under a `## <heading>` section.
83
112
  * Nested/indented sub-bullets are ignored. The next `## ` heading ends the section.
@@ -106,7 +135,7 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
106
135
  const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
107
136
  let inSection = false;
108
137
  let seen = false; // first match wins — do not re-enter on a later duplicate heading
109
- let body = "";
138
+ let hasBody = false; // only emptiness matters — never accumulate the body itself
110
139
  forEachLineOutsideFences(content, (line) => {
111
140
  if (/^##\s+/.test(line)) {
112
141
  if (inSection) {
@@ -119,9 +148,9 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
119
148
  }
120
149
  return;
121
150
  }
122
- if (inSection) body += `${line}\n`;
151
+ if (inSection && line.trim().length > 0) hasBody = true;
123
152
  });
124
- return body.trim().length > 0;
153
+ return hasBody;
125
154
  }
126
155
 
127
156
  function escapeRegExp(s: string): string {
@@ -207,7 +236,7 @@ export function verifyCitations(body: string, cwd: string): GateResult<undefined
207
236
  }
208
237
  let lineCount: number;
209
238
  try {
210
- lineCount = readFileSync(abs, "utf-8").split("\n").length;
239
+ lineCount = lineCountOf(readFileSync(abs, "utf-8"));
211
240
  } catch {
212
241
  errors.push(`unbacked citation ${key} — file could not be read`);
213
242
  continue;
@@ -13,6 +13,24 @@ export interface Limits {
13
13
  readonly maxErrors?: number;
14
14
  }
15
15
 
16
+ /** Pick the limit caps out of a config (`RlmConfig` satisfies this structurally). */
17
+ export function limitsFromConfig(config: Limits): Limits {
18
+ return {
19
+ maxBudgetUsd: config.maxBudgetUsd,
20
+ maxTimeoutMs: config.maxTimeoutMs,
21
+ maxTokens: config.maxTokens,
22
+ maxErrors: config.maxErrors,
23
+ };
24
+ }
25
+
26
+ /** Point-in-time totals for a run. */
27
+ export interface UsageSnapshot {
28
+ readonly inputTokens: number;
29
+ readonly outputTokens: number;
30
+ readonly costUsd: number;
31
+ readonly durationMs: number;
32
+ }
33
+
16
34
  export class LimitError extends Error {
17
35
  constructor(
18
36
  public readonly kind: "timeout" | "tokens" | "budget" | "errors",
@@ -71,7 +89,7 @@ export class LimitGuard {
71
89
  }
72
90
  }
73
91
 
74
- usage() {
92
+ usage(): UsageSnapshot {
75
93
  return {
76
94
  inputTokens: this.inputTokens,
77
95
  outputTokens: this.outputTokens,
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The phase pipeline's stateful half: the `save_artifact` / `advance_phase` sandbox handlers
3
+ * and the validate-phase finalize routing.
4
+ *
5
+ * Pulled out of the engine's run closure so the pipeline's mutable state (current phase, the
6
+ * per-phase latest save, serviced ask rounds, accumulated warnings, a pending history reset)
7
+ * lives in one owner instead of six `let`s threaded through a 640-line function.
8
+ *
9
+ * Two invariants this file exists to protect:
10
+ * - Gates measure the CURRENT phase's latest save (`lastSaved`) only — never
11
+ * `phase.artifacts`, whose paths are the completed channel and go stale across a
12
+ * corrective loop-back.
13
+ * - `lastSaved` and `askRounds` are session-only. They are never rehydrated from the trail,
14
+ * so a resumed run must genuinely re-save and re-interview rather than re-gate stale work.
15
+ */
16
+
17
+ import type { ChatMsg } from "../bridge/model.ts";
18
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
19
+ import type { PhaseRecon } from "../state/resume.ts";
20
+ import { formatError } from "../util/errors.ts";
21
+ import { readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
22
+ import { critiqueArtifact, formatCritique } from "./critique.ts";
23
+ import {
24
+ advancePhase as validatePhaseTransition,
25
+ initialPhaseState,
26
+ isPhase,
27
+ PHASES,
28
+ reconcilePhase,
29
+ routeAfterValidate,
30
+ stageForArtifactKind,
31
+ STAGES,
32
+ type ArtifactRef,
33
+ type Phase,
34
+ type PhaseState,
35
+ type SavedArtifact,
36
+ type StageGateData,
37
+ } from "./pipeline.ts";
38
+
39
+ /** Builds the fresh-session history for a phase. Supplied by the engine (its own policy). */
40
+ export type ResetHistoryForPhase = (
41
+ state: PhaseState,
42
+ options: { readonly goal?: GoalCapture; readonly validation?: import("./gates.ts").ValidationGateData; readonly notice?: string },
43
+ ) => ChatMsg[];
44
+
45
+ /** Appends a `phase` row to the run trail. Supplied by the engine (owns persistence state). */
46
+ export type PersistPhaseRow = (
47
+ state: PhaseState,
48
+ artifactPath: string | undefined,
49
+ artifactPhase: Phase | undefined,
50
+ gateData: StageGateData | undefined,
51
+ supersededPath?: string,
52
+ ) => Promise<void>;
53
+
54
+ export interface PipelineDeps {
55
+ /** Repo root that artifact paths are resolved against. */
56
+ readonly runCwd: string;
57
+ readonly maxBackwardJumps: number;
58
+ readonly emitter: RlmEmitter;
59
+ /** Turns completed so far — phase rows and `advancedAt` are stamped with it. */
60
+ readonly completedTurns: () => number;
61
+ readonly resetHistoryForPhase: ResetHistoryForPhase;
62
+ readonly persistPhaseRow: PersistPhaseRow;
63
+ }
64
+
65
+ /** What the engine should do with a finalize submitted while in the `validate` phase. */
66
+ export type ValidateOutcome =
67
+ /** Not finalizable yet — feed `error` back as the next turn's REPL output. */
68
+ | { readonly kind: "reject"; readonly error: string }
69
+ /** Blockers found — re-enter `blueprint` with this fresh history. */
70
+ | { readonly kind: "loop-back"; readonly history: ChatMsg[] }
71
+ /** Backward-jump cap reached — terminate with this report. */
72
+ | { readonly kind: "halt"; readonly report: string }
73
+ /** Validation passed — take the model's final answer. */
74
+ | { readonly kind: "accept" };
75
+
76
+ /** The sandbox handlers the pipeline contributes. Shape-compatible with `SubLlmHandlers`. */
77
+ export interface PipelineHandlers {
78
+ saveArtifact(kind: string, content: string): Promise<string>;
79
+ advancePhase(phase: string, summary: string | undefined): Promise<string>;
80
+ }
81
+
82
+ export class PipelineController {
83
+ /** Current phase state; `undefined` until seeded. Read by the engine for gate prompts/rows. */
84
+ phase: PhaseState | undefined;
85
+
86
+ /**
87
+ * Latest save per phase: path plus an optional gate memo, as ONE record so the two cannot
88
+ * desync. Cleared on phase exit and on loop-back.
89
+ */
90
+ private lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
91
+
92
+ /** Serviced ask_user_question rounds in the current phase (session-only). */
93
+ private askRounds = 0;
94
+
95
+ /** Advisory critique warnings accumulated across saves (surfaced in the TUI). */
96
+ private warnings: readonly string[] = [];
97
+
98
+ /** History replacement scheduled by advance_phase; the engine drains it at a turn boundary. */
99
+ private pendingReset: ChatMsg[] | undefined;
100
+
101
+ private goal: GoalCapture | undefined;
102
+
103
+ constructor(private readonly deps: PipelineDeps) {}
104
+
105
+ /** Called after each successfully serviced root-depth ask_user_question round. */
106
+ noteAskRound(): void {
107
+ this.askRounds++;
108
+ }
109
+
110
+ /** Take and clear the scheduled fresh-session history, if advance_phase left one. */
111
+ takePendingReset(): ChatMsg[] | undefined {
112
+ const reset = this.pendingReset;
113
+ this.pendingReset = undefined;
114
+ return reset;
115
+ }
116
+
117
+ /** Seed a fresh run: capture goal, enter `startPhase`, and build the opening history. */
118
+ seedFresh(startPhase: Phase, goal: GoalCapture | undefined, notice: string | undefined): ChatMsg[] {
119
+ this.goal = goal;
120
+ this.phase = initialPhaseState(0, startPhase);
121
+ return this.deps.resetHistoryForPhase(this.phase, { goal, notice });
122
+ }
123
+
124
+ /**
125
+ * Rehydrate phase state from a trail. `lastSaved`/`askRounds` stay empty by design: a
126
+ * resume must re-save and re-interview rather than re-gate work from a previous process.
127
+ */
128
+ seedFromResume(recon: PhaseRecon): void {
129
+ const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
130
+ for (const [key, value] of Object.entries(recon.artifacts ?? {})) {
131
+ if (value === undefined || !isPhase(key)) continue;
132
+ artifacts[key] = Object.freeze({
133
+ path: value.path,
134
+ status: value.superseded ? ("superseded" as const) : ("active" as const),
135
+ });
136
+ }
137
+ this.phase = {
138
+ current: reconcilePhase(recon.current),
139
+ advancedAt: recon.advancedAt,
140
+ summary: recon.summary,
141
+ artifacts,
142
+ backwardJumps: recon.backwardJumps ?? 0,
143
+ };
144
+ this.lastSaved = {};
145
+ this.askRounds = 0;
146
+ }
147
+
148
+ handlers(): PipelineHandlers {
149
+ return {
150
+ saveArtifact: (kind, content) => this.handleSaveArtifact(kind, content),
151
+ advancePhase: (phase, summary) => this.handleAdvancePhase(phase, summary),
152
+ };
153
+ }
154
+
155
+ // ── save_artifact ──
156
+
157
+ private async handleSaveArtifact(kind: string, content: string): Promise<string> {
158
+ const stage = stageForArtifactKind(kind);
159
+ if (stage === undefined) {
160
+ return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
161
+ }
162
+ const current = this.currentPhase();
163
+ if (stage.phase !== current) {
164
+ return formatError(`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`);
165
+ }
166
+ const saved = saveArtifact(this.deps.runCwd, stage.artifactDir, kind, content);
167
+ if (!saved.ok) return formatError(saved.error);
168
+
169
+ // Preflight: run the SAME gate advance_phase will run, now instead of a turn later, and
170
+ // memoize the verdict so the transition does not re-read and re-gate the file.
171
+ const critique = critiqueArtifact(stage, content, saved.path, this.deps.runCwd);
172
+ this.lastSaved = {
173
+ ...this.lastSaved,
174
+ [stage.phase]: Object.freeze({ path: saved.path, gateData: critique.gateData }),
175
+ };
176
+ if (critique.warnings.length > 0) {
177
+ this.warnings = Object.freeze([...this.warnings, ...critique.warnings]);
178
+ this.deps.emitter.emitWarnings(this.warnings);
179
+ }
180
+ return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
181
+ }
182
+
183
+ // ── advance_phase ──
184
+
185
+ private async handleAdvancePhase(phase: string, summary: string | undefined): Promise<string> {
186
+ const current = this.currentPhase();
187
+ const outcome = validatePhaseTransition(current, phase);
188
+ if (!outcome.ok) return formatError(outcome.error);
189
+
190
+ // Clarify interview gate: the engine counts serviced rounds itself, so the model cannot
191
+ // advance by merely claiming to have interviewed the user.
192
+ if (current === "clarify" && this.askRounds === 0) {
193
+ return formatError("clarify requires at least one ask_user_question round — interview the user before advancing");
194
+ }
195
+
196
+ const gated = this.gateCurrentPhase(current);
197
+ if (!gated.ok) return formatError(gated.error);
198
+ const { artifactPath, gateData } = gated;
199
+
200
+ const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...(this.phase?.artifacts ?? {}) };
201
+ if (artifactPath !== undefined) {
202
+ nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
203
+ }
204
+ this.phase = {
205
+ current: outcome.phase,
206
+ advancedAt: this.deps.completedTurns(),
207
+ summary,
208
+ artifacts: nextArtifacts,
209
+ backwardJumps: this.phase?.backwardJumps ?? 0,
210
+ };
211
+ await this.deps.persistPhaseRow(this.phase, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
212
+ this.clearLastSaved(current);
213
+ this.askRounds = 0;
214
+ this.pendingReset = this.deps.resetHistoryForPhase(this.phase, { goal: this.goal });
215
+ return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
216
+ }
217
+
218
+ /**
219
+ * Measure the current phase's latest save. Deliberately reads `lastSaved` only — falling
220
+ * back to `phase.artifacts` would let a stale path from before a loop-back pass the gate.
221
+ */
222
+ private gateCurrentPhase(
223
+ current: Phase,
224
+ ): { ok: true; artifactPath: string | undefined; gateData: StageGateData | undefined } | { ok: false; error: string } {
225
+ const stage = STAGES[current];
226
+ if (stage.artifactDir === "") return { ok: true, artifactPath: undefined, gateData: undefined };
227
+
228
+ const savedEntry = this.lastSaved[current];
229
+ const artifactPath = savedEntry?.path;
230
+ if (artifactPath === undefined) {
231
+ return {
232
+ ok: false,
233
+ error: `phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
234
+ };
235
+ }
236
+ if (savedEntry?.gateData !== undefined) {
237
+ return { ok: true, artifactPath, gateData: savedEntry.gateData };
238
+ }
239
+ // No memo (e.g. the file was written outside save_artifact) — read and gate it now.
240
+ const content = readArtifact(this.deps.runCwd, artifactPath);
241
+ if (!content.ok) return { ok: false, error: content.error };
242
+ const gate = stage.gate(content.value, artifactPath, this.deps.runCwd);
243
+ if (!gate.ok) return { ok: false, error: gate.error };
244
+ this.lastSaved = {
245
+ ...this.lastSaved,
246
+ [current]: Object.freeze({ path: artifactPath, gateData: gate.value }),
247
+ };
248
+ return { ok: true, artifactPath, gateData: gate.value };
249
+ }
250
+
251
+ // ── validate-phase finalize ──
252
+
253
+ /**
254
+ * Decide what a finalize submitted during `validate` means. As with advance_phase, this
255
+ * measures THIS turn's validation save only, never `phase.artifacts`.
256
+ */
257
+ async finalizeInValidate(final: string): Promise<ValidateOutcome> {
258
+ const phase = this.phase;
259
+ if (phase === undefined) return { kind: "accept" };
260
+
261
+ const vPath = this.lastSaved.validate?.path;
262
+ if (vPath === undefined) {
263
+ return {
264
+ kind: "reject",
265
+ error: formatError(
266
+ 'finalize rejected — save the validation artifact first via save_artifact("validation", content) with status: ready, blockers_count, and verdict',
267
+ ),
268
+ };
269
+ }
270
+ const content = readArtifact(this.deps.runCwd, vPath);
271
+ if (!content.ok) return { kind: "reject", error: formatError(content.error) };
272
+
273
+ const gate = STAGES.validate.gate(content.value, vPath, this.deps.runCwd);
274
+ if (!gate.ok) return { kind: "reject", error: formatError(gate.error) };
275
+ if (gate.value.kind !== "validation") {
276
+ return { kind: "reject", error: formatError("internal: validate gate did not return validation data") };
277
+ }
278
+
279
+ const { validation } = gate.value;
280
+ const route = routeAfterValidate(validation, phase.backwardJumps, this.deps.maxBackwardJumps);
281
+ if (route.kind === "halt") return { kind: "halt", report: `${route.reason}\n\n${final}` };
282
+ if (route.kind !== "loop-back") return { kind: "accept" };
283
+
284
+ // Loop back to blueprint. Prior artifacts are kept — the append-only journal marks the
285
+ // blueprint superseded by this validation rather than dropping it.
286
+ const prior = phase.artifacts.blueprint;
287
+ const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
288
+ ...phase.artifacts,
289
+ validate: Object.freeze({ path: vPath, status: "active" }),
290
+ };
291
+ if (prior !== undefined) {
292
+ nextArtifacts.blueprint = Object.freeze({ path: prior.path, status: "superseded", supersededBy: vPath });
293
+ }
294
+ this.phase = {
295
+ current: "blueprint",
296
+ advancedAt: this.deps.completedTurns(),
297
+ summary: `loop-back: ${validation.blockersCount} blocker(s)`,
298
+ artifacts: nextArtifacts,
299
+ backwardJumps: phase.backwardJumps + 1,
300
+ };
301
+ await this.deps.persistPhaseRow(this.phase, vPath, "validate", gate.value, prior?.path);
302
+ // Clear BOTH so the re-entered blueprint must produce a genuinely fresh plan and a fresh
303
+ // validation of it, rather than re-gating what was just rejected.
304
+ this.clearLastSaved("blueprint", "validate");
305
+ this.askRounds = 0;
306
+ this.pendingReset = undefined;
307
+ return { kind: "loop-back", history: this.deps.resetHistoryForPhase(this.phase, { goal: this.goal, validation }) };
308
+ }
309
+
310
+ private currentPhase(): Phase {
311
+ return this.phase?.current ?? PHASES[0];
312
+ }
313
+
314
+ private clearLastSaved(...phases: readonly Phase[]): void {
315
+ const next: Partial<Record<Phase, SavedArtifact>> = { ...this.lastSaved };
316
+ for (const phase of phases) delete next[phase];
317
+ this.lastSaved = next;
318
+ }
319
+ }
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * RLM pipeline stage graph — data-driven transitions with deterministic gates.
3
3
  *
4
- * Stages: clarify → research → blueprint → implement → validate
4
+ * Stages: clarify → research → blueprint → validate
5
5
  * (clarify skipped when askUserQuestion is off).
6
6
  * The root RLM writes artifacts via save_artifact(); advance_phase() is gated by
7
7
  * TypeScript floors (never LLM judgment). Validate routes on measured
8
8
  * blockers_count with a bounded corrective loop back to blueprint.
9
+ * The pipeline is read-only by design: it produces a validated plan and does not
10
+ * write source. Accidental sandbox writes are blocked (open/pathlib/os.open);
11
+ * this is steering, not a hard security boundary.
9
12
  */
10
13
  import {
11
14
  checkStatusReady,
@@ -19,16 +22,31 @@ import {
19
22
  verifyCitations,
20
23
  } from "./gates.ts";
21
24
 
22
- export type Phase = "clarify" | "research" | "blueprint" | "implement" | "validate";
25
+ export type Phase = "clarify" | "research" | "blueprint" | "validate";
23
26
 
24
27
  export const PHASES = Object.freeze([
25
28
  "clarify",
26
29
  "research",
27
30
  "blueprint",
28
- "implement",
29
31
  "validate",
30
32
  ] as const satisfies readonly Phase[]);
31
33
 
34
+ const PHASE_SET: ReadonlySet<string> = Object.freeze(new Set<string>(PHASES));
35
+
36
+ export function isPhase(value: unknown): value is Phase {
37
+ return typeof value === "string" && PHASE_SET.has(value);
38
+ }
39
+
40
+ /**
41
+ * Map a persisted phase name onto the current graph. Trails written before the
42
+ * implement phase was retired resume at `blueprint` — the last phase whose
43
+ * artifact is still meaningful.
44
+ */
45
+ export function reconcilePhase(persisted: unknown): Phase {
46
+ if (isPhase(persisted)) return persisted;
47
+ return "blueprint";
48
+ }
49
+
32
50
  /** Kind string the model passes to save_artifact(kind, content). */
33
51
  export type ArtifactKind = "clarification" | "research" | "plan" | "validation";
34
52
 
@@ -37,8 +55,7 @@ export type StageGateData =
37
55
  | { readonly kind: "clarification"; readonly clarification: ClarificationGateData }
38
56
  | { readonly kind: "research" }
39
57
  | { readonly kind: "plan"; readonly plan: PlanGateData }
40
- | { readonly kind: "validation"; readonly validation: ValidationGateData }
41
- | { readonly kind: "side-effect" };
58
+ | { readonly kind: "validation"; readonly validation: ValidationGateData };
42
59
 
43
60
  export interface StageDef {
44
61
  readonly phase: Phase;
@@ -90,13 +107,6 @@ export const STAGES: Readonly<Record<Phase, StageDef>> = Object.freeze({
90
107
  clarify: { phase: "clarify", artifactDir: "clarifications", artifactKind: "clarification", gate: clarifyGate },
91
108
  research: { phase: "research", artifactDir: "research", artifactKind: "research", gate: researchGate },
92
109
  blueprint: { phase: "blueprint", artifactDir: "plans", artifactKind: "plan", gate: blueprintGate },
93
- // implement is a side-effect stage; exit is engine-driven (serial fanout).
94
- implement: {
95
- phase: "implement",
96
- artifactDir: "",
97
- artifactKind: "",
98
- gate: (): GateResult<StageGateData> => ({ ok: true, value: { kind: "side-effect" } }),
99
- },
100
110
  validate: { phase: "validate", artifactDir: "validations", artifactKind: "validation", gate: validateGate },
101
111
  });
102
112
 
@@ -115,12 +125,27 @@ export function stageForArtifactKind(kind: string): StageDef | undefined {
115
125
  return undefined;
116
126
  }
117
127
 
128
+ /** An artifact path plus its lifecycle status — append-only, never deleted. */
129
+ export interface ArtifactRef {
130
+ readonly path: string;
131
+ readonly status: "active" | "superseded";
132
+ /** Path of the artifact that superseded this one (the rejecting validation). */
133
+ readonly supersededBy?: string;
134
+ }
135
+
136
+ /** The latest artifact saved for a stage, plus its gate result when it passed. */
137
+ export interface SavedArtifact {
138
+ readonly path: string;
139
+ /** Gate payload when the artifact passed; undefined ⇒ advance_phase must re-gate. */
140
+ readonly gateData?: StageGateData;
141
+ }
142
+
118
143
  export interface PhaseState {
119
144
  readonly current: Phase;
120
145
  readonly advancedAt: number;
121
146
  readonly summary?: string;
122
147
  /** Artifact each completed stage produced — the named channels. */
123
- readonly artifacts: Readonly<Partial<Record<Phase, string>>>;
148
+ readonly artifacts: Readonly<Partial<Record<Phase, ArtifactRef>>>;
124
149
  /** Corrective validate→blueprint re-entries taken so far. */
125
150
  readonly backwardJumps: number;
126
151
  }
@@ -183,7 +208,7 @@ export function advancePhase(
183
208
  current: Phase | undefined,
184
209
  target: string,
185
210
  ): AdvancePhaseOutcome {
186
- if (!PHASES.includes(target as Phase)) {
211
+ if (!isPhase(target)) {
187
212
  return {
188
213
  ok: false,
189
214
  error: `unknown phase '${target}'; valid phases: ${PHASES.join(", ")}`,
@@ -206,7 +231,7 @@ export function advancePhase(
206
231
  phase: from,
207
232
  };
208
233
  }
209
- return { ok: true, phase: target as Phase };
234
+ return { ok: true, phase: target };
210
235
  }
211
236
 
212
237
  /** Return the current phase (defaults to first phase if undefined). */
package/src/core/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Shared configuration + runtime types for the RLM engine. */
2
2
 
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
- import type { AskAnswer, AskQuestion, ProposedEdit } from "../sandbox/protocol.ts";
4
+ import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
5
5
  import type { ReconstructResult } from "../state/resume.ts";
6
6
 
7
7
  export interface Sampling {
@@ -10,8 +10,6 @@ export interface Sampling {
10
10
  readonly reasoning?: ThinkingLevel;
11
11
  }
12
12
 
13
- type MutableSampling = { -readonly [Key in keyof Sampling]?: Sampling[Key] };
14
-
15
13
  export interface RunLogConfig {
16
14
  /** Default: true — always-on, opt-out. */
17
15
  readonly enabled?: boolean;
@@ -25,59 +23,59 @@ export interface RunLogConfig {
25
23
 
26
24
  export interface RlmConfig {
27
25
  /** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
28
- enabled: boolean;
26
+ readonly enabled: boolean;
29
27
  /** Max recursion depth. depth >= maxDepth ⇒ rlm_query falls back to a plain llm_query. */
30
- maxDepth: number;
28
+ readonly maxDepth: number;
31
29
  /** Max turns before the engine must finalize. */
32
- maxIterations: number;
30
+ readonly maxIterations: number;
33
31
  /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
34
- execTimeoutS: number;
32
+ readonly execTimeoutS: number;
35
33
  /** Parent-side watchdog per sandbox request (ms). */
36
- requestTimeoutMs: number;
34
+ readonly requestTimeoutMs: number;
37
35
  /** Concurrency pool for *_batched sub-calls. */
38
- maxConcurrentSubcalls: number;
36
+ readonly maxConcurrentSubcalls: number;
39
37
  /** Reject sub-LLM prompts larger than this many chars. */
40
- maxPromptChars: number;
38
+ readonly maxPromptChars: number;
41
39
  /** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
42
- maxBudgetUsd?: number;
40
+ readonly maxBudgetUsd?: number;
43
41
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
44
- maxTimeoutMs?: number;
42
+ readonly maxTimeoutMs?: number;
45
43
  /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
46
- maxTokens?: number;
44
+ readonly maxTokens?: number;
47
45
  /** Max consecutive error turns before the engine stops (undefined = no cap). */
48
- maxErrors?: number;
46
+ readonly maxErrors?: number;
49
47
  /** Append the orchestrator addendum to the system prompt. */
50
- orchestrator: boolean;
48
+ readonly orchestrator: boolean;
51
49
  /** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
52
- pipeline: boolean;
50
+ readonly pipeline: boolean;
53
51
  /** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
54
- maxBackwardJumps: number;
52
+ readonly maxBackwardJumps: number;
55
53
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
56
- compaction: boolean;
54
+ readonly compaction: boolean;
57
55
  /** Compact when estimated history tokens reach this fraction of the model's context window. */
58
- compactionThresholdPct: number;
56
+ readonly compactionThresholdPct: number;
59
57
  /** Python executable used to launch the sandbox worker. */
60
- python: string;
58
+ readonly python: string;
61
59
  /** Worker startup wait before treating sandbox init as failed (ms). */
62
- sandboxInitTimeoutMs: number;
60
+ readonly sandboxInitTimeoutMs: number;
63
61
  /** Allow ask_user_question() calls from the root REPL. */
64
- askUserQuestion: boolean;
62
+ readonly askUserQuestion: boolean;
65
63
  /** Allow todo() calls from the REPL. */
66
- todo: boolean;
64
+ readonly todo: boolean;
67
65
  /** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
68
- libraryLoader: boolean;
66
+ readonly libraryLoader: boolean;
69
67
  /** ThinkingLevel for the root smart model (set via /rlm-config). */
70
- smartReasoning?: ThinkingLevel;
68
+ readonly smartReasoning?: ThinkingLevel;
71
69
  /** Output token cap + temperature for the root smart model per turn.
72
70
  * Keeps each turn short so the next turn's input stays manageable.
73
71
  * `reasoning` is read from `smartReasoning` if omitted here. */
74
- rootSampling?: Readonly<Sampling>;
72
+ readonly rootSampling?: Readonly<Sampling>;
75
73
  /** System prompt injected into every llm_query / llm_query_batched sub-call.
76
74
  * Instructs the worker model to respond concisely.
77
75
  * undefined = no system prompt (raw completion). */
78
- subSystemPrompt?: string;
76
+ readonly subSystemPrompt?: string;
79
77
  /** Sampling for sub-LLM (worker) calls. */
80
- subSampling: MutableSampling;
78
+ readonly subSampling: Readonly<Sampling>;
81
79
  /** Optional run-state persistence configuration. Enabled by default. */
82
80
  readonly runLog?: RunLogConfig;
83
81
  }
@@ -105,8 +103,6 @@ export interface RlmInput {
105
103
  /** Result of a completed RLM run. */
106
104
  export interface RlmResult {
107
105
  readonly answer: string;
108
- /** Legacy anchor edits retained for compatibility while older run-state rows exist. */
109
- readonly edits?: readonly ProposedEdit[];
110
106
  readonly iterations: number;
111
107
  readonly costUsd: number;
112
108
  readonly inputTokens: number;