@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,125 @@
1
+ /**
2
+ * Per-phase guidance for the gated RLM pipeline.
3
+ * Injected at each phase boundary; the only channel between phases is durable
4
+ * artifacts under .rlm/artifacts/.
5
+ */
6
+ import type { PhaseRecord } from "../core/gates.ts";
7
+ import type { Phase } from "../core/pipeline.ts";
8
+
9
+ const CLARIFY_GUIDANCE = Object.freeze([
10
+ "## Clarify phase (intake interview)",
11
+ "Interview the user until the task is understood. The engine COUNTS ask_user_question",
12
+ "rounds it services — you cannot advance without having actually asked at least once.",
13
+ "",
14
+ "1. Intent first: ask ONE open-ended intent question (what problem, who hits it, what",
15
+ " success looks like). Options are answer *shapes* (e.g. end user / maintainer / operator);",
16
+ " do NOT mark a Recommended option. The automatic Other / free-text path carries the real framing.",
17
+ " Up to 2 more intent rounds if the answer still cannot scope a narrow probe; then proceed",
18
+ " with what you have.",
19
+ "2. Probe before asking more: ground follow-ups in the REPL (deterministic search over",
20
+ " context, a few llm_query reads) so questions cite real code as file:line.",
21
+ "3. Confirm inferences, don't record them: batch inferred decisions into one call —",
22
+ " \"From the code I inferred <behavior> (file:line). Keep or change?\" The user's answer",
23
+ " is the Decision, not your inference.",
24
+ "4. Scope / shape / detail rounds, one at a time: recommended option first; architecture",
25
+ " (shape) options must state the tradeoff (\"optimizes X, costs Y\") — never one option",
26
+ " masquerading as a choice; 2–4 independent detail questions may batch in one call.",
27
+ "5. Classify answers: Decision → record; Correction → re-probe that seam, re-ask dependents;",
28
+ " Defer → ## Open Questions.",
29
+ "6. Terminate on depth, not politeness: stop when every raised branch has a Decision or a",
30
+ " Deferral and ## Problem & Intent holds the user's own words verbatim. Do not pad the",
31
+ " interview; do not ask a final \"looks good?\" rubber-stamp.",
32
+ "7. Then save_artifact(\"clarification\", content) with frontmatter:",
33
+ " status: ready",
34
+ " decisions_count: N",
35
+ " open_questions_count: M",
36
+ " and body sections:",
37
+ " ## Problem & Intent (user's words VERBATIM)",
38
+ " ## Decisions (one '- ' bullet per decision)",
39
+ " ## Open Questions (one '- ' bullet per deferral; may be empty with count 0)",
40
+ " ## Non-Goals",
41
+ ' Then advance_phase("research", summary).',
42
+ ].join("\n"));
43
+
44
+ const RESEARCH_GUIDANCE = Object.freeze([
45
+ "## Research phase",
46
+ "Read the clarifications artifact (if present) and honor recorded Decisions; do not silently",
47
+ "resolve Open Questions.",
48
+ "Probe the repository, delegate long reads via `llm_query_batched` / `llm_query_chunked`.",
49
+ "Every factual claim about the code MUST be cited as `path/file.ext:LINE` (or `LINE-LINE`).",
50
+ "The engine VERIFIES citations against the working tree — unbacked citations reject advance.",
51
+ "When research is complete, write ONE research document and call:",
52
+ ' `save_artifact("research", content)` # frontmatter must include `status: ready`',
53
+ ' `advance_phase("blueprint", summary)`',
54
+ "Do not implement code in this phase.",
55
+ ].join("\n"));
56
+
57
+ const BLUEPRINT_GUIDANCE = Object.freeze([
58
+ "## Blueprint phase",
59
+ "Read the clarifications artifact (if present) and plan only within recorded Decisions;",
60
+ "Open Questions that would block the design must be re-asked via ask_user_question, not assumed.",
61
+ 'Produce ONE plan document and save it with `save_artifact("plan", content)`.',
62
+ "The ENGINE derive-checks the document — these are hard gates, not suggestions:",
63
+ "- frontmatter: `status: ready`, `phase_count: N`, `phases: [{n: 1, title: ...}, ...]`",
64
+ "- `phases:` / `phase_count` MUST match the `## Phase N:` body headings exactly (fenced examples ignored)",
65
+ "- every `file:line` citation MUST resolve against the working tree at this revision",
66
+ "Each `## Phase N: <title>` section contains:",
67
+ "- `### Changes Required` — per file: path + exact intended change (code from research)",
68
+ "- `### Success Criteria` — `#### Automated Verification:` (runnable commands) and",
69
+ " `#### Manual Verification:` checklists",
70
+ "Phases are executed by ISOLATED workers, one at a time, in order: each phase must be",
71
+ "independently implementable, leave the tree working, and never share a file with a",
72
+ "later phase unless that phase only EDITS what an earlier phase CREATED.",
73
+ 'When ready: `advance_phase("implement", summary)` — the engine runs implement fanout itself.',
74
+ ].join("\n"));
75
+
76
+ const IMPLEMENT_GUIDANCE = Object.freeze([
77
+ "## Implement phase",
78
+ "The engine drives serial child-RLM workers over each plan phase — you do not implement",
79
+ "here. If you were re-entered into implement unexpectedly, call",
80
+ '`advance_phase("validate")` only after the fanout has already completed.',
81
+ ].join("\n"));
82
+
83
+ const VALIDATE_GUIDANCE = Object.freeze([
84
+ "## Validate phase",
85
+ "Read the goal artifact (verbatim brief), the clarifications artifact (recorded Decisions),",
86
+ "and the plan. Check each phase's Success Criteria against the working tree.",
87
+ "Exclude paths listed in the baseline JSON (pre-existing dirt).",
88
+ "Open Questions are not silently resolved — a blocking one surfaces via ask_user_question.",
89
+ "Write ONE validation document via `save_artifact(\"validation\", content)` with frontmatter:",
90
+ "- `status: ready`",
91
+ "- `blockers_count: <int ≥ 0>` # MEASURED — routing key; not prose",
92
+ "- `verdict: pass | fail` # pass requires blockers_count === 0",
93
+ "Each blocker needs a resolvable `file:line` citation.",
94
+ "Then finalize: `answer[\"content\"] = <report>; answer[\"ready\"] = True`.",
95
+ "If `blockers_count > 0`, the engine re-enters blueprint (bounded by maxBackwardJumps).",
96
+ ].join("\n"));
97
+
98
+ const GUIDANCE: Readonly<Record<Phase, string>> = Object.freeze({
99
+ clarify: CLARIFY_GUIDANCE,
100
+ research: RESEARCH_GUIDANCE,
101
+ blueprint: BLUEPRINT_GUIDANCE,
102
+ implement: IMPLEMENT_GUIDANCE,
103
+ validate: VALIDATE_GUIDANCE,
104
+ });
105
+
106
+ export function phaseGuidance(phase: Phase): string {
107
+ return GUIDANCE[phase];
108
+ }
109
+
110
+ /** Single-phase implement prompt for an isolated child RLM (serial fanout unit). */
111
+ export function buildImplementPhasePrompt(planPath: string, r: PhaseRecord): string {
112
+ return [
113
+ `Implement ONLY Phase ${r.n} (${r.title}) of the plan at ${planPath} (${r.index + 1}/${r.total}).`,
114
+ `Read the plan from the REPL (open("${planPath}").read()).`,
115
+ "Hard rules (you are one unit of a sequenced run):",
116
+ "- CRITICAL: `context` is a STALE snapshot from run start. Always `open(path).read()` any file",
117
+ " you will edit BEFORE computing stage_edit anchors — earlier phases may have already changed them.",
118
+ "- Touch ONLY the files this phase's ### Changes Required lists.",
119
+ "- Earlier phases have already landed; a missing prerequisite file is a HARD ERROR —",
120
+ " finalize with 'Error: prerequisite missing: <path>' instead of creating it.",
121
+ "- Stage every change via stage_edit(path, old_text, new_text); never defer your own edits.",
122
+ "- Run only THIS phase's '#### Automated Verification:' commands; whole-plan checks are validate's job.",
123
+ `Finalize with a short summary of what landed for Phase ${r.n}.`,
124
+ ].join("\n");
125
+ }
@@ -20,6 +20,7 @@ export interface SystemPromptOptions {
20
20
  readonly todo?: boolean;
21
21
  readonly pipeline?: boolean;
22
22
  readonly maxPromptChars?: number;
23
+ readonly libraryLoader?: boolean;
23
24
  }
