@hicaru/pi-rlm 0.1.6 → 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 CHANGED
@@ -117,11 +117,41 @@ These functions are injected into the model's Python namespace inside the REPL:
117
117
  | `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
118
118
  | `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
119
119
  | `ask_user_question` | `(questions) -> list[dict]` | Ask the user structured questions (depth 0 only) |
120
+ | `load_library` | `(source) -> dict \| str` | Load an external dir, file, or git URL into a new `context_N` slot |
120
121
  | `stage_edit` | `(path, old_text, new_text) -> str` | Stage a file edit; relayed to the host's native edit flow |
121
- | `advance_phase` | `(phase, summary=None) -> str` | Move the root pipeline to a new phase |
122
+ | `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only) |
123
+ | `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → implement → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
122
124
  | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
123
125
  | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
124
126
 
127
+ ### Loading external libraries
128
+
129
+ When the task needs an **external library, another source tree, or standalone docs** that are
130
+ not in the packed repo `context`, the model calls `load_library(source)` mid-run:
131
+
132
+ ```python
133
+ info = load_library("../some-lib") # local directory → repomix-packed list[dict]
134
+ info = load_library("docs/api.md") # single file → plain str
135
+ info = load_library("https://github.com/x/y.git") # shallow clone, then pack
136
+ # info == {"index": 1, "var": "context_1", "files": …, "chars": …}
137
+ # then chunk context_1 exactly like context
138
+ ```
139
+
140
+ Slots start at `context_1` (`context` / `context_0` remains the repo). Toggle via
141
+ `/rlm-config` → **Library loader** (`libraryLoader`, default on). On headless runs with
142
+ persistence, each loaded slot is written as a resume sidecar (`context.<N>.json`).
143
+
144
+ ### Artifact-gated pipeline (opt-in via `pipeline: true`)
145
+
146
+ When enabled at root depth:
147
+
148
+ 1. **Goal capture** — the brief is written verbatim to `.rlm/artifacts/goal/goal-<ts>.md` with a pre-run dirty-tree baseline.
149
+ 2. **Stages** — `clarify → research → blueprint → implement → validate`. Each produces a durable markdown artifact with frontmatter contracts; chat history is **reset** at every phase boundary (artifacts are the only channel; REPL vars persist).
150
+ 3. **Clarify (intake)** — interviews the user via `ask_user_question` (intent first, then evidence-confirmed decisions). Writes `.rlm/artifacts/clarifications/*` with `decisions_count` / `open_questions_count`. Engine gate: **≥1 serviced ask round** + artifact contract. When **`askUserQuestion` is off**, clarify is skipped and the run starts at research.
151
+ 4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`.
152
+ 5. **Implement fanout** — on `advance_phase("implement")` the engine runs one **serial** child RLM per plan phase and applies that child's edits before the next phase starts.
153
+ 6. **Corrective loop** — `blockers_count > 0` re-enters blueprint, bounded by `maxBackwardJumps` (default 2).
154
+
125
155
  ## Settings (`/rlm-config`)
126
156
 
127
157
  | Setting | Default | Meaning |
@@ -137,11 +167,15 @@ These functions are injected into the model's Python namespace inside the REPL:
137
167
  | Token ceiling | none | total input+output token cap for the whole recursive tree |
138
168
  | Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
139
169
  | Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
170
+ | Phase pipeline | off | artifact-gated clarify→research→blueprint→implement fanout→validate |
171
+ | Max validate→blueprint loops | `2` | bounded corrective re-entries when validation reports blockers |
172
+ | Ask user question | on | when pipeline is on, enables clarify intake; when off, pipeline starts at research |
140
173
  | Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
141
174
  | Root model output cap (tok) | `16384` | max output tokens per root-model turn |
142
175
  | Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
143
176
  | `askUserQuestion` | on | expose `ask_user_question()` to the model |
144
177
  | `todo` | on | expose `todo()` to the model |
178
+ | Library loader | on | expose `load_library()` for external dirs/files/git repos |
145
179
 
146
180
  > **Concurrency note:** each `rlm_query` child spawns its own `python3` worker (~50–150 ms
147
181
  > cold start). Worst-case concurrent interpreters ≈ `maxConcurrentSubcalls`^(depth−1); at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared load_library handler for headless engine and native repl() mode.
3
+ *
4
+ * Host assigns context slot indices (slot 0 = repo); packs the source via
5
+ * resolveLibrarySource; optional onLoaded writes resume sidecars.
6
+ *
7
+ * Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
8
+ * across native repl() calls — getOrCreate only installs handlers at spawn.
9
+ */
10
+
11
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
12
+ import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
13
+ import { resolveLibrarySource } from "../context/library-context.ts";
14
+ import { previewText } from "../text/preview.ts";
15
+
16
+ export interface LibraryBridgeOpts {
17
+ /** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
18
+ readonly cwd?: string;
19
+ /** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
20
+ readonly getCwd?: () => string;
21
+ readonly emitter?: RlmEmitter;
22
+ /** Native mode: read the live emitter each call. */
23
+ readonly getEmitter?: () => RlmEmitter | null | undefined;
24
+ readonly parentId?: string;
25
+ readonly signal?: AbortSignal;
26
+ /** First slot to assign (slot 0 is the repo context). Resume passes 1 + restored slots. */
27
+ readonly startIndex: number;
28
+ /** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
29
+ readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
30
+ }
31
+
32
+ export interface LibraryHandlerBundle {
33
+ readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
34
+ /** Reset the slot counter (call when the sandbox is discarded and will re-spawn). */
35
+ readonly reset: () => void;
36
+ }
37
+
38
+ export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
39
+ let nextIndex = opts.startIndex;
40
+ return {
41
+ reset: () => { nextIndex = opts.startIndex; },
42
+ handlers: {
43
+ async loadLibrary(source, depth) {
44
+ const emitter = opts.getEmitter?.() ?? opts.emitter;
45
+ const cwd = opts.getCwd?.() ?? opts.cwd;
46
+ if (cwd === undefined || cwd === "") {
47
+ throw new Error("load_library: no cwd configured");
48
+ }
49
+ const id = emitter?.emitSubcallCreated({
50
+ kind: "tool", parentId: opts.parentId,
51
+ label: "load_library",
52
+ args: previewText(source, 80),
53
+ depth,
54
+ });
55
+ try {
56
+ const resolved = await resolveLibrarySource(source, cwd, opts.signal);
57
+ if (!resolved.ok) throw new Error(resolved.error);
58
+ const index = nextIndex++;
59
+ await opts.onLoaded?.(index, resolved.value.payload);
60
+ if (id) emitter?.emitSubcallUpdated({
61
+ id, status: "done",
62
+ resultPreview: `context_${index}: ${resolved.value.files ?? 1} file(s), ${resolved.value.chars.toLocaleString()} chars`,
63
+ });
64
+ return {
65
+ payload: resolved.value.payload,
66
+ index,
67
+ files: resolved.value.files,
68
+ chars: resolved.value.chars,
69
+ };
70
+ } catch (err) {
71
+ if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
72
+ throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
73
+ }
74
+ },
75
+ },
76
+ };
77
+ }
@@ -4,18 +4,31 @@
4
4
  * A child RLM gets its own sandbox and iterates over the prompt as its context. At/over the
5
5
  * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
6
  * concurrency pool bounds parallel children for `rlm_query_batched`.
7
+ *
8
+ * `childRun` is the single spawn path: rlmQuery returns `.answer`; implement fanout also
9
+ * needs `.edits` — both share this implementation (DRY).
7
10
  */
8
11
 
9
- import type { RunRlm } from "../core/types.ts";
12
+ import type { RlmResult, RunRlm } from "../core/types.ts";
10
13
  import type { LlmBridge } from "./llm-query.ts";
11
14
  import type { RlmEmitter } from "../tool/rlm-events.ts";
12
15
  import { checkResourceLimits } from "../core/resource-limits.ts";
13
16
  import { formatError } from "../util/errors.ts";
14
17
  import { mapPool } from "../util/concurrency.ts";
15
18
 
19
+ export interface ChildRunInput {
20
+ readonly rootPrompt: string;
21
+ readonly context: unknown;
22
+ readonly depth: number;
23
+ readonly label?: string;
24
+ readonly model?: string | null;
25
+ }
26
+
16
27
  export interface RlmHandlers {
17
28
  rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
18
29
  rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
30
+ /** Full child-run result (answer + edits + usage) for engine-driven fanout. */
31
+ childRun(input: ChildRunInput): Promise<RlmResult>;
19
32
  }
20
33
 
21
34
  export interface RlmBridgeOptions {
@@ -33,29 +46,51 @@ export interface RlmBridgeOptions {
33
46
  readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
34
47
  }
35
48
 
49
+ function emptyResult(answer: string): RlmResult {
50
+ return {
51
+ answer,
52
+ edits: [],
53
+ iterations: 0,
54
+ costUsd: 0,
55
+ inputTokens: 0,
56
+ outputTokens: 0,
57
+ durationMs: 0,
58
+ };
59
+ }
60
+
36
61
  export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
37
- async function child(prompt: string, model: string | null, depth: number): Promise<string> {
38
- const childDepth = depth + 1;
62
+ async function childRun(input: ChildRunInput): Promise<RlmResult> {
63
+ const childDepth = input.depth;
39
64
  // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
40
- if (childDepth >= opts.maxDepth) return opts.llm.llmQuery(prompt, model, depth);
65
+ // (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
66
+ if (childDepth >= opts.maxDepth) {
67
+ const answer = await opts.llm.llmQuery(
68
+ input.rootPrompt || String(input.context),
69
+ input.model ?? null,
70
+ childDepth - 1,
71
+ );
72
+ return emptyResult(answer);
73
+ }
41
74
  let subId: string | undefined;
42
75
  try {
43
76
  const rem = opts.remainingBudget?.() ?? {};
44
77
  // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
45
78
  // (reference: _subcall checks remaining_budget/timeout before spawning).
46
79
  const limitError = checkResourceLimits(rem);
47
- if (limitError) return limitError;
80
+ if (limitError) return emptyResult(limitError);
81
+ const label = input.label ?? "rlm_query";
82
+ const detailSource = input.rootPrompt || String(input.context);
48
83
  subId = opts.emitter.emitSubcallCreated({
49
- kind: "rlm", parentId: opts.parentNodeId, label: "rlm_query",
50
- model: model ?? undefined, detail: prompt.slice(0, 60),
84
+ kind: "rlm", parentId: opts.parentNodeId, label,
85
+ model: input.model ?? undefined, detail: detailSource.slice(0, 60),
51
86
  depth: childDepth,
52
87
  });
53
88
  const res = await opts.run({
54
- rootPrompt: "",
55
- context: prompt,
89
+ rootPrompt: input.rootPrompt,
90
+ context: input.context,
56
91
  depth: childDepth,
57
92
  parentNodeId: subId,
58
- modelOverride: model ?? undefined,
93
+ modelOverride: input.model ?? undefined,
59
94
  remainingBudgetUsd: rem.budgetUsd,
60
95
  remainingTimeoutMs: rem.timeoutMs,
61
96
  });
@@ -63,16 +98,27 @@ export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
63
98
  opts.emitter.emitSubcallUpdated({ id: subId,
64
99
  status: "done", resultPreview: res.answer.slice(0, 200),
65
100
  });
66
- return res.answer;
101
+ return res;
67
102
  } catch (err) {
68
103
  const msg = err instanceof Error ? err.message : String(err);
69
104
  if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
70
- return formatError(`child RLM failed - ${msg}`);
105
+ return emptyResult(formatError(`child RLM failed - ${msg}`));
71
106
  }
72
107
  }
73
108
 
109
+ async function child(prompt: string, model: string | null, depth: number): Promise<string> {
110
+ const res = await childRun({
111
+ rootPrompt: "",
112
+ context: prompt,
113
+ depth: depth + 1,
114
+ model,
115
+ });
116
+ return res.answer;
117
+ }
118
+
74
119
  return {
75
120
  rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
76
121
  rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
122
+ childRun,
77
123
  };
78
124
  }
@@ -21,12 +21,14 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
21
21
  maxErrors: 5,
22
22
  orchestrator: true,
23
23
  pipeline: false,
24
+ maxBackwardJumps: 2,
24
25
  compaction: true,
25
26
  compactionThresholdPct: 0.65,
26
27
  python: "python3",
27
28
  sandboxInitTimeoutMs: 30_000,
28
29
  askUserQuestion: true,
29
30
  todo: true,
31
+ libraryLoader: true,
30
32
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
31
33
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
32
34
  subSampling: Object.freeze({ maxTokens: 8192 }),
@@ -75,6 +75,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
75
75
  if (orchestrator !== undefined) out.orchestrator = orchestrator;
76
76
  const pipeline = validateBoolean(r.pipeline);
77
77
  if (pipeline !== undefined) out.pipeline = pipeline;
78
+ const maxBackwardJumps = validateNumber(r.maxBackwardJumps, 0);
79
+ if (maxBackwardJumps !== undefined) out.maxBackwardJumps = maxBackwardJumps;
78
80
  const compaction = validateBoolean(r.compaction);
79
81
  if (compaction !== undefined) out.compaction = compaction;
80
82
  const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
@@ -92,6 +94,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
92
94
  if (askUserQuestion !== undefined) out.askUserQuestion = askUserQuestion;
93
95
  const todo = validateBoolean(r.todo);
94
96
  if (todo !== undefined) out.todo = todo;
97
+ const libraryLoader = validateBoolean(r.libraryLoader);
98
+ if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
95
99
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
96
100
  const ss = r.subSampling as Record<string, unknown>;
97
101
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Resolve load_library(source) into a sandbox-ready payload.
3
+ *
4
+ * Sources: local directory (repomix-packed), single file (utf-8 string), or
5
+ * remote git URL (shallow clone then pack). Host-side only — never runs in the sandbox.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { isAbsolute, join, resolve } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { packRepository, serializeForSandbox, type ContextBundle } from "./repomix-context.ts";
14
+ import type { Result } from "../util/errors.ts";
15
+ import { errorMessage } from "../util/errors.ts";
16
+
17
+ const execFileP = promisify(execFile);
18
+
19
+ export interface LibrarySource {
20
+ readonly payload: unknown; // str for a single file; ContextFile[] for a packed dir/repo
21
+ readonly files?: number; // undefined for single-file payloads
22
+ readonly chars: number;
23
+ }
24
+
25
+ /** https://host/… or git@host:… — option-injection safe (never starts with "-"). */
26
+ const GIT_URL = /^(https:\/\/|git@)[\w.-]+[:/]\S+$/;
27
+
28
+ export async function resolveLibrarySource(
29
+ source: string,
30
+ cwd: string,
31
+ signal?: AbortSignal,
32
+ ): Promise<Result<LibrarySource, string>> {
33
+ const trimmed = source.trim();
34
+ if (trimmed === "") return { ok: false, error: "load_library: empty source" };
35
+ if (GIT_URL.test(trimmed)) return await cloneAndPack(trimmed, signal);
36
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
37
+ return { ok: false, error: `unsupported URL scheme (only https:// and git@ are allowed): ${trimmed}` };
38
+ }
39
+ const path = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
40
+ let s: Awaited<ReturnType<typeof stat>>;
41
+ try {
42
+ s = await stat(path);
43
+ } catch {
44
+ return { ok: false, error: `load_library: path not found: ${path}` };
45
+ }
46
+ if (s.isDirectory()) return await packDir(path, signal);
47
+ const text = await readFile(path, "utf-8");
48
+ return { ok: true, value: { payload: text, chars: text.length } };
49
+ }
50
+
51
+ async function packDir(dir: string, signal?: AbortSignal): Promise<Result<LibrarySource, string>> {
52
+ const packed = await packRepository(dir, signal); // repomix + existing per-path cache
53
+ if (!packed.ok) return { ok: false, error: `pack failed for ${dir} — ${packed.error}` };
54
+ return { ok: true, value: bundleToSource(packed.value) };
55
+ }
56
+
57
+ function bundleToSource(bundle: ContextBundle): LibrarySource {
58
+ const payload = serializeForSandbox(bundle);
59
+ // Match slot-0 contextLength for object/array payloads: JSON-serialized size,
60
+ // not sum-of-content-lengths (bundle.totalChars), so the model sees one ruler.
61
+ return {
62
+ payload,
63
+ files: bundle.totalFiles,
64
+ chars: JSON.stringify(payload).length,
65
+ };
66
+ }
67
+
68
+ async function cloneAndPack(url: string, signal?: AbortSignal): Promise<Result<LibrarySource, string>> {
69
+ const dir = await mkdtemp(join(tmpdir(), "rlm-lib-"));
70
+ try {
71
+ // "--" terminates options; URL regex already forbids a leading dash.
72
+ await execFileP("git", ["clone", "--depth", "1", "--", url, dir], { signal, timeout: 120_000 });
73
+ return await packDir(dir, signal);
74
+ } catch (err: unknown) {
75
+ return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
76
+ } finally {
77
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
78
+ }
79
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Artifact plumbing for the gated RLM pipeline: goal capture, baseline dirty-tree
3
+ * snapshot, and timestamped stage artifact writes under `.rlm/artifacts/`.
4
+ */
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { Result } from "../util/errors.ts";
9
+
10
+ export const ARTIFACTS_DIR = ".rlm/artifacts";
11
+
12
+ const stamp = (): string => new Date().toISOString().replace(/[:.]/g, "-");
13
+
14
+ export interface GoalCapture {
15
+ readonly goalPath: string; // repo-relative
16
+ readonly baselinePath: string; // repo-relative
17
+ }
18
+
19
+ export type SaveOutcome =
20
+ | { readonly ok: true; readonly path: string }
21
+ | { readonly ok: false; readonly error: string };
22
+
23
+ export type GoalCaptureResult =
24
+ | { readonly ok: true; readonly value: GoalCapture }
25
+ | { readonly ok: false; readonly error: string };
26
+
27
+ /**
28
+ * Capture the user's brief VERBATIM: no frontmatter, no headers — the raw file
29
+ * is the only artifact that preserves explicit user constraints unrefracted. The
30
+ * baseline snapshot records paths ALREADY dirty before the run, so validate
31
+ * judges only the run's own delta. Best-effort: git failure ⇒ empty baseline.
32
+ * Failures never throw (unwritable cwd etc.) — returns error for the engine to surface.
33
+ */
34
+ export function captureGoal(cwd: string, brief: string): GoalCaptureResult {
35
+ try {
36
+ const ts = stamp();
37
+ const dir = join(ARTIFACTS_DIR, "goal");
38
+ mkdirSync(join(cwd, dir), { recursive: true });
39
+ const goalPath = join(dir, `goal-${ts}.md`);
40
+ writeFileSync(join(cwd, goalPath), brief, "utf-8");
41
+ let paths: readonly string[] = [];
42
+ try {
43
+ paths = execFileSync("git", ["status", "--short"], {
44
+ cwd,
45
+ encoding: "utf-8",
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ })
48
+ .split("\n")
49
+ .filter((l) => l.trim() !== "")
50
+ .map((l) => {
51
+ const rest = l.slice(3).trim();
52
+ const arrow = rest.indexOf(" -> ");
53
+ return arrow >= 0 ? rest.slice(arrow + 4).trim() : rest;
54
+ });
55
+ } catch {
56
+ paths = [];
57
+ }
58
+ const baselinePath = join(dir, `baseline-${ts}.json`);
59
+ writeFileSync(join(cwd, baselinePath), JSON.stringify({ paths }, null, 2), "utf-8");
60
+ return { ok: true, value: { goalPath, baselinePath } };
61
+ } catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return { ok: false, error: message };
64
+ }
65
+ }
66
+
67
+ /** Write a stage artifact under its dir; timestamped so runs never collide. */
68
+ export function saveArtifact(cwd: string, dir: string, slug: string, content: string): SaveOutcome {
69
+ try {
70
+ const rel = join(ARTIFACTS_DIR, dir, `${stamp()}_${slug}.md`);
71
+ mkdirSync(join(cwd, ARTIFACTS_DIR, dir), { recursive: true });
72
+ writeFileSync(join(cwd, rel), content, "utf-8");
73
+ return { ok: true, path: rel };
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ return { ok: false, error: message };
77
+ }
78
+ }
79
+
80
+ /** Read a previously saved artifact (repo-relative path). Failures never throw. */
81
+ export function readArtifact(cwd: string, relPath: string): Result<string, string> {
82
+ try {
83
+ return { ok: true, value: readFileSync(join(cwd, relPath), "utf-8") };
84
+ } catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ return { ok: false, error: `could not read artifact ${relPath}: ${message}` };
87
+ }
88
+ }