@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.
- package/README.md +35 -1
- package/package.json +1 -1
- package/src/bridge/library.ts +77 -0
- package/src/bridge/rlm-query.ts +58 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +79 -0
- package/src/core/artifacts.ts +88 -0
- package/src/core/engine.ts +432 -35
- package/src/core/gates.ts +272 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +170 -27
- package/src/core/types.ts +4 -0
- package/src/index.ts +6 -1
- package/src/prompts/phases.ts +125 -0
- package/src/prompts/system.ts +38 -7
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +25 -2
- package/src/sandbox/sandbox.ts +42 -1
- package/src/sandbox/worker.py +42 -5
- package/src/state/index.ts +2 -1
- package/src/state/paths.ts +4 -2
- package/src/state/reads.ts +31 -2
- package/src/state/resume.ts +19 -1
- package/src/state/rows.ts +6 -0
- package/src/state/writes.ts +5 -3
- package/src/text/edits.ts +148 -0
- package/src/tool/apply-edits-tool.ts +36 -29
- package/src/tool/repl-tool.ts +23 -2
- package/src/tool/rlm-tool.ts +0 -1
- package/src/ui/config-panel.ts +8 -1
|
@@ -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
|
+
}
|
package/src/core/iteration.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/pipeline.ts
CHANGED
|
@@ -1,25 +1,165 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RLM pipeline
|
|
2
|
+
* RLM pipeline stage graph — data-driven transitions with deterministic gates.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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;
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 ??
|
|
190
|
+
phase: current ?? PHASES[0],
|
|
51
191
|
};
|
|
52
192
|
}
|
|
53
|
-
const from = current ??
|
|
54
|
-
const
|
|
55
|
-
|
|
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: `
|
|
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
|
|
212
|
+
/** Return the current phase (defaults to first phase if undefined). */
|
|
67
213
|
export function currentPhase(state: PhaseState | undefined): Phase {
|
|
68
|
-
return state?.current ??
|
|
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 =
|
|
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: () => {
|
|
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");
|