24
25
 
25
26
  export type ContextKind = "files" | "text";
@@ -76,7 +77,14 @@ function howToRunCode(): string {
76
77
  ].join(" ");
77
78
  }
78
79
 
79
- function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: boolean, todo: boolean, pipeline: boolean): string {
80
+ function replGlossary(
81
+ kind: ContextKind,
82
+ recursion: boolean,
83
+ askUserQuestion: boolean,
84
+ todo: boolean,
85
+ pipeline: boolean,
86
+ libraryLoader: boolean,
87
+ ): string {
80
88
  const lines = ["Available in the REPL:"];
81
89
  if (kind === "text") {
82
90
  lines.push(
@@ -116,8 +124,9 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
116
124
  "- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
117
125
  " with 1-4 structured questions. Each question: {question, header, options: [{label, description}],",
118
126
  " multiSelect?}. Returns list of {question, selected: [label], custom?}.",
119
- " Use when you have 2-4 concrete options from your analysis and need a decision before proceeding.",
120
- " DO NOT ask open-ended chat questions use concrete options grounded in code/data.",
127
+ " Default: use concrete options grounded in code/data (2–4 choices, Recommended first when ranking).",
128
+ " Exception clarify-phase intent rounds: lead with an open-ended intent question whose options",
129
+ " are answer *shapes* (not a Recommended pick); free-text / Other carries the real framing.",
121
130
  " Only valid at root depth; returns an error inside rlm_query sub-calls.",
122
131
  );
123
132
  }
@@ -130,6 +139,17 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
130
139
  " Use to plan multi-step work before starting, then mark tasks as you complete them.",
131
140
  );
132
141
  }
