@hicaru/pi-rlm 0.1.7 → 0.1.8

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,272 @@
1
+ /**
2
+ * Deterministic gate floors for the RLM pipeline: the engine measures artifacts
3
+ * instead of trusting the model's claim that a phase is complete.
4
+ */
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import { isAbsolute, join } from "node:path";
7
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
+ import type { Result } from "../util/errors.ts";
9
+
10
+ /** Gate outcome — same shape as Result; alias keeps call sites domain-clear. */
11
+ export type GateResult<T> = Result<T, string>;
12
+
13
+ export const MAX_PHASES = 32;
14
+
15
+ /** One parsed entry of a plan's `phases:` frontmatter array. */
16
+ export interface PhaseRecord {
17
+ readonly n: number;
18
+ readonly title: string;
19
+ readonly index: number;
20
+ readonly total: number;
21
+ }
22
+
23
+ export interface PlanGateData {
24
+ readonly phases: readonly PhaseRecord[];
25
+ }
26
+
27
+ export interface ValidationGateData {
28
+ readonly blockersCount: number;
29
+ readonly verdict: "pass" | "fail";
30
+ }
31
+
32
+ export interface ClarificationGateData {
33
+ readonly decisionsCount: number;
34
+ readonly openQuestionsCount: number;
35
+ }
36
+
37
+ const PLAN_PHASE_RE = /^## Phase (\d+):/;
38
+ const STATUS_READY = "ready";
39
+ const BULLET_RE = /^-\s+\S/;
40
+
41
+ /**
42
+ * Walk content lines, invoking `visit` only for lines outside fenced code blocks.
43
+ * Shared by heading and bullet counters (DRY — single fence scan).
44
+ */
45
+ export function forEachLineOutsideFences(
46
+ content: string,
47
+ visit: (line: string) => void,
48
+ ): void {
49
+ let inFence = false;
50
+ let fenceLen = 0;
51
+ for (const line of content.split("\n")) {
52
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line);
53
+ if (fence) {
54
+ const len = (fence[1] ?? "").length;
55
+ if (!inFence) {
56
+ inFence = true;
57
+ fenceLen = len;
58
+ } else if (len >= fenceLen && line.trim().length === len) {
59
+ inFence = false;
60
+ fenceLen = 0;
61
+ }
62
+ continue;
63
+ }
64
+ if (!inFence) visit(line);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Count lines matching `re` OUTSIDE fenced code blocks — a `## Phase N:` inside
70
+ * a ``` fence is example text, not a structural heading.
71
+ */
72
+ export function countHeadingsOutsideFences(content: string, re: RegExp): number {
73
+ const lineRe = new RegExp(re.source);
74
+ let count = 0;
75
+ forEachLineOutsideFences(content, (line) => {
76
+ if (lineRe.test(line)) count++;
77
+ });
78
+ return count;
79
+ }
80
+
81
+ /**
82
+ * Fence-aware count of top-level (column-0) `- ` bullets under a `## <heading>` section.
83
+ * Nested/indented sub-bullets are ignored. The next `## ` heading ends the section.
84
+ * Missing heading ⇒ 0.
85
+ */
86
+ export function countBulletsUnderHeading(content: string, heading: string): number {
87
+ const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
88
+ let inSection = false;
89
+ let count = 0;
90
+ forEachLineOutsideFences(content, (line) => {
91
+ if (/^##\s+/.test(line)) {
92
+ inSection = headingRe.test(line);
93
+ return;
94
+ }
95
+ // Column-0 only: do not trimStart — indented sub-bullets must not inflate the count.
96
+ if (inSection && BULLET_RE.test(line)) count++;
97
+ });
98
+ return count;
99
+ }
100
+
101
+ /**
102
+ * True when `## <heading>` exists and has non-whitespace body before the next `## `.
103
+ * Only the first matching heading is considered (later duplicates are ignored).
104
+ */
105
+ export function sectionHasNonEmptyBody(content: string, heading: string): boolean {
106
+ const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
107
+ let inSection = false;
108
+ let seen = false; // first match wins — do not re-enter on a later duplicate heading
109
+ let body = "";
110
+ forEachLineOutsideFences(content, (line) => {
111
+ if (/^##\s+/.test(line)) {
112
+ if (inSection) {
113
+ inSection = false;
114
+ return;
115
+ }
116
+ if (!seen && headingRe.test(line)) {
117
+ inSection = true;
118
+ seen = true;
119
+ }
120
+ return;
121
+ }
122
+ if (inSection) body += `${line}\n`;
123
+ });
124
+ return body.trim().length > 0;
125
+ }
126
+
127
+ function escapeRegExp(s: string): string {
128
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ }
130
+
131
+ /** Frontmatter as a plain record (parseFrontmatter returns unknown-shaped data). */
132
+ function frontmatterOf(content: string): Record<string, unknown> {
133
+ const { frontmatter } = parseFrontmatter(content);
134
+ return typeof frontmatter === "object" && frontmatter !== null
135
+ ? (frontmatter as Record<string, unknown>)
136
+ : {};
137
+ }
138
+
139
+ /** `status: ready` floor — shared by every produces-stage gate. */
140
+ export function checkStatusReady(content: string, path: string): GateResult<undefined> {
141
+ const status = frontmatterOf(content).status;
142
+ return status === STATUS_READY
143
+ ? { ok: true, value: undefined }
144
+ : { ok: false, error: `artifact ${path} has status '${String(status)}' — set frontmatter status: ready before advancing` };
145
+ }
146
+
147
+ /**
148
+ * Plan-structure floor:
149
+ * `phases:` array ≡ fence-aware `## Phase N:` heading count, `phase_count` ≡
150
+ * array length, count within [1, MAX_PHASES]. Stale array ⇒ reject, so the
151
+ * fanout never dispatches a wrong unit list.
152
+ */
153
+ export function planPhaseRecords(content: string, path: string): GateResult<PlanGateData> {
154
+ const fm = frontmatterOf(content);
155
+ const raw = fm.phases;
156
+ const phases = Array.isArray(raw) ? raw : [];
157
+ const headingCount = countHeadingsOutsideFences(content, PLAN_PHASE_RE);
158
+ if (phases.length !== headingCount) {
159
+ return { ok: false, error: `plan ${path}: frontmatter phases (${phases.length}) ≠ '## Phase N:' headings (${headingCount}) — rebuild the phases: array from the body headings` };
160
+ }
161
+ if (fm.phase_count !== phases.length) {
162
+ return { ok: false, error: `plan ${path}: phase_count (${String(fm.phase_count)}) ≠ phases length (${phases.length}) — rebuild phase_count` };
163
+ }
164
+ if (phases.length === 0) {
165
+ return { ok: false, error: `plan ${path}: declares no '## Phase N:' sections — a plan needs at least one phase` };
166
+ }
167
+ if (phases.length > MAX_PHASES) {
168
+ return { ok: false, error: `plan ${path}: ${phases.length} phases exceeds MAX_PHASES (${MAX_PHASES}) — split the plan` };
169
+ }
170
+ const records = new Array<PhaseRecord>(phases.length);
171
+ for (let index = 0; index < phases.length; index++) {
172
+ const entry = phases[index];
173
+ const e = typeof entry === "object" && entry !== null ? (entry as Record<string, unknown>) : {};
174
+ records[index] = {
175
+ n: typeof e.n === "number" ? e.n : index + 1,
176
+ title: typeof e.title === "string" ? e.title : "",
177
+ index,
178
+ total: phases.length,
179
+ };
180
+ }
181
+ return { ok: true, value: { phases: records } };
182
+ }
183
+
184
+ /**
185
+ * Citation floor (direct path resolution only): every `path/file.ext:NN[-MM]`
186
+ * in the artifact body must name a real file with at least NN lines. Unbacked
187
+ * citations are fabricated precision — reject before they mislead implement.
188
+ */
189
+ const FILE_LINE_CITATION_RE =
190
+ /((?:(?<![\w.])\.)?(?<!\w)[\w][\w./-]*\.[a-zA-Z][a-zA-Z0-9]{0,4}):(\d+)(?:-(\d+))?/g;
191
+
192
+ export function verifyCitations(body: string, cwd: string): GateResult<undefined> {
193
+ const errors: string[] = [];
194
+ const seen = new Set<string>();
195
+ for (const m of body.matchAll(FILE_LINE_CITATION_RE)) {
196
+ const path = m[1];
197
+ const startStr = m[2];
198
+ const endStr = m[3];
199
+ if (path === undefined || startStr === undefined) continue;
200
+ const key = `${path}:${startStr}${endStr !== undefined ? `-${endStr}` : ""}`;
201
+ if (seen.has(key)) continue;
202
+ seen.add(key);
203
+ const abs = isAbsolute(path) ? path : join(cwd, path);
204
+ if (!existsSync(abs) || !statSync(abs).isFile()) {
205
+ errors.push(`unbacked citation ${key} — file does not exist (use a repo-root-relative path or drop the line numbers)`);
206
+ continue;
207
+ }
208
+ let lineCount: number;
209
+ try {
210
+ lineCount = readFileSync(abs, "utf-8").split("\n").length;
211
+ } catch {
212
+ errors.push(`unbacked citation ${key} — file could not be read`);
213
+ continue;
214
+ }
215
+ const high = Math.max(Number(startStr), endStr !== undefined ? Number(endStr) : 0);
216
+ if (high > lineCount) {
217
+ errors.push(`unbacked citation ${key} — file has ${lineCount} lines; correct the range or drop the line numbers`);
218
+ }
219
+ }
220
+ return errors.length === 0
221
+ ? { ok: true, value: undefined }
222
+ : { ok: false, error: errors.slice(0, 10).join("\n") };
223
+ }
224
+
225
+ /**
226
+ * Validation-contract floor: the validate artifact must carry the numeric gate
227
+ * field (`blockers_count`) so routing is measured, never inferred from prose.
228
+ */
229
+ export function validationRecord(content: string, path: string): GateResult<ValidationGateData> {
230
+ const fm = frontmatterOf(content);
231
+ const blockers = fm.blockers_count;
232
+ const verdict = fm.verdict;
233
+ if (typeof blockers !== "number" || !Number.isInteger(blockers) || blockers < 0) {
234
+ return { ok: false, error: `validation ${path}: frontmatter blockers_count must be an integer ≥ 0 (got ${String(blockers)})` };
235
+ }
236
+ if (verdict !== "pass" && verdict !== "fail") {
237
+ return { ok: false, error: `validation ${path}: frontmatter verdict must be 'pass' or 'fail' (got ${String(verdict)})` };
238
+ }
239
+ if (verdict === "pass" && blockers > 0) {
240
+ return { ok: false, error: `validation ${path}: verdict 'pass' contradicts blockers_count ${blockers}` };
241
+ }
242
+ return { ok: true, value: { blockersCount: blockers, verdict } };
243
+ }
244
+
245
+ /**
246
+ * Clarification-contract floor: interview outcome document.
247
+ * `decisions_count` / `open_questions_count` must match fence-aware bullet counts;
248
+ * `## Problem & Intent` must be present and non-empty (user's words).
249
+ */
250
+ export function clarificationRecord(content: string, path: string): GateResult<ClarificationGateData> {
251
+ const fm = frontmatterOf(content);
252
+ const decisions = fm.decisions_count;
253
+ const openQs = fm.open_questions_count;
254
+ if (typeof decisions !== "number" || !Number.isInteger(decisions) || decisions < 0) {
255
+ return { ok: false, error: `clarification ${path}: frontmatter decisions_count must be an integer ≥ 0 (got ${String(decisions)})` };
256
+ }
257
+ if (typeof openQs !== "number" || !Number.isInteger(openQs) || openQs < 0) {
258
+ return { ok: false, error: `clarification ${path}: frontmatter open_questions_count must be an integer ≥ 0 (got ${String(openQs)})` };
259
+ }
260
+ if (!sectionHasNonEmptyBody(content, "Problem & Intent")) {
261
+ return { ok: false, error: `clarification ${path}: '## Problem & Intent' section is missing or empty — record the user's words verbatim` };
262
+ }
263
+ const decisionBullets = countBulletsUnderHeading(content, "Decisions");
264
+ if (decisions !== decisionBullets) {
265
+ return { ok: false, error: `clarification ${path}: decisions_count (${decisions}) ≠ '- ' bullets under '## Decisions' (${decisionBullets}) — rebuild the count from the body` };
266
+ }
267
+ const openBullets = countBulletsUnderHeading(content, "Open Questions");
268
+ if (openQs !== openBullets) {
269
+ return { ok: false, error: `clarification ${path}: open_questions_count (${openQs}) ≠ '- ' bullets under '## Open Questions' (${openBullets}) — rebuild the count from the body` };
270
+ }
271
+ return { ok: true, value: { decisionsCount: decisions, openQuestionsCount: openQs } };
272
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
8
8
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
9
- import { type ChatMsg, modelComplete } from "../bridge/model.ts";
9
+ import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete } from "../bridge/model.ts";
10
10
  import type { ReplResult } from "../sandbox/protocol.ts";
11
11
  import type { PythonSandbox } from "../sandbox/sandbox.ts";
12
12
  import { findReplBlocks } from "../text/parsing.ts";
@@ -21,15 +21,20 @@ export interface Turn {
21
21
  readonly skippedBlocks: number;
22
22
  }
23
23
 
24
+ export type CompleteFn = (messages: readonly ChatMsg[], opts: CompleteOptions) => Promise<CompleteResult>;
25
+
24
26
  export interface TurnDeps {
25
27
  readonly model: Model<Api>;
26
28
  readonly registry: ModelRegistry;
27
29
  readonly sampling?: Sampling;
28
30
  readonly signal?: AbortSignal;
31
+ /** Test-only override for model completion (scripted responses). */
32
+ readonly complete?: CompleteFn;
29
33
  }
30
34
 
31
35
  export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
32
- const { text, usage } = await modelComplete(history, {
36
+ const complete = deps.complete ?? modelComplete;
37
+ const { text, usage } = await complete(history, {
33
38
  model: deps.model,
34
39
  registry: deps.registry,
35
40
  maxTokens: deps.sampling?.maxTokens,
@@ -1,25 +1,165 @@
1
1
  /**
2
- * RLM pipeline phase state machine.
2
+ * RLM pipeline stage graph — data-driven transitions with deterministic gates.
3
3
  *
4
- * Hard-gated phases: research → blueprint → implement → validate.
5
- * The root RLM calls advance_phase() to move forward; the engine enforces
6
- * forward-only progression, persists phase rows, and re-prompts when a
7
- * phase stalls for too many turns.
4
+ * Stages: clarify → research → blueprint → implement → validate
5
+ * (clarify skipped when askUserQuestion is off).
6
+ * The root RLM writes artifacts via save_artifact(); advance_phase() is gated by
7
+ * TypeScript floors (never LLM judgment). Validate routes on measured
8
+ * blockers_count with a bounded corrective loop back to blueprint.
8
9
  */
10
+ import {
11
+ checkStatusReady,
12
+ clarificationRecord,
13
+ type ClarificationGateData,
14
+ type GateResult,
15
+ planPhaseRecords,
16
+ type PlanGateData,
17
+ type ValidationGateData,
18
+ validationRecord,
19
+ verifyCitations,
20
+ } from "./gates.ts";
9
21
 
10
- export type Phase = "research" | "blueprint" | "implement" | "validate";
22
+ export type Phase = "clarify" | "research" | "blueprint" | "implement" | "validate";
11
23
 
12
24
  export const PHASES = Object.freeze([
25
+ "clarify",
13
26
  "research",
14
27
  "blueprint",
15
28
  "implement",
16
29
  "validate",
17
30
  ] as const satisfies readonly Phase[]);
18
31
 
32
+ /** Kind string the model passes to save_artifact(kind, content). */
33
+ export type ArtifactKind = "clarification" | "research" | "plan" | "validation";
34
+
35
+ /** Structured data a stage gate extracts from its artifact (what edges route on). */
36
+ export type StageGateData =
37
+ | { readonly kind: "clarification"; readonly clarification: ClarificationGateData }
38
+ | { readonly kind: "research" }
39
+ | { readonly kind: "plan"; readonly plan: PlanGateData }
40
+ | { readonly kind: "validation"; readonly validation: ValidationGateData }
41
+ | { readonly kind: "side-effect" };
42
+
43
+ export interface StageDef {
44
+ readonly phase: Phase;
45
+ /** Subdir under .rlm/artifacts/ ("" = side-effect stage, no artifact). */
46
+ readonly artifactDir: string;
47
+ /** save_artifact kind, or "" when the stage produces no artifact. */
48
+ readonly artifactKind: ArtifactKind | "";
49
+ /** Deterministic floor run on the artifact BEFORE leaving this stage. */
50
+ readonly gate: (content: string, path: string, cwd: string) => GateResult<StageGateData>;
51
+ }
52
+
53
+ const clarifyGate: StageDef["gate"] = (content, path) => {
54
+ const status = checkStatusReady(content, path);
55
+ if (!status.ok) return status;
56
+ const rec = clarificationRecord(content, path);
57
+ if (!rec.ok) return rec;
58
+ return { ok: true, value: { kind: "clarification", clarification: rec.value } };
59
+ };
60
+
61
+ /** Compose floors: status: ready → citations → stage-specific contract. */
62
+ const researchGate: StageDef["gate"] = (content, path, cwd) => {
63
+ const status = checkStatusReady(content, path);
64
+ if (!status.ok) return status;
65
+ const cites = verifyCitations(content, cwd);
66
+ if (!cites.ok) return cites;
67
+ return { ok: true, value: { kind: "research" } };
68
+ };
69
+
70
+ const blueprintGate: StageDef["gate"] = (content, path, cwd) => {
71
+ const status = checkStatusReady(content, path);
72
+ if (!status.ok) return status;
73
+ const cites = verifyCitations(content, cwd);
74
+ if (!cites.ok) return cites;
75
+ const plan = planPhaseRecords(content, path);
76
+ if (!plan.ok) return plan;
77
+ return { ok: true, value: { kind: "plan", plan: plan.value } };
78
+ };
79
+
80
+ const validateGate: StageDef["gate"] = (content, path) => {
81
+ const status = checkStatusReady(content, path);
82
+ if (!status.ok) return status;
83
+ const rec = validationRecord(content, path);
84
+ if (!rec.ok) return rec;
85
+ return { ok: true, value: { kind: "validation", validation: rec.value } };
86
+ };
87
+
88
+ /** Single source of truth for gates, artifact dirs/kinds, and routing. */
89
+ export const STAGES: Readonly<Record<Phase, StageDef>> = Object.freeze({
90
+ clarify: { phase: "clarify", artifactDir: "clarifications", artifactKind: "clarification", gate: clarifyGate },
91
+ research: { phase: "research", artifactDir: "research", artifactKind: "research", gate: researchGate },
92
+ 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
+ validate: { phase: "validate", artifactDir: "validations", artifactKind: "validation", gate: validateGate },
101
+ });
102
+
103
+ /** Lookup stage by save_artifact kind — single map derived from STAGES (DRY). */
104
+ const STAGE_BY_KIND: Readonly<Partial<Record<ArtifactKind, StageDef>>> = Object.freeze(
105
+ (Object.values(STAGES) as readonly StageDef[]).reduce<Partial<Record<ArtifactKind, StageDef>>>((acc, stage) => {
106
+ if (stage.artifactKind !== "") acc[stage.artifactKind] = stage;
107
+ return acc;
108
+ }, {}),
109
+ );
110
+
111
+ export function stageForArtifactKind(kind: string): StageDef | undefined {
112
+ if (kind === "clarification" || kind === "research" || kind === "plan" || kind === "validation") {
113
+ return STAGE_BY_KIND[kind];
114
+ }
115
+ return undefined;
116
+ }
117
+
19
118
  export interface PhaseState {
20
119
  readonly current: Phase;
21
- readonly advancedAt: number; // turn number when this phase was entered (0-based)
120
+ readonly advancedAt: number;
22
121
  readonly summary?: string;
122
+ /** Artifact each completed stage produced — the named channels. */
123
+ readonly artifacts: Readonly<Partial<Record<Phase, string>>>;
124
+ /** Corrective validate→blueprint re-entries taken so far. */
125
+ readonly backwardJumps: number;
126
+ }
127
+
128
+ /**
129
+ * @param start — first phase of the run (`clarify` when interviews are on, else `research`).
130
+ */
131
+ export function initialPhaseState(advancedAt = 0, start: Phase = "clarify"): PhaseState {
132
+ return { current: start, advancedAt, artifacts: Object.freeze({}), backwardJumps: 0 };
133
+ }
134
+
135
+ export type RouteDecision =
136
+ | { readonly kind: "done" }
137
+ | { readonly kind: "loop-back"; readonly next: "blueprint" }
138
+ | { readonly kind: "halt"; readonly reason: string };
139
+
140
+ /**
141
+ * Route out of `validate` on MEASURED gate data: blockers_count === 0 → done;
142
+ * blockers_count > 0 → loop back to blueprint, bounded by maxBackwardJumps.
143
+ */
144
+ export function routeAfterValidate(
145
+ data: ValidationGateData,
146
+ backwardJumps: number,
147
+ maxBackwardJumps: number,
148
+ ): RouteDecision {
149
+ if (data.blockersCount === 0) return { kind: "done" };
150
+ if (backwardJumps >= maxBackwardJumps) {
151
+ return {
152
+ kind: "halt",
153
+ reason: `validation reports ${data.blockersCount} blocker(s) after ${backwardJumps} corrective pass(es) — backward-jump limit (${maxBackwardJumps}) reached; surfacing the validation report as the final answer`,
154
+ };
155
+ }
156
+ return { kind: "loop-back", next: "blueprint" };
157
+ }
158
+
159
+ /** Forward transitions only; corrective loop-back is ENGINE-initiated via routeAfterValidate. */
160
+ export function nextForward(current: Phase): Phase | undefined {
161
+ const idx = PHASES.indexOf(current);
162
+ return idx >= 0 && idx < PHASES.length - 1 ? PHASES[idx + 1] : undefined;
23
163
  }
24
164
 
25
165
  export interface AdvancePhaseResult {
@@ -35,10 +175,10 @@ export interface AdvancePhaseFailure {
35
175
 
36
176
  export type AdvancePhaseOutcome = AdvancePhaseResult | AdvancePhaseFailure;
37
177
 
38
- /** PHASE_GATE_TURNS: if the model stays in one phase for this many turns, the engine re-prompts. */
39
- export const PHASE_GATE_TURNS = 4;
40
-
41
- /** Validate a phase transition. Only forward progression is allowed. */
178
+ /**
179
+ * Pure order check: only the immediate next phase is allowed.
180
+ * Artifact gates are applied by the engine after this returns ok.
181
+ */
42
182
  export function advancePhase(
43
183
  current: Phase | undefined,
44
184
  target: string,
@@ -47,25 +187,31 @@ export function advancePhase(
47
187
  return {
48
188
  ok: false,
49
189
  error: `unknown phase '${target}'; valid phases: ${PHASES.join(", ")}`,
50
- phase: current ?? "research",
190
+ phase: current ?? PHASES[0],
51
191
  };
52
192
  }
53
- const from = current ?? "research";
54
- const currentIdx = PHASES.indexOf(from);
55
- const targetIdx = PHASES.indexOf(target as Phase);
56
- if (targetIdx <= currentIdx) {
193
+ const from = current ?? PHASES[0];
194
+ const expected = nextForward(from);
195
+ if (expected === undefined) {
57
196
  return {
58
197
  ok: false,
59
- error: `cannot move backward from '${from}' to '${target}'`,
198
+ error: `'${from}' is the terminal phase; finalize via answer["ready"] = True after saving the validation artifact`,
199
+ phase: from,
200
+ };
201
+ }
202
+ if (target !== expected) {
203
+ return {
204
+ ok: false,
205
+ error: `cannot advance from '${from}' to '${target}' — the next phase is '${expected}'`,
60
206
  phase: from,
61
207
  };
62
208
  }
63
209
  return { ok: true, phase: target as Phase };
64
210
  }
65
211
 
66
- /** Return the current phase (defaults to "research" if undefined). */
212
+ /** Return the current phase (defaults to first phase if undefined). */
67
213
  export function currentPhase(state: PhaseState | undefined): Phase {
68
- return state?.current ?? "research";
214
+ return state?.current ?? PHASES[0];
69
215
  }
70
216
 
71
217
  /** Return the number of turns spent in the current phase. */
@@ -73,6 +219,9 @@ export function turnsInPhase(state: PhaseState | undefined, completedTurns: numb
73
219
  return state ? completedTurns - state.advancedAt : completedTurns;
74
220
  }
75
221
 
222
+ /** PHASE_GATE_TURNS: if the model stays in one phase for this many turns, the engine re-prompts. */
223
+ export const PHASE_GATE_TURNS = 4;
224
+
76
225
  /** Produce a re-prompt message when the model stalls in a phase for too long. */
77
226
  export function phaseGatePrompt(
78
227
  state: PhaseState | undefined,
@@ -81,9 +230,9 @@ export function phaseGatePrompt(
81
230
  const turns = turnsInPhase(state, completedTurns);
82
231
  const phase = currentPhase(state);
83
232
  if (turns >= PHASE_GATE_TURNS && turns % PHASE_GATE_TURNS === 0) {
84
- const next = nextPhase(phase);
233
+ const next = nextForward(phase);
85
234
  const hint = next
86
- ? ` Consider calling advance_phase("${next}") if your ${phase} work is complete.`
235
+ ? ` Consider calling advance_phase("${next}") if your ${phase} work is complete (after save_artifact when required).`
87
236
  : "";
88
237
  return [
89
238
  `You have spent ${turns} turns in the '${phase}' phase.`,
@@ -92,9 +241,3 @@ export function phaseGatePrompt(
92
241
  }
93
242
  return undefined;
94
243
  }
95
-
96
- /** Return the next phase, or undefined if at the terminal phase. */
97
- export function nextPhase(current: Phase): Phase | undefined {
98
- const idx = PHASES.indexOf(current);
99
- return idx >= 0 && idx < PHASES.length - 1 ? PHASES[idx + 1] : undefined;
100
- }
package/src/core/types.ts CHANGED
@@ -50,6 +50,8 @@ export interface RlmConfig {
50
50
  orchestrator: boolean;
51
51
  /** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
52
52
  pipeline: boolean;
53
+ /** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
54
+ maxBackwardJumps: number;
53
55
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
54
56
  compaction: boolean;
55
57
  /** Compact when estimated history tokens reach this fraction of the model's context window. */
@@ -62,6 +64,8 @@ export interface RlmConfig {
62
64
  askUserQuestion: boolean;
63
65
  /** Allow todo() calls from the REPL. */
64
66
  todo: boolean;
67
+ /** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
68
+ libraryLoader: boolean;
65
69
  /** ThinkingLevel for the root smart model (set via /rlm-config). */
66
70
  smartReasoning?: ThinkingLevel;
67
71
  /** Output token cap + temperature for the root smart model per turn.
package/src/index.ts CHANGED
@@ -27,13 +27,17 @@ export default function rlmExtension(pi: ExtensionAPI): void {
27
27
  const config = mergeConfig({});
28
28
  const controller = new RlmController(config);
29
29
  const editRegistry = new EditRegistry();
30
+ let onSandboxDiscardExtra: (() => void) | undefined;
30
31
  const sandboxManager = new SandboxManager({
31
32
  execTimeoutS: config.execTimeoutS,
32
33
  requestTimeoutMs: config.requestTimeoutMs,
33
34
  python: config.python,
34
35
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
35
36
  maxPromptChars: config.maxPromptChars,
36
- onSandboxDiscarded: () => { editRegistry.clear(); },
37
+ onSandboxDiscarded: () => {
38
+ editRegistry.clear();
39
+ onSandboxDiscardExtra?.();
40
+ },
37
41
  });
38
42
  let packedContextText: string | undefined;
39
43
  let contextPackPromise: Promise<string | undefined> | undefined;
@@ -107,6 +111,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
107
111
  registry: ctx.modelRegistry,
108
112
  editRegistry,
109
113
  config: controller.config,
114
+ registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
110
115
  ensureContext: async () => {
111
116
  const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
112
117
  if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");