@hicaru/pi-rlm 0.2.1 → 0.2.2
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 +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +6 -17
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +55 -335
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +23 -12
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -407
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +8 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/{worker.py → py/worker.py} +76 -696
- package/src/sandbox/sandbox-manager.ts +13 -0
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +29 -3
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +37 -159
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +1 -12
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/core/gates.ts
DELETED
|
@@ -1,301 +0,0 @@
|
|
|
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
|
-
/** Count newlines without materialising a line array. */
|
|
82
|
-
export function lineCountOf(text: string): number {
|
|
83
|
-
let count = 1;
|
|
84
|
-
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++;
|
|
85
|
-
return count;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Names of `## Phase N:` sections that contain no `### Success Criteria` heading. */
|
|
89
|
-
export function phasesMissingSuccessCriteria(content: string): readonly string[] {
|
|
90
|
-
const missing: string[] = [];
|
|
91
|
-
let currentPhase: string | undefined;
|
|
92
|
-
let sawCriteria = false;
|
|
93
|
-
const flush = (): void => {
|
|
94
|
-
if (currentPhase !== undefined && !sawCriteria) missing.push(currentPhase);
|
|
95
|
-
};
|
|
96
|
-
forEachLineOutsideFences(content, (line) => {
|
|
97
|
-
const phase = /^## Phase (\d+):/.exec(line);
|
|
98
|
-
if (phase) {
|
|
99
|
-
flush();
|
|
100
|
-
currentPhase = `Phase ${phase[1] ?? "?"}`;
|
|
101
|
-
sawCriteria = false;
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (currentPhase !== undefined && /^### Success Criteria/.test(line)) sawCriteria = true;
|
|
105
|
-
});
|
|
106
|
-
flush();
|
|
107
|
-
return Object.freeze(missing);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Fence-aware count of top-level (column-0) `- ` bullets under a `## <heading>` section.
|
|
112
|
-
* Nested/indented sub-bullets are ignored. The next `## ` heading ends the section.
|
|
113
|
-
* Missing heading ⇒ 0.
|
|
114
|
-
*/
|
|
115
|
-
export function countBulletsUnderHeading(content: string, heading: string): number {
|
|
116
|
-
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
|
|
117
|
-
let inSection = false;
|
|
118
|
-
let count = 0;
|
|
119
|
-
forEachLineOutsideFences(content, (line) => {
|
|
120
|
-
if (/^##\s+/.test(line)) {
|
|
121
|
-
inSection = headingRe.test(line);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
// Column-0 only: do not trimStart — indented sub-bullets must not inflate the count.
|
|
125
|
-
if (inSection && BULLET_RE.test(line)) count++;
|
|
126
|
-
});
|
|
127
|
-
return count;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* True when `## <heading>` exists and has non-whitespace body before the next `## `.
|
|
132
|
-
* Only the first matching heading is considered (later duplicates are ignored).
|
|
133
|
-
*/
|
|
134
|
-
export function sectionHasNonEmptyBody(content: string, heading: string): boolean {
|
|
135
|
-
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
|
|
136
|
-
let inSection = false;
|
|
137
|
-
let seen = false; // first match wins — do not re-enter on a later duplicate heading
|
|
138
|
-
let hasBody = false; // only emptiness matters — never accumulate the body itself
|
|
139
|
-
forEachLineOutsideFences(content, (line) => {
|
|
140
|
-
if (/^##\s+/.test(line)) {
|
|
141
|
-
if (inSection) {
|
|
142
|
-
inSection = false;
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (!seen && headingRe.test(line)) {
|
|
146
|
-
inSection = true;
|
|
147
|
-
seen = true;
|
|
148
|
-
}
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (inSection && line.trim().length > 0) hasBody = true;
|
|
152
|
-
});
|
|
153
|
-
return hasBody;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function escapeRegExp(s: string): string {
|
|
157
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Frontmatter as a plain record (parseFrontmatter returns unknown-shaped data). */
|
|
161
|
-
function frontmatterOf(content: string): Record<string, unknown> {
|
|
162
|
-
const { frontmatter } = parseFrontmatter(content);
|
|
163
|
-
return typeof frontmatter === "object" && frontmatter !== null
|
|
164
|
-
? (frontmatter as Record<string, unknown>)
|
|
165
|
-
: {};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** `status: ready` floor — shared by every produces-stage gate. */
|
|
169
|
-
export function checkStatusReady(content: string, path: string): GateResult<undefined> {
|
|
170
|
-
const status = frontmatterOf(content).status;
|
|
171
|
-
return status === STATUS_READY
|
|
172
|
-
? { ok: true, value: undefined }
|
|
173
|
-
: { ok: false, error: `artifact ${path} has status '${String(status)}' — set frontmatter status: ready before advancing` };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Plan-structure floor:
|
|
178
|
-
* `phases:` array ≡ fence-aware `## Phase N:` heading count, `phase_count` ≡
|
|
179
|
-
* array length, count within [1, MAX_PHASES]. Stale array ⇒ reject, so the
|
|
180
|
-
* fanout never dispatches a wrong unit list.
|
|
181
|
-
*/
|
|
182
|
-
export function planPhaseRecords(content: string, path: string): GateResult<PlanGateData> {
|
|
183
|
-
const fm = frontmatterOf(content);
|
|
184
|
-
const raw = fm.phases;
|
|
185
|
-
const phases = Array.isArray(raw) ? raw : [];
|
|
186
|
-
const headingCount = countHeadingsOutsideFences(content, PLAN_PHASE_RE);
|
|
187
|
-
if (phases.length !== headingCount) {
|
|
188
|
-
return { ok: false, error: `plan ${path}: frontmatter phases (${phases.length}) ≠ '## Phase N:' headings (${headingCount}) — rebuild the phases: array from the body headings` };
|
|
189
|
-
}
|
|
190
|
-
if (fm.phase_count !== phases.length) {
|
|
191
|
-
return { ok: false, error: `plan ${path}: phase_count (${String(fm.phase_count)}) ≠ phases length (${phases.length}) — rebuild phase_count` };
|
|
192
|
-
}
|
|
193
|
-
if (phases.length === 0) {
|
|
194
|
-
return { ok: false, error: `plan ${path}: declares no '## Phase N:' sections — a plan needs at least one phase` };
|
|
195
|
-
}
|
|
196
|
-
if (phases.length > MAX_PHASES) {
|
|
197
|
-
return { ok: false, error: `plan ${path}: ${phases.length} phases exceeds MAX_PHASES (${MAX_PHASES}) — split the plan` };
|
|
198
|
-
}
|
|
199
|
-
const records = new Array<PhaseRecord>(phases.length);
|
|
200
|
-
for (let index = 0; index < phases.length; index++) {
|
|
201
|
-
const entry = phases[index];
|
|
202
|
-
const e = typeof entry === "object" && entry !== null ? (entry as Record<string, unknown>) : {};
|
|
203
|
-
records[index] = {
|
|
204
|
-
n: typeof e.n === "number" ? e.n : index + 1,
|
|
205
|
-
title: typeof e.title === "string" ? e.title : "",
|
|
206
|
-
index,
|
|
207
|
-
total: phases.length,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
return { ok: true, value: { phases: records } };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Citation floor (direct path resolution only): every `path/file.ext:NN[-MM]`
|
|
215
|
-
* in the artifact body must name a real file with at least NN lines. Unbacked
|
|
216
|
-
* citations are fabricated precision — reject before they mislead implement.
|
|
217
|
-
*/
|
|
218
|
-
const FILE_LINE_CITATION_RE =
|
|
219
|
-
/((?:(?<![\w.])\.)?(?<!\w)[\w][\w./-]*\.[a-zA-Z][a-zA-Z0-9]{0,4}):(\d+)(?:-(\d+))?/g;
|
|
220
|
-
|
|
221
|
-
export function verifyCitations(body: string, cwd: string): GateResult<undefined> {
|
|
222
|
-
const errors: string[] = [];
|
|
223
|
-
const seen = new Set<string>();
|
|
224
|
-
for (const m of body.matchAll(FILE_LINE_CITATION_RE)) {
|
|
225
|
-
const path = m[1];
|
|
226
|
-
const startStr = m[2];
|
|
227
|
-
const endStr = m[3];
|
|
228
|
-
if (path === undefined || startStr === undefined) continue;
|
|
229
|
-
const key = `${path}:${startStr}${endStr !== undefined ? `-${endStr}` : ""}`;
|
|
230
|
-
if (seen.has(key)) continue;
|
|
231
|
-
seen.add(key);
|
|
232
|
-
const abs = isAbsolute(path) ? path : join(cwd, path);
|
|
233
|
-
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
234
|
-
errors.push(`unbacked citation ${key} — file does not exist (use a repo-root-relative path or drop the line numbers)`);
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
let lineCount: number;
|
|
238
|
-
try {
|
|
239
|
-
lineCount = lineCountOf(readFileSync(abs, "utf-8"));
|
|
240
|
-
} catch {
|
|
241
|
-
errors.push(`unbacked citation ${key} — file could not be read`);
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
const high = Math.max(Number(startStr), endStr !== undefined ? Number(endStr) : 0);
|
|
245
|
-
if (high > lineCount) {
|
|
246
|
-
errors.push(`unbacked citation ${key} — file has ${lineCount} lines; correct the range or drop the line numbers`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return errors.length === 0
|
|
250
|
-
? { ok: true, value: undefined }
|
|
251
|
-
: { ok: false, error: errors.slice(0, 10).join("\n") };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Validation-contract floor: the validate artifact must carry the numeric gate
|
|
256
|
-
* field (`blockers_count`) so routing is measured, never inferred from prose.
|
|
257
|
-
*/
|
|
258
|
-
export function validationRecord(content: string, path: string): GateResult<ValidationGateData> {
|
|
259
|
-
const fm = frontmatterOf(content);
|
|
260
|
-
const blockers = fm.blockers_count;
|
|
261
|
-
const verdict = fm.verdict;
|
|
262
|
-
if (typeof blockers !== "number" || !Number.isInteger(blockers) || blockers < 0) {
|
|
263
|
-
return { ok: false, error: `validation ${path}: frontmatter blockers_count must be an integer ≥ 0 (got ${String(blockers)})` };
|
|
264
|
-
}
|
|
265
|
-
if (verdict !== "pass" && verdict !== "fail") {
|
|
266
|
-
return { ok: false, error: `validation ${path}: frontmatter verdict must be 'pass' or 'fail' (got ${String(verdict)})` };
|
|
267
|
-
}
|
|
268
|
-
if (verdict === "pass" && blockers > 0) {
|
|
269
|
-
return { ok: false, error: `validation ${path}: verdict 'pass' contradicts blockers_count ${blockers}` };
|
|
270
|
-
}
|
|
271
|
-
return { ok: true, value: { blockersCount: blockers, verdict } };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Clarification-contract floor: interview outcome document.
|
|
276
|
-
* `decisions_count` / `open_questions_count` must match fence-aware bullet counts;
|
|
277
|
-
* `## Problem & Intent` must be present and non-empty (user's words).
|
|
278
|
-
*/
|
|
279
|
-
export function clarificationRecord(content: string, path: string): GateResult<ClarificationGateData> {
|
|
280
|
-
const fm = frontmatterOf(content);
|
|
281
|
-
const decisions = fm.decisions_count;
|
|
282
|
-
const openQs = fm.open_questions_count;
|
|
283
|
-
if (typeof decisions !== "number" || !Number.isInteger(decisions) || decisions < 0) {
|
|
284
|
-
return { ok: false, error: `clarification ${path}: frontmatter decisions_count must be an integer ≥ 0 (got ${String(decisions)})` };
|
|
285
|
-
}
|
|
286
|
-
if (typeof openQs !== "number" || !Number.isInteger(openQs) || openQs < 0) {
|
|
287
|
-
return { ok: false, error: `clarification ${path}: frontmatter open_questions_count must be an integer ≥ 0 (got ${String(openQs)})` };
|
|
288
|
-
}
|
|
289
|
-
if (!sectionHasNonEmptyBody(content, "Problem & Intent")) {
|
|
290
|
-
return { ok: false, error: `clarification ${path}: '## Problem & Intent' section is missing or empty — record the user's words verbatim` };
|
|
291
|
-
}
|
|
292
|
-
const decisionBullets = countBulletsUnderHeading(content, "Decisions");
|
|
293
|
-
if (decisions !== decisionBullets) {
|
|
294
|
-
return { ok: false, error: `clarification ${path}: decisions_count (${decisions}) ≠ '- ' bullets under '## Decisions' (${decisionBullets}) — rebuild the count from the body` };
|
|
295
|
-
}
|
|
296
|
-
const openBullets = countBulletsUnderHeading(content, "Open Questions");
|
|
297
|
-
if (openQs !== openBullets) {
|
|
298
|
-
return { ok: false, error: `clarification ${path}: open_questions_count (${openQs}) ≠ '- ' bullets under '## Open Questions' (${openBullets}) — rebuild the count from the body` };
|
|
299
|
-
}
|
|
300
|
-
return { ok: true, value: { decisionsCount: decisions, openQuestionsCount: openQs } };
|
|
301
|
-
}
|
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The phase pipeline's stateful half: the `save_artifact` / `advance_phase` sandbox handlers
|
|
3
|
-
* and the validate-phase finalize routing.
|
|
4
|
-
*
|
|
5
|
-
* Pulled out of the engine's run closure so the pipeline's mutable state (current phase, the
|
|
6
|
-
* per-phase latest save, serviced ask rounds, accumulated warnings, a pending history reset)
|
|
7
|
-
* lives in one owner instead of six `let`s threaded through a 640-line function.
|
|
8
|
-
*
|
|
9
|
-
* Two invariants this file exists to protect:
|
|
10
|
-
* - Gates measure the CURRENT phase's latest save (`lastSaved`) only — never
|
|
11
|
-
* `phase.artifacts`, whose paths are the completed channel and go stale across a
|
|
12
|
-
* corrective loop-back.
|
|
13
|
-
* - `lastSaved` and `askRounds` are session-only. They are never rehydrated from the trail,
|
|
14
|
-
* so a resumed run must genuinely re-save and re-interview rather than re-gate stale work.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { ChatMsg } from "../bridge/model.ts";
|
|
18
|
-
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
19
|
-
import type { PhaseRecon } from "../state/resume.ts";
|
|
20
|
-
import { formatError } from "../util/errors.ts";
|
|
21
|
-
import { readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
|
|
22
|
-
import { critiqueArtifact, formatCritique } from "./critique.ts";
|
|
23
|
-
import {
|
|
24
|
-
advancePhase as validatePhaseTransition,
|
|
25
|
-
initialPhaseState,
|
|
26
|
-
isPhase,
|
|
27
|
-
PHASES,
|
|
28
|
-
reconcilePhase,
|
|
29
|
-
routeAfterValidate,
|
|
30
|
-
stageForArtifactKind,
|
|
31
|
-
STAGES,
|
|
32
|
-
type ArtifactRef,
|
|
33
|
-
type Phase,
|
|
34
|
-
type PhaseState,
|
|
35
|
-
type SavedArtifact,
|
|
36
|
-
type StageGateData,
|
|
37
|
-
} from "./pipeline.ts";
|
|
38
|
-
|
|
39
|
-
/** Builds the fresh-session history for a phase. Supplied by the engine (its own policy). */
|
|
40
|
-
export type ResetHistoryForPhase = (
|
|
41
|
-
state: PhaseState,
|
|
42
|
-
options: { readonly goal?: GoalCapture; readonly validation?: import("./gates.ts").ValidationGateData; readonly notice?: string },
|
|
43
|
-
) => ChatMsg[];
|
|
44
|
-
|
|
45
|
-
/** Appends a `phase` row to the run trail. Supplied by the engine (owns persistence state). */
|
|
46
|
-
export type PersistPhaseRow = (
|
|
47
|
-
state: PhaseState,
|
|
48
|
-
artifactPath: string | undefined,
|
|
49
|
-
artifactPhase: Phase | undefined,
|
|
50
|
-
gateData: StageGateData | undefined,
|
|
51
|
-
supersededPath?: string,
|
|
52
|
-
) => Promise<void>;
|
|
53
|
-
|
|
54
|
-
export interface PipelineDeps {
|
|
55
|
-
/** Repo root that artifact paths are resolved against. */
|
|
56
|
-
readonly runCwd: string;
|
|
57
|
-
readonly maxBackwardJumps: number;
|
|
58
|
-
readonly emitter: RlmEmitter;
|
|
59
|
-
/** Turns completed so far — phase rows and `advancedAt` are stamped with it. */
|
|
60
|
-
readonly completedTurns: () => number;
|
|
61
|
-
readonly resetHistoryForPhase: ResetHistoryForPhase;
|
|
62
|
-
readonly persistPhaseRow: PersistPhaseRow;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** What the engine should do with a finalize submitted while in the `validate` phase. */
|
|
66
|
-
export type ValidateOutcome =
|
|
67
|
-
/** Not finalizable yet — feed `error` back as the next turn's REPL output. */
|
|
68
|
-
| { readonly kind: "reject"; readonly error: string }
|
|
69
|
-
/** Blockers found — re-enter `blueprint` with this fresh history. */
|
|
70
|
-
| { readonly kind: "loop-back"; readonly history: ChatMsg[] }
|
|
71
|
-
/** Backward-jump cap reached — terminate with this report. */
|
|
72
|
-
| { readonly kind: "halt"; readonly report: string }
|
|
73
|
-
/** Validation passed — take the model's final answer. */
|
|
74
|
-
| { readonly kind: "accept" };
|
|
75
|
-
|
|
76
|
-
/** The sandbox handlers the pipeline contributes. Shape-compatible with `SubLlmHandlers`. */
|
|
77
|
-
export interface PipelineHandlers {
|
|
78
|
-
saveArtifact(kind: string, content: string): Promise<string>;
|
|
79
|
-
advancePhase(phase: string, summary: string | undefined): Promise<string>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export class PipelineController {
|
|
83
|
-
/** Current phase state; `undefined` until seeded. Read by the engine for gate prompts/rows. */
|
|
84
|
-
phase: PhaseState | undefined;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Latest save per phase: path plus an optional gate memo, as ONE record so the two cannot
|
|
88
|
-
* desync. Cleared on phase exit and on loop-back.
|
|
89
|
-
*/
|
|
90
|
-
private lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
|
|
91
|
-
|
|
92
|
-
/** Serviced ask_user_question rounds in the current phase (session-only). */
|
|
93
|
-
private askRounds = 0;
|
|
94
|
-
|
|
95
|
-
/** Advisory critique warnings accumulated across saves (surfaced in the TUI). */
|
|
96
|
-
private warnings: readonly string[] = [];
|
|
97
|
-
|
|
98
|
-
/** History replacement scheduled by advance_phase; the engine drains it at a turn boundary. */
|
|
99
|
-
private pendingReset: ChatMsg[] | undefined;
|
|
100
|
-
|
|
101
|
-
private goal: GoalCapture | undefined;
|
|
102
|
-
|
|
103
|
-
constructor(private readonly deps: PipelineDeps) {}
|
|
104
|
-
|
|
105
|
-
/** Called after each successfully serviced root-depth ask_user_question round. */
|
|
106
|
-
noteAskRound(): void {
|
|
107
|
-
this.askRounds++;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Take and clear the scheduled fresh-session history, if advance_phase left one. */
|
|
111
|
-
takePendingReset(): ChatMsg[] | undefined {
|
|
112
|
-
const reset = this.pendingReset;
|
|
113
|
-
this.pendingReset = undefined;
|
|
114
|
-
return reset;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Seed a fresh run: capture goal, enter `startPhase`, and build the opening history. */
|
|
118
|
-
seedFresh(startPhase: Phase, goal: GoalCapture | undefined, notice: string | undefined): ChatMsg[] {
|
|
119
|
-
this.goal = goal;
|
|
120
|
-
this.phase = initialPhaseState(0, startPhase);
|
|
121
|
-
return this.deps.resetHistoryForPhase(this.phase, { goal, notice });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Rehydrate phase state from a trail. `lastSaved`/`askRounds` stay empty by design: a
|
|
126
|
-
* resume must re-save and re-interview rather than re-gate work from a previous process.
|
|
127
|
-
*/
|
|
128
|
-
seedFromResume(recon: PhaseRecon): void {
|
|
129
|
-
const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
|
|
130
|
-
for (const [key, value] of Object.entries(recon.artifacts ?? {})) {
|
|
131
|
-
if (value === undefined || !isPhase(key)) continue;
|
|
132
|
-
artifacts[key] = Object.freeze({
|
|
133
|
-
path: value.path,
|
|
134
|
-
status: value.superseded ? ("superseded" as const) : ("active" as const),
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
this.phase = {
|
|
138
|
-
current: reconcilePhase(recon.current),
|
|
139
|
-
advancedAt: recon.advancedAt,
|
|
140
|
-
summary: recon.summary,
|
|
141
|
-
artifacts,
|
|
142
|
-
backwardJumps: recon.backwardJumps ?? 0,
|
|
143
|
-
};
|
|
144
|
-
this.lastSaved = {};
|
|
145
|
-
this.askRounds = 0;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
handlers(): PipelineHandlers {
|
|
149
|
-
return {
|
|
150
|
-
saveArtifact: (kind, content) => this.handleSaveArtifact(kind, content),
|
|
151
|
-
advancePhase: (phase, summary) => this.handleAdvancePhase(phase, summary),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── save_artifact ──
|
|
156
|
-
|
|
157
|
-
private async handleSaveArtifact(kind: string, content: string): Promise<string> {
|
|
158
|
-
const stage = stageForArtifactKind(kind);
|
|
159
|
-
if (stage === undefined) {
|
|
160
|
-
return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
|
|
161
|
-
}
|
|
162
|
-
const current = this.currentPhase();
|
|
163
|
-
if (stage.phase !== current) {
|
|
164
|
-
return formatError(`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`);
|
|
165
|
-
}
|
|
166
|
-
const saved = saveArtifact(this.deps.runCwd, stage.artifactDir, kind, content);
|
|
167
|
-
if (!saved.ok) return formatError(saved.error);
|
|
168
|
-
|
|
169
|
-
// Preflight: run the SAME gate advance_phase will run, now instead of a turn later, and
|
|
170
|
-
// memoize the verdict so the transition does not re-read and re-gate the file.
|
|
171
|
-
const critique = critiqueArtifact(stage, content, saved.path, this.deps.runCwd);
|
|
172
|
-
this.lastSaved = {
|
|
173
|
-
...this.lastSaved,
|
|
174
|
-
[stage.phase]: Object.freeze({ path: saved.path, gateData: critique.gateData }),
|
|
175
|
-
};
|
|
176
|
-
if (critique.warnings.length > 0) {
|
|
177
|
-
this.warnings = Object.freeze([...this.warnings, ...critique.warnings]);
|
|
178
|
-
this.deps.emitter.emitWarnings(this.warnings);
|
|
179
|
-
}
|
|
180
|
-
return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ── advance_phase ──
|
|
184
|
-
|
|
185
|
-
private async handleAdvancePhase(phase: string, summary: string | undefined): Promise<string> {
|
|
186
|
-
const current = this.currentPhase();
|
|
187
|
-
const outcome = validatePhaseTransition(current, phase);
|
|
188
|
-
if (!outcome.ok) return formatError(outcome.error);
|
|
189
|
-
|
|
190
|
-
// Clarify interview gate: the engine counts serviced rounds itself, so the model cannot
|
|
191
|
-
// advance by merely claiming to have interviewed the user.
|
|
192
|
-
if (current === "clarify" && this.askRounds === 0) {
|
|
193
|
-
return formatError("clarify requires at least one ask_user_question round — interview the user before advancing");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const gated = this.gateCurrentPhase(current);
|
|
197
|
-
if (!gated.ok) return formatError(gated.error);
|
|
198
|
-
const { artifactPath, gateData } = gated;
|
|
199
|
-
|
|
200
|
-
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...(this.phase?.artifacts ?? {}) };
|
|
201
|
-
if (artifactPath !== undefined) {
|
|
202
|
-
nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
|
|
203
|
-
}
|
|
204
|
-
this.phase = {
|
|
205
|
-
current: outcome.phase,
|
|
206
|
-
advancedAt: this.deps.completedTurns(),
|
|
207
|
-
summary,
|
|
208
|
-
artifacts: nextArtifacts,
|
|
209
|
-
backwardJumps: this.phase?.backwardJumps ?? 0,
|
|
210
|
-
};
|
|
211
|
-
await this.deps.persistPhaseRow(this.phase, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
|
|
212
|
-
this.clearLastSaved(current);
|
|
213
|
-
this.askRounds = 0;
|
|
214
|
-
this.pendingReset = this.deps.resetHistoryForPhase(this.phase, { goal: this.goal });
|
|
215
|
-
return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Measure the current phase's latest save. Deliberately reads `lastSaved` only — falling
|
|
220
|
-
* back to `phase.artifacts` would let a stale path from before a loop-back pass the gate.
|
|
221
|
-
*/
|
|
222
|
-
private gateCurrentPhase(
|
|
223
|
-
current: Phase,
|
|
224
|
-
): { ok: true; artifactPath: string | undefined; gateData: StageGateData | undefined } | { ok: false; error: string } {
|
|
225
|
-
const stage = STAGES[current];
|
|
226
|
-
if (stage.artifactDir === "") return { ok: true, artifactPath: undefined, gateData: undefined };
|
|
227
|
-
|
|
228
|
-
const savedEntry = this.lastSaved[current];
|
|
229
|
-
const artifactPath = savedEntry?.path;
|
|
230
|
-
if (artifactPath === undefined) {
|
|
231
|
-
return {
|
|
232
|
-
ok: false,
|
|
233
|
-
error: `phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
if (savedEntry?.gateData !== undefined) {
|
|
237
|
-
return { ok: true, artifactPath, gateData: savedEntry.gateData };
|
|
238
|
-
}
|
|
239
|
-
// No memo (e.g. the file was written outside save_artifact) — read and gate it now.
|
|
240
|
-
const content = readArtifact(this.deps.runCwd, artifactPath);
|
|
241
|
-
if (!content.ok) return { ok: false, error: content.error };
|
|
242
|
-
const gate = stage.gate(content.value, artifactPath, this.deps.runCwd);
|
|
243
|
-
if (!gate.ok) return { ok: false, error: gate.error };
|
|
244
|
-
this.lastSaved = {
|
|
245
|
-
...this.lastSaved,
|
|
246
|
-
[current]: Object.freeze({ path: artifactPath, gateData: gate.value }),
|
|
247
|
-
};
|
|
248
|
-
return { ok: true, artifactPath, gateData: gate.value };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ── validate-phase finalize ──
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Decide what a finalize submitted during `validate` means. As with advance_phase, this
|
|
255
|
-
* measures THIS turn's validation save only, never `phase.artifacts`.
|
|
256
|
-
*/
|
|
257
|
-
async finalizeInValidate(final: string): Promise<ValidateOutcome> {
|
|
258
|
-
const phase = this.phase;
|
|
259
|
-
if (phase === undefined) return { kind: "accept" };
|
|
260
|
-
|
|
261
|
-
const vPath = this.lastSaved.validate?.path;
|
|
262
|
-
if (vPath === undefined) {
|
|
263
|
-
return {
|
|
264
|
-
kind: "reject",
|
|
265
|
-
error: formatError(
|
|
266
|
-
'finalize rejected — save the validation artifact first via save_artifact("validation", content) with status: ready, blockers_count, and verdict',
|
|
267
|
-
),
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
const content = readArtifact(this.deps.runCwd, vPath);
|
|
271
|
-
if (!content.ok) return { kind: "reject", error: formatError(content.error) };
|
|
272
|
-
|
|
273
|
-
const gate = STAGES.validate.gate(content.value, vPath, this.deps.runCwd);
|
|
274
|
-
if (!gate.ok) return { kind: "reject", error: formatError(gate.error) };
|
|
275
|
-
if (gate.value.kind !== "validation") {
|
|
276
|
-
return { kind: "reject", error: formatError("internal: validate gate did not return validation data") };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const { validation } = gate.value;
|
|
280
|
-
const route = routeAfterValidate(validation, phase.backwardJumps, this.deps.maxBackwardJumps);
|
|
281
|
-
if (route.kind === "halt") return { kind: "halt", report: `${route.reason}\n\n${final}` };
|
|
282
|
-
if (route.kind !== "loop-back") return { kind: "accept" };
|
|
283
|
-
|
|
284
|
-
// Loop back to blueprint. Prior artifacts are kept — the append-only journal marks the
|
|
285
|
-
// blueprint superseded by this validation rather than dropping it.
|
|
286
|
-
const prior = phase.artifacts.blueprint;
|
|
287
|
-
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
|
|
288
|
-
...phase.artifacts,
|
|
289
|
-
validate: Object.freeze({ path: vPath, status: "active" }),
|
|
290
|
-
};
|
|
291
|
-
if (prior !== undefined) {
|
|
292
|
-
nextArtifacts.blueprint = Object.freeze({ path: prior.path, status: "superseded", supersededBy: vPath });
|
|
293
|
-
}
|
|
294
|
-
this.phase = {
|
|
295
|
-
current: "blueprint",
|
|
296
|
-
advancedAt: this.deps.completedTurns(),
|
|
297
|
-
summary: `loop-back: ${validation.blockersCount} blocker(s)`,
|
|
298
|
-
artifacts: nextArtifacts,
|
|
299
|
-
backwardJumps: phase.backwardJumps + 1,
|
|
300
|
-
};
|
|
301
|
-
await this.deps.persistPhaseRow(this.phase, vPath, "validate", gate.value, prior?.path);
|
|
302
|
-
// Clear BOTH so the re-entered blueprint must produce a genuinely fresh plan and a fresh
|
|
303
|
-
// validation of it, rather than re-gating what was just rejected.
|
|
304
|
-
this.clearLastSaved("blueprint", "validate");
|
|
305
|
-
this.askRounds = 0;
|
|
306
|
-
this.pendingReset = undefined;
|
|
307
|
-
return { kind: "loop-back", history: this.deps.resetHistoryForPhase(this.phase, { goal: this.goal, validation }) };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
private currentPhase(): Phase {
|
|
311
|
-
return this.phase?.current ?? PHASES[0];
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private clearLastSaved(...phases: readonly Phase[]): void {
|
|
315
|
-
const next: Partial<Record<Phase, SavedArtifact>> = { ...this.lastSaved };
|
|
316
|
-
for (const phase of phases) delete next[phase];
|
|
317
|
-
this.lastSaved = next;
|
|
318
|
-
}
|
|
319
|
-
}
|