142
+ if (libraryLoader) {
143
+ lines.push(
144
+ "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document into a",
145
+ " NEW `context_N` REPL variable. `source` may be a local directory (repomix-packed to the same",
146
+ " list[dict] shape as `context`), a single file path (loaded as a plain str), or an https/git@ URL",
147
+ " (shallow-cloned, then packed). Returns {\"index\": N, \"var\": \"context_N\", \"files\", \"chars\"}",
148
+ " on success or an \"Error: ...\" string. Use it when the task requires learning an external lib's",
149
+ " API, structure, or docs that are not in `context`; then chunk `context_N` to sub-LLMs exactly like",
150
+ " `context`. Do not re-load a source that is already in a slot.",
151
+ );
152
+ }
133
153
  if (recursion) {
134
154
  lines.push(
135
155
  "- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
@@ -148,9 +168,16 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
148
168
  }
149
169
  if (pipeline) {
150
170
  lines.push(
151
- "- `advance_phase(phase: str, summary=None) -> str`: transition the root RLM pipeline to the next phase.",
152
- " Valid phases in order: 'research' 'blueprint' 'implement' 'validate'. You must advance forward",
153
- " one phase at a time. Only callable at the root depth; returns an error in sub-RLM contexts.",
171
+ "- `save_artifact(kind: str, content: str) -> str`: persist a stage artifact under `.rlm/artifacts/`.",
172
+ " Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
173
+ " Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
174
+ "- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
175
+ " Order: 'clarify' → 'research' → 'blueprint' → 'implement' → 'validate' (one step at a time;",
176
+ " clarify is skipped when ask_user_question is disabled).",
177
+ " **advance_phase is validated by the engine** — it measures the latest saved artifact",
178
+ " (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
179
+ " A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
180
+ " Only callable at root depth.",
154
181
  );
155
182
  }
156
183
  lines.push(
@@ -197,7 +224,10 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
197
224
  "",
198
225
  howToRunCode(),
199
226
  "",
200
- replGlossary(kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false, opts.pipeline ?? false),
227
+ replGlossary(
228
+ kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false,
229
+ opts.pipeline ?? false, opts.libraryLoader ?? false,
230
+ ),
201
231
  "",
202
232
  "REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
203
233
  "REPL variables as buffers. Re-print only the slice you need (e.g. `print(result[:500])`); never",
@@ -232,6 +262,7 @@ function nativeReplGlossary(): string {
232
262
  "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
233
263
  "",
234
264
  "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
265
+ "- `load_library(source)` → `context_N` (external).",
235
266
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
236
267
  "- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
237
268
  "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
@@ -3,16 +3,24 @@
3
3
  * Native mode does not use these — pi's own loop supplies the turns.
4
4
  */
5
5
 
6
- export function buildTurnPrompt(iteration: number, maxIterations: number, gateMessage?: string): string {
6
+ export function buildTurnPrompt(
7
+ iteration: number,
8
+ maxIterations: number,
9
+ gateMessage?: string,
10
+ phaseGuidanceText?: string,
11
+ ): string {
12
+ const parts: string[] = [];
13
+ if (phaseGuidanceText) parts.push(phaseGuidanceText);
14
+ if (gateMessage) parts.push(gateMessage);
15
+ const prefix = parts.length > 0 ? `${parts.join("\n\n")}\n\n` : "";
7
16
  const body = `Turn ${iteration + 1}/${maxIterations}:`;
8
- const gate = gateMessage ? `\n${gateMessage}\n\n` : "";
9
17
  if (iteration === 0) {
10
18
  return (
11
19
  "You have not interacted with the REPL or seen your context yet. Look at the context first; " +
12
- `do not provide a final answer yet.\n\n${gate}${body}`
20
+ `do not provide a final answer yet.\n\n${prefix}${body}`
13
21
  );
14
22
  }
15
- return `${gate}${body}`;
23
+ return `${prefix}${body}`;
16
24
  }
17
25
 
18
26
  /** Asked once when the engine runs out of turns without a submitted answer. */
@@ -21,6 +21,12 @@ export interface LlmReply {
21
21
  readonly response?: string;
22
22
  readonly responses?: readonly string[];
23
23
  readonly answers?: readonly AskAnswer[];
24
+ /** load_library reply: temp file with the packed payload + assigned slot. */
25
+ readonly path?: string;
26
+ readonly json?: boolean;
27
+ readonly index?: number;
28
+ readonly files?: number;
29
+ readonly chars?: number;
24
30
  readonly error?: string;
25
31
  }
26
32
 
@@ -62,8 +68,10 @@ export type InterruptKind =
62
68
  | "rlm_query"
63
69
  | "rlm_query_batched"
64
70
  | "advance_phase"
71
+ | "save_artifact"
65
72
  | "ask_user_question"
66
- | "todo";
73
+ | "todo"
74
+ | "load_library";
67
75
 
68
76
  export interface AskOption {
69
77
  readonly label: string;
@@ -111,6 +119,12 @@ interface AdvancePhaseInterrupt extends InterruptBase {
111
119
  readonly summary?: string;
112
120
  }
113
121
 
122
+ interface SaveArtifactInterrupt extends InterruptBase {
123
+ readonly type: "save_artifact";
124
+ readonly artifactKind?: string;
125
+ readonly content?: string;
126
+ }
127
+
114
128
  export interface AskUserQuestionInterrupt extends InterruptBase {
115
129
  readonly type: "ask_user_question";
116
130
  readonly questions: readonly AskQuestion[];
@@ -132,13 +146,20 @@ export interface TodoInterrupt extends InterruptBase {
132
146
  readonly includeDeleted?: boolean;
133
147
  }
134
148
 
149
+ export interface LoadLibraryInterrupt extends InterruptBase {
150
+ readonly type: "load_library";
151
+ readonly source?: string;
152
+ }
153
+
135
154
  /** A mid-exec sub-LLM/tool request from the worker. */
136
155
  export type WorkerInterrupt =
137
156
  | PromptInterrupt
138
157
  | BatchedPromptInterrupt
139
158
  | AdvancePhaseInterrupt
159
+ | SaveArtifactInterrupt
140
160
  | AskUserQuestionInterrupt
141
- | TodoInterrupt;
161
+ | TodoInterrupt
162
+ | LoadLibraryInterrupt;
142
163
 
143
164
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
144
165
 
@@ -148,8 +169,10 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
148
169
  "rlm_query",
149
170
  "rlm_query_batched",
150
171
  "advance_phase",
172
+ "save_artifact",
151
173
  "ask_user_question",
152
174
  "todo",
175
+ "load_library",
153
176
  ]));
154
177
 
155
178
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -26,6 +26,14 @@ import {
26
26
  } from "./protocol.ts";
27
27
  import { formatError } from "../util/errors.ts";
28
28
 
29
+ /** Result of a host-side library pack requested by `load_library`. */
30
+ export interface LibraryLoadResult {
31
+ readonly payload: unknown; // string (single file) or ContextFile[] (packed dir/repo)
32
+ readonly index: number; // slot assigned by the host
33
+ readonly files?: number;
34
+ readonly chars: number;
35
+ }
36
+
29
37
  /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
30
38
  export interface SubLlmHandlers {
31
39
  llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
@@ -33,8 +41,10 @@ export interface SubLlmHandlers {
33
41
  rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
34
42
  rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
35
43
  advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
44
+ saveArtifact(kind: string, content: string, depth: number): Promise<string>;
36
45
  askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
37
46
  todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
47
+ loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
38
48
  }
39
49
 
40
50
  export interface SandboxOptions {
@@ -76,12 +86,14 @@ const REJECT: SubLlmHandlers = {
76
86
  rlmQuery: async () => formatError("sub-LLM bridge not configured"),
77
87
  rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
78
88
  advancePhase: async () => formatError("phase advancement not available"),
89
+ saveArtifact: async () => formatError("save_artifact not available"),
79
90
  askUserQuestion: async (questions) => questions.map((q) => ({
80
91
  question: q.question,
81
92
  selected: [],
82
93
  custom: formatError("ask_user_question not configured"),
83
94
  })),
84
95
  todo: async () => formatError("todo not configured"),
96
+ loadLibrary: async () => { throw new Error("load_library not configured"); },
85
97
  };
86
98
 
87
99
  /** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
@@ -285,6 +297,15 @@ export class PythonSandbox {
285
297
  }
286
298
  }
287
299
 
300
+ /**
301
+ * Refresh the parent-side request watchdog for every pending request.
302
+ * Used during long mid-exec work (e.g. serial implement fanout) that does not
303
+ * produce additional worker interrupts on this sandbox.
304
+ */
305
+ refreshWatchdog(): void {
306
+ this.touchPending();
307
+ }
308
+
288
309
  private send(msg: ParentMessage): void {
289
310
  this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
290
311
  }
@@ -346,6 +367,9 @@ export class PythonSandbox {
346
367
  } else if (msg.type === "advance_phase") {
347
368
  const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
348
369
  this.reply(msg.rid, { response });
370
+ } else if (msg.type === "save_artifact") {
371
+ const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
372
+ this.reply(msg.rid, { response });
349
373
  } else if (msg.type === "ask_user_question") {
350
374
  const answers = await h.askUserQuestion(msg.questions ?? [], d);
351
375
  this.reply(msg.rid, { answers });
@@ -355,13 +379,30 @@ export class PythonSandbox {
355
379
  );
356
380
  const response = await h.todo(msg.action ?? "list", params, d);
357
381
  this.reply(msg.rid, { response });
382
+ } else if (msg.type === "load_library") {
383
+ const lib = await h.loadLibrary(msg.source ?? "", d);
384
+ const isJson = typeof lib.payload !== "string";
385
+ const path = await this.writeContextFile(lib.payload, isJson);
386
+ // Worker reads then unlinks (worker._load_library). Host must not unlink here —
387
+ // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
388
+ this.reply(msg.rid, { path, json: isJson, index: lib.index, files: lib.files, chars: lib.chars });
358
389
  }
359
390
  } catch (err) {
360
391
  this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
361
392
  }
362
393
  }
363
394
 
364
- private reply(rid: string, body: { response?: string; responses?: string[]; answers?: AskAnswer[]; error?: string }): void {
395
+ private reply(rid: string, body: {
396
+ response?: string;
397
+ responses?: string[];
398
+ answers?: AskAnswer[];
399
+ path?: string;
400
+ json?: boolean;
401
+ index?: number;
402
+ files?: number;
403
+ chars?: number;
404
+ error?: string;
405
+ }): void {
365
406
  if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
366
407
  }
367
408
 
@@ -7,9 +7,9 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
7
7
  Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
8
8
  Protocol (worker -> parent): {"id","ok",...result} # response to a request
9
9
  {"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
10
- "advance_phase"|"ask_user_question"|"todo","rid",...}
10
+ "advance_phase"|"save_artifact"|"ask_user_question"|"todo","rid",...}
11
11
  # mid-exec helper request
12
- When sandbox code calls llm_query/rlm_query/advance_phase/ask_user_question/todo, the worker writes a request line
12
+ When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
13
13
  and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
14
14
  services the request in-process (it holds API keys).
15
15
  """
@@ -66,9 +66,9 @@ RESERVED = frozenset(
66
66
  {
67
67
  "llm_query", "llm_query_batched", "llm_query_chunked",
68
68
  "rlm_query", "rlm_query_batched",
69
- "advance_phase",
69
+ "advance_phase", "save_artifact",
70
70
  "ask_user_question", "todo",
71
- "stage_edit",
71
+ "stage_edit", "load_library",
72
72
  "SHOW_VARS", "answer", "context",
73
73
  }
74
74
  )
@@ -148,9 +148,11 @@ class Worker:
148
148
  ns["rlm_query"] = self._rlm_query
149
149
  ns["rlm_query_batched"] = self._rlm_query_batched
150
150
  ns["advance_phase"] = self._advance_phase
151
+ ns["save_artifact"] = self._save_artifact
151
152
  ns["ask_user_question"] = self._ask_user_question
152
153
  ns["todo"] = self._todo
153
154
  ns["stage_edit"] = self._stage_edit
155
+ ns["load_library"] = self._load_library
154
156
  ns["SHOW_VARS"] = self._show_vars
155
157
  if not isinstance(ns.get("answer"), _AnswerDict):
156
158
  cur = ns.get("answer")
@@ -331,11 +333,30 @@ class Worker:
331
333
  self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
332
334
  return edit_id
333
335
 
336
+ def _load_library(self, source: str) -> dict[str, Any] | str:
337
+ """Ask the host to pack an external dir/file/git-URL and load it as a new context slot."""
338
+ r = self._rpc("load_library", {"source": str(source)})
339
+ if r.get("error"):
340
+ return f"Error: {r['error']}"
341
+ path = r.get("path")
342
+ if not isinstance(path, str):
343
+ return "Error: malformed load_library reply (no path)"
344
+ try:
345
+ idx = self.load_context(path, r.get("index"), bool(r.get("json")))
346
+ finally:
347
+ try:
348
+ os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
349
+ except OSError:
350
+ pass
351
+ return {"index": idx, "var": f"context_{idx}",
352
+ "files": r.get("files"), "chars": r.get("chars")}
353
+
334
354
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
335
355
  """Transition the root RLM pipeline to a new phase.
336
356
 
337
357
  Only callable at depth 0. The parent handler validates the transition
338
- against the phase state machine (research → blueprint → implement → validate).
358
+ against the phase state machine (research → blueprint → implement → validate)
359
+ and runs deterministic artifact gates before accepting the transition.
339
360
  Returns a short confirmation, or an `Error: …` string the model can act on.
340
361
  """
341
362
  if self.depth > 0:
@@ -348,6 +369,22 @@ class Worker:
348
369
  return response
349
370
  return response if isinstance(response, str) else "ok"
350
371
 
372
+ def _save_artifact(self, kind: str, content: str) -> str:
373
+ """Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
374
+
375
+ Only callable at depth 0. The engine gates advance_phase against the latest
376
+ saved artifact for the current stage.
377
+ """
378
+ if self.depth > 0:
379
+ return "Error: save_artifact is only available at the root RLM depth"
380
+ r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
381
+ if r.get("error"):
382
+ return f"Error: {r['error']}"
383
+ response = r.get("response", "ok")
384
+ if isinstance(response, str) and response.startswith("Error:"):
385
+ return response
386
+ return response if isinstance(response, str) else "ok"
387
+
351
388
  def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
352
389
  prompts = [str(p) for p in prompts]
353
390
  if not prompts:
@@ -18,6 +18,7 @@ export type {
18
18
  } from "./rows.ts";
19
19
  export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
20
20
  export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
21
- export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
21
+ export { readRows, readHeader, readContextSidecar, readLibrarySidecars, listRunIds, resolveRunId } from "./reads.ts";
22
+ export type { LibrarySlot } from "./reads.ts";
22
23
  export { reconstructRlmState } from "./resume.ts";
23
24
  export type { PhaseRecon, ReconstructResult } from "./resume.ts";
@@ -32,8 +32,10 @@ export const runDir = (cwd: string, dir: string, runId: string): string => join(
32
32
 
33
33
  export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
34
34
 
35
- export const contextPath = (cwd: string, dir: string, runId: string, json: boolean): string =>
36
- join(runDir(cwd, dir, runId), json ? "context.json" : "context.txt");
35
+ export const contextPath = (cwd: string, dir: string, runId: string, json: boolean, index = 0): string =>
36
+ join(runDir(cwd, dir, runId), index === 0
37
+ ? (json ? "context.json" : "context.txt")
38
+ : `context.${index}.${json ? "json" : "txt"}`);
37
39
 
38
40
  /** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
39
41
  export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
@@ -6,8 +6,9 @@
6
6
  * by the slug (ISO-like timestamps are self-sorting).
7
7
  */
8
8
 
9
- import { open, readFile } from "node:fs/promises";
10
- import { runsDir, trailPath, contextPath } from "./paths.ts";
9
+ import { open, readdir, readFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
11
12
  import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
12
13
  import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
13
14
 
@@ -83,6 +84,34 @@ export async function readContextSidecar(cwd: string, dir: string, runId: string
83
84
  }
84
85
  }
85
86
 
87
+ const LIBRARY_SIDECAR = /^context\.(\d+)\.(json|txt)$/;
88
+
89
+ export interface LibrarySlot {
90
+ readonly index: number;
91
+ readonly payload: unknown;
92
+ }
93
+
94
+ /** Fail-soft lister for load_library resume sidecars (`context.<index>.json|txt`). */
95
+ export async function readLibrarySidecars(cwd: string, dir: string, runId: string): Promise<LibrarySlot[]> {
96
+ const entries = await failSoft(() => readdir(runDir(cwd, dir, runId)), [] as string[]);
97
+ const slots: LibrarySlot[] = [];
98
+ for (const name of entries) {
99
+ const m = LIBRARY_SIDECAR.exec(name);
100
+ if (!m) continue;
101
+ const index = Number(m[1]);
102
+ const json = m[2] === "json";
103
+ const content = await failSoft(
104
+ () => readFile(join(runDir(cwd, dir, runId), name), "utf-8"),
105
+ undefined as string | undefined,
106
+ );
107
+ if (content === undefined) continue;
108
+ try {
109
+ slots.push({ index, payload: json ? JSON.parse(content) as unknown : content });
110
+ } catch (e) { warn(e); }
111
+ }
112
+ return slots.sort((a, b) => a.index - b.index);
113
+ }
114
+
86
115
  /** Enumerate run-ids by directory listing; newest first (slug sorts chronologically). */
87
116
  export async function listRunIds(cwd: string, dir: string): Promise<string[]> {
88
117
  return await failSoft(() => listDirectoriesSorted(runsDir(cwd, dir)), [], { warn: false });
@@ -32,6 +32,9 @@ export interface PhaseRecon {
32
32
  readonly current: string;
33
33
  readonly advancedAt: number;
34
34
  readonly summary?: string;
35
+ /** Repo-relative artifact paths keyed by the phase that produced them. */
36
+ readonly artifacts?: Readonly<Partial<Record<string, string>>>;
37
+ readonly backwardJumps?: number;
35
38
  }
36
39
 
37
40
  export type ReconstructResult =
@@ -107,6 +110,8 @@ export async function reconstructRlmState(
107
110
  const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
108
111
  let terminated = false;
109
112
  let phase: PhaseRecon | undefined;
113
+ // Accumulate artifact paths keyed by the producing phase (artifactPhase on the row).
114
+ const artifactsAcc: Record<string, string> = {};
110
115
 
111
116
  for (const row of rows) {
112
117
  if (isHeader(row)) continue;
@@ -139,7 +144,20 @@ export async function reconstructRlmState(
139
144
  continue;
140
145
  }
141
146
  if (isPhase(row)) {
142
- phase = { current: row.phase, advancedAt: row.turn - 1, summary: row.summary };
147
+ if (row.artifactPath !== undefined && row.artifactPhase !== undefined) {
148
+ artifactsAcc[row.artifactPhase] = row.artifactPath;
149
+ }
150
+ // On loop-back to blueprint, drop stale plan so resume cannot re-gate with it.
151
+ if (row.phase === "blueprint" && row.backwardJumps !== undefined && row.backwardJumps > 0) {
152
+ delete artifactsAcc.blueprint;
153
+ }
154
+ phase = {
155
+ current: row.phase,
156
+ advancedAt: row.turn - 1,
157
+ summary: row.summary,
158
+ artifacts: Object.keys(artifactsAcc).length > 0 ? { ...artifactsAcc } : undefined,
159
+ backwardJumps: row.backwardJumps,
160
+ };
143
161
  continue;
144
162
  }
145
163
  if (isTodo(row)) {
package/src/state/rows.ts CHANGED
@@ -88,6 +88,12 @@ export interface PhaseRow {
88
88
  readonly ts: string;
89
89
  readonly phase: string;
90
90
  readonly summary?: string;
91
+ /** Optional fields (no schema-version break — isPhase guard unchanged). */
92
+ readonly artifactPath?: string;
93
+ /** Phase that produced `artifactPath` (not inferred from order — loop-back safe). */
94
+ readonly artifactPhase?: string;
95
+ readonly blockersCount?: number;
96
+ readonly backwardJumps?: number;
91
97
  }
92
98
 
93
99
  export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
@@ -32,11 +32,13 @@ export async function appendTodoRow(cwd: string, dir: string, runId: string, row
32
32
  return await appendRow(cwd, dir, runId, { kind: "todo", ...row });
33
33
  }
34
34
 
35
- /** Persist the original context ONCE at run start so resume can reload it. */
36
- export async function writeContextSidecar(cwd: string, dir: string, runId: string, context: unknown, json: boolean): Promise<boolean> {
35
+ /** Persist a context payload for resume. Slot 0 = repo context; index ≥ 1 = load_library slots. */
36
+ export async function writeContextSidecar(
37
+ cwd: string, dir: string, runId: string, context: unknown, json: boolean, index = 0,
38
+ ): Promise<boolean> {
37
39
  return await failSoft(async () => {
38
40
  await mkdir(runDir(cwd, dir, runId), { recursive: true });
39
- await writeFile(contextPath(cwd, dir, runId, json), json ? JSON.stringify(context) : String(context), "utf-8");
41
+ await writeFile(contextPath(cwd, dir, runId, json, index), json ? JSON.stringify(context) : String(context), "utf-8");
40
42
  return true;
41
43
  }, false);
42
44
  }