@tritard/waterbrother 0.6.6 → 0.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.6.6",
3
+ "version": "0.8.0",
4
4
  "description": "Waterbrother: Grok-powered coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -101,6 +101,8 @@ function buildSystemPrompt(profile, experienceMode = "standard", autonomyMode =
101
101
  if (executionContext.chosenOption) ctxLines.push(`Chosen approach: ${executionContext.chosenOption}`);
102
102
  if (executionContext.contractSummary) ctxLines.push(`Contract: ${executionContext.contractSummary}`);
103
103
  if (executionContext.phase) ctxLines.push(`Phase: ${executionContext.phase}. Execute the chosen approach — do not re-decide.`);
104
+ if (executionContext.plan) ctxLines.push(`Execution plan:\n${executionContext.plan}`);
105
+ if (executionContext.reminders) ctxLines.push(`Scope reminders:\n${executionContext.reminders}`);
104
106
  if (ctxLines.length > 0) base += `\n\nExecution context:\n${ctxLines.join("\n")}`;
105
107
  }
106
108
  if (!memoryBlock) return base;
package/src/cli.js CHANGED
@@ -19,6 +19,8 @@ import { runDecisionPass, runInventPass, formatDecisionForDisplay, formatDecisio
19
19
  import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow } from "./workflow.js";
20
20
  import { createPanelRenderer, buildPanelState } from "./panel.js";
21
21
  import { deriveTaskNameFromPrompt, nextActionsForState, routeNaturalInput } from "./router.js";
22
+ import { compressEpisode, saveEpisode, loadRecentEpisodes, findRelevantEpisodes, buildEpisodicMemoryBlock, buildReminderBlock } from "./episodic.js";
23
+ import { formatPlanForDisplay } from "./planner.js";
22
24
 
23
25
  const execFileAsync = promisify(execFile);
24
26
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -4033,6 +4035,21 @@ async function promptLoop(agent, session, context) {
4033
4035
  if (!context.runtime.projectMemory) {
4034
4036
  context.runtime.projectMemory = await readProjectMemory(context.cwd);
4035
4037
  }
4038
+
4039
+ // Load episodic memory and combine with project memory
4040
+ try {
4041
+ const recentEpisodes = await loadRecentEpisodes({ cwd: context.cwd, limit: 5 });
4042
+ if (recentEpisodes.length > 0) {
4043
+ const episodicBlock = buildEpisodicMemoryBlock(recentEpisodes);
4044
+ context.runtime.episodicMemory = episodicBlock;
4045
+ const fullMemory = [
4046
+ context.runtime.projectMemory?.promptText || "",
4047
+ episodicBlock
4048
+ ].filter(Boolean).join("\n\n");
4049
+ agent.setMemory(fullMemory);
4050
+ }
4051
+ } catch {}
4052
+
4036
4053
  if (!Array.isArray(context.runtime.lastSearchResults)) {
4037
4054
  context.runtime.lastSearchResults = [];
4038
4055
  }
@@ -5002,6 +5019,13 @@ async function promptLoop(agent, session, context) {
5002
5019
  continue;
5003
5020
  }
5004
5021
  try {
5022
+ // Save episodic memory before closing
5023
+ try {
5024
+ const receipt = context.runtime.lastReceipt || null;
5025
+ const episode = compressEpisode({ task, receipt });
5026
+ await saveEpisode({ cwd: context.cwd, episode });
5027
+ } catch {}
5028
+
5005
5029
  await closeTask({ cwd: context.cwd, taskId: task.id });
5006
5030
  clearTaskFromSession(currentSession);
5007
5031
  agent.toolRuntime.setTaskContext(null);
@@ -5117,6 +5141,29 @@ async function promptLoop(agent, session, context) {
5117
5141
 
5118
5142
  await maybeAutoCompactConversation({ agent, currentSession, context, pendingInput: buildPrompt });
5119
5143
 
5144
+ // Inject adaptive reminders from episodic memory
5145
+ try {
5146
+ const contractPaths = task.activeContract?.paths || [];
5147
+ const taskTags = [task.name, task.goal].filter(Boolean).join(" ").toLowerCase().split(/\s+/).filter((w) => w.length >= 3);
5148
+ const relevant = await findRelevantEpisodes({ cwd: context.cwd, filePatterns: contractPaths, tags: taskTags, limit: 3 });
5149
+ if (relevant.length > 0) {
5150
+ const reminders = buildReminderBlock({
5151
+ episodes: relevant,
5152
+ memoryText: context.runtime.projectMemory?.raw || "",
5153
+ contractPaths
5154
+ });
5155
+ if (reminders) {
5156
+ agent.setExecutionContext({
5157
+ taskName: task.name,
5158
+ chosenOption: task.chosenOption || null,
5159
+ contractSummary: task.activeContract?.summary || null,
5160
+ phase: "build",
5161
+ reminders
5162
+ });
5163
+ }
5164
+ }
5165
+ } catch {}
5166
+
5120
5167
  const turnSummary = { startedAt: Date.now(), tools: [], events: [{ at: Date.now(), name: "thinking" }] };
5121
5168
  const spinner = createProgressSpinner("building...");
5122
5169
  let lastProgressAt = Date.now();
@@ -5174,6 +5221,13 @@ async function promptLoop(agent, session, context) {
5174
5221
  markProgress();
5175
5222
  currentSession.runState = { state, detail: "", updatedAt: new Date().toISOString() };
5176
5223
  },
5224
+ onPlan(plan) {
5225
+ markProgress();
5226
+ spinner.stop();
5227
+ console.log(formatPlanForDisplay(plan));
5228
+ printRailTransition("executing");
5229
+ spinner.setLabel("executing plan...");
5230
+ },
5177
5231
  onAssistantDelta() { markProgress(); },
5178
5232
  onToolStart(toolCall) {
5179
5233
  markProgress();
package/src/config.js CHANGED
@@ -197,6 +197,7 @@ export function resolveRuntimeConfig(config, overrides = {}) {
197
197
  ? Boolean(config.panelEnabled)
198
198
  : true,
199
199
  decisionModel: overrides.decisionModel || config.decisionModel || "",
200
+ plannerModel: overrides.plannerModel || config.plannerModel || "",
200
201
  taskDefaults: normalizeTaskDefaults(
201
202
  overrides.taskDefaults !== undefined ? overrides.taskDefaults : config.taskDefaults
202
203
  ),
@@ -0,0 +1,279 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ const STOP_WORDS = new Set(["the", "a", "an", "in", "to", "for", "of", "on", "at", "by", "and", "or", "is", "it", "this", "that", "with", "from", "as", "be", "was", "are"]);
6
+ const MAX_INDEX_ENTRIES = 200;
7
+ const MAX_EPISODIC_PROMPT_CHARS = 2000;
8
+ const MAX_REMINDER_CHARS = 1500;
9
+ const MAX_FILES_PER_EPISODE = 50;
10
+
11
+ function memoryDir(cwd) {
12
+ return path.join(cwd, ".waterbrother", "memory");
13
+ }
14
+
15
+ function indexPath(cwd) {
16
+ return path.join(memoryDir(cwd), "index.json");
17
+ }
18
+
19
+ function episodePath(cwd, id) {
20
+ return path.join(memoryDir(cwd), `${id}.json`);
21
+ }
22
+
23
+ function slugify(name) {
24
+ return String(name || "")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-|-$/g, "")
28
+ .slice(0, 60);
29
+ }
30
+
31
+ function makeEpisodeId(taskName) {
32
+ const slug = slugify(taskName);
33
+ const rand = crypto.randomBytes(3).toString("hex");
34
+ return slug ? `ep_${slug}-${rand}` : `ep_${rand}`;
35
+ }
36
+
37
+ function deriveTags(text) {
38
+ return String(text || "")
39
+ .toLowerCase()
40
+ .split(/[\s/\\._\-:,;!?'"()[\]{}]+/)
41
+ .filter((w) => w.length >= 3 && !STOP_WORDS.has(w))
42
+ .filter((v, i, a) => a.indexOf(v) === i)
43
+ .slice(0, 20);
44
+ }
45
+
46
+ function deriveFilePatterns(files) {
47
+ const dirs = new Set();
48
+ for (const f of files) {
49
+ const dir = path.dirname(f).replace(/\\/g, "/");
50
+ if (dir && dir !== ".") dirs.add(`${dir}/**`);
51
+ }
52
+ return [...dirs];
53
+ }
54
+
55
+ async function readIndex(cwd) {
56
+ try {
57
+ const raw = await fs.readFile(indexPath(cwd), "utf8");
58
+ const parsed = JSON.parse(raw);
59
+ return Array.isArray(parsed) ? parsed : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ async function writeIndex(cwd, index) {
66
+ await fs.mkdir(memoryDir(cwd), { recursive: true });
67
+ await fs.writeFile(indexPath(cwd), `${JSON.stringify(index, null, 2)}\n`, "utf8");
68
+ }
69
+
70
+ export function compressEpisode({ task, receipt }) {
71
+ const id = makeEpisodeId(task.name || task.id);
72
+ const filesChanged = (receipt?.changedFiles || []).slice(0, MAX_FILES_PER_EPISODE);
73
+ const filePatterns = deriveFilePatterns(filesChanged);
74
+
75
+ // Key facts
76
+ const keyFacts = [];
77
+ if (task.goal) keyFacts.push(`Goal: ${task.goal}`);
78
+ if (task.chosenOption) {
79
+ const option = task.lastDecision?.options?.find((o) => o.id === task.chosenOption);
80
+ keyFacts.push(`Chose: ${task.chosenOption}${option?.title ? ` — ${option.title}` : ""}`);
81
+ }
82
+ if (receipt?.diffStat) {
83
+ const lastLine = receipt.diffStat.split("\n").pop()?.trim();
84
+ if (lastLine) keyFacts.push(`Diff: ${lastLine}`);
85
+ }
86
+ if (Array.isArray(receipt?.verification)) {
87
+ for (const v of receipt.verification) {
88
+ keyFacts.push(`Verify ${v.command}: ${v.ok ? "passed" : "FAILED"}`);
89
+ }
90
+ }
91
+
92
+ // Warnings and concerns
93
+ const warnings = [];
94
+ const sentinelConcerns = [];
95
+ if (receipt?.review?.concerns?.length) {
96
+ sentinelConcerns.push(...receipt.review.concerns);
97
+ }
98
+ if (receipt?.challenge?.concerns?.length) {
99
+ for (const c of receipt.challenge.concerns) {
100
+ if (!sentinelConcerns.includes(c)) sentinelConcerns.push(c);
101
+ }
102
+ }
103
+ if (Array.isArray(receipt?.verification)) {
104
+ for (const v of receipt.verification) {
105
+ if (!v.ok) warnings.push(`Verification failed: ${v.command}`);
106
+ }
107
+ }
108
+
109
+ // Outcome
110
+ let outcome = "closed-empty";
111
+ if (receipt?.mutated) {
112
+ outcome = task.accepted ? "accepted" : "closed-unaccepted";
113
+ }
114
+
115
+ // Tags from task name, goal, and file paths
116
+ const tagSource = [task.name, task.goal, ...filesChanged].join(" ");
117
+ const tags = deriveTags(tagSource);
118
+
119
+ return {
120
+ id,
121
+ taskId: task.id,
122
+ taskName: task.name || "",
123
+ closedAt: new Date().toISOString(),
124
+ goal: task.goal || "",
125
+ chosenOption: task.chosenOption || null,
126
+ outcome,
127
+ filesChanged,
128
+ filePatterns,
129
+ keyFacts: keyFacts.slice(0, 8),
130
+ warnings: warnings.slice(0, 5),
131
+ sentinelConcerns: sentinelConcerns.slice(0, 5),
132
+ tags
133
+ };
134
+ }
135
+
136
+ export async function saveEpisode({ cwd, episode }) {
137
+ await fs.mkdir(memoryDir(cwd), { recursive: true });
138
+ await fs.writeFile(episodePath(cwd, episode.id), `${JSON.stringify(episode, null, 2)}\n`, "utf8");
139
+
140
+ // Update index
141
+ const index = await readIndex(cwd);
142
+ const entry = {
143
+ id: episode.id,
144
+ taskName: episode.taskName,
145
+ closedAt: episode.closedAt,
146
+ tags: episode.tags,
147
+ filePatterns: episode.filePatterns,
148
+ outcome: episode.outcome
149
+ };
150
+ index.unshift(entry);
151
+ if (index.length > MAX_INDEX_ENTRIES) index.length = MAX_INDEX_ENTRIES;
152
+ await writeIndex(cwd, index);
153
+ }
154
+
155
+ export async function loadRecentEpisodes({ cwd, limit = 5 }) {
156
+ const index = await readIndex(cwd);
157
+ const recent = index.slice(0, limit);
158
+ const episodes = [];
159
+ for (const entry of recent) {
160
+ try {
161
+ const raw = await fs.readFile(episodePath(cwd, entry.id), "utf8");
162
+ episodes.push(JSON.parse(raw));
163
+ } catch {
164
+ // Skip missing/corrupt episodes
165
+ }
166
+ }
167
+ return episodes;
168
+ }
169
+
170
+ export async function findRelevantEpisodes({ cwd, filePatterns = [], tags = [], limit = 5 }) {
171
+ const index = await readIndex(cwd);
172
+ if (index.length === 0) return [];
173
+
174
+ const queryDirs = filePatterns.map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/"));
175
+ const queryTags = new Set(tags.map((t) => t.toLowerCase()));
176
+
177
+ const scored = [];
178
+ for (const entry of index) {
179
+ let score = 0;
180
+
181
+ // File pattern overlap
182
+ if (queryDirs.length > 0 && Array.isArray(entry.filePatterns)) {
183
+ for (const ep of entry.filePatterns) {
184
+ const epDir = ep.replace(/\/?\*\*$/, "").replace(/\\/g, "/");
185
+ for (const qd of queryDirs) {
186
+ if (epDir.startsWith(qd) || qd.startsWith(epDir)) {
187
+ score += 3;
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ // Tag overlap
195
+ if (queryTags.size > 0 && Array.isArray(entry.tags)) {
196
+ for (const t of entry.tags) {
197
+ if (queryTags.has(t)) score += 1;
198
+ }
199
+ }
200
+
201
+ if (score > 0) scored.push({ entry, score });
202
+ }
203
+
204
+ scored.sort((a, b) => b.score - a.score || new Date(b.entry.closedAt) - new Date(a.entry.closedAt));
205
+ const top = scored.slice(0, limit);
206
+
207
+ const episodes = [];
208
+ for (const { entry } of top) {
209
+ try {
210
+ const raw = await fs.readFile(episodePath(cwd, entry.id), "utf8");
211
+ episodes.push(JSON.parse(raw));
212
+ } catch {}
213
+ }
214
+ return episodes;
215
+ }
216
+
217
+ export function buildEpisodicMemoryBlock(episodes) {
218
+ if (!episodes || episodes.length === 0) return "";
219
+ const lines = ["Recent work in this project:"];
220
+ let chars = lines[0].length;
221
+
222
+ for (const ep of episodes) {
223
+ const date = ep.closedAt ? ep.closedAt.slice(0, 10) : "unknown";
224
+ const parts = [`[${date}] "${ep.taskName}" (${ep.outcome})`];
225
+ if (ep.keyFacts?.length > 0) parts.push(ep.keyFacts[0]);
226
+ if (ep.warnings?.length > 0) parts.push(`Warning: ${ep.warnings[0]}`);
227
+ if (ep.sentinelConcerns?.length > 0) parts.push(`Sentinel: ${ep.sentinelConcerns[0]}`);
228
+ const line = `- ${parts.join(". ")}`;
229
+ if (chars + line.length + 1 > MAX_EPISODIC_PROMPT_CHARS) break;
230
+ lines.push(line);
231
+ chars += line.length + 1;
232
+ }
233
+
234
+ return lines.join("\n");
235
+ }
236
+
237
+ export function buildReminderBlock({ episodes = [], memoryText = "", contractPaths = [] }) {
238
+ const contractDirs = contractPaths.map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/").toLowerCase());
239
+ const reminders = [];
240
+ let chars = 0;
241
+
242
+ // From episodes: warnings and concerns for overlapping scopes
243
+ for (const ep of episodes) {
244
+ const epDirs = (ep.filePatterns || []).map((p) => p.replace(/\/?\*\*$/, "").replace(/\\/g, "/").toLowerCase());
245
+ const overlaps = contractDirs.length === 0 || epDirs.some((ed) => contractDirs.some((cd) => ed.startsWith(cd) || cd.startsWith(ed)));
246
+ if (!overlaps) continue;
247
+
248
+ for (const w of (ep.warnings || [])) {
249
+ const line = `[from "${ep.taskName}"]: ${w}`;
250
+ if (chars + line.length > MAX_REMINDER_CHARS) break;
251
+ reminders.push(line);
252
+ chars += line.length;
253
+ }
254
+ for (const c of (ep.sentinelConcerns || [])) {
255
+ const line = `[from "${ep.taskName}"]: Sentinel flagged: ${c}`;
256
+ if (chars + line.length > MAX_REMINDER_CHARS) break;
257
+ reminders.push(line);
258
+ chars += line.length;
259
+ }
260
+ }
261
+
262
+ // From WATERBROTHER.md: lines mentioning paths in contract scope
263
+ if (memoryText && contractDirs.length > 0) {
264
+ const memLines = memoryText.split("\n");
265
+ for (const ml of memLines) {
266
+ const lower = ml.toLowerCase();
267
+ const relevant = contractDirs.some((cd) => lower.includes(cd.split("/").pop()));
268
+ if (relevant && ml.trim().length > 5) {
269
+ const line = `[from WATERBROTHER.md]: ${ml.trim()}`;
270
+ if (chars + line.length > MAX_REMINDER_CHARS) break;
271
+ reminders.push(line);
272
+ chars += line.length;
273
+ }
274
+ }
275
+ }
276
+
277
+ if (reminders.length === 0) return "";
278
+ return `Reminders for this scope:\n${reminders.join("\n")}`;
279
+ }
package/src/planner.js ADDED
@@ -0,0 +1,128 @@
1
+ import { createJsonCompletion } from "./grok-client.js";
2
+
3
+ const PLANNER_SCHEMA = `Respond with ONLY a JSON object matching this schema:
4
+ {
5
+ "summary": "one-line summary of what will be done",
6
+ "steps": [
7
+ {
8
+ "action": "read|edit|create|delete|run",
9
+ "target": "file path or shell command",
10
+ "description": "what this step does and why"
11
+ }
12
+ ],
13
+ "risks": ["potential issues to watch for"],
14
+ "estimate": "rough scope — e.g. ~30 lines, 2 files"
15
+ }`;
16
+
17
+ const PLANNER_SYSTEM_PROMPT = `You are a senior engineer creating an implementation plan. You do NOT write code — you plan.
18
+
19
+ Your job is to:
20
+ 1. Decompose the task into concrete, ordered steps
21
+ 2. Specify exactly which files to read, edit, create, or delete
22
+ 3. Specify which shell commands to run (tests, builds, etc.)
23
+ 4. Flag risks the executor should watch for
24
+ 5. Keep the plan tight — no unnecessary steps
25
+
26
+ Rules:
27
+ - Each step must have an action (read, edit, create, delete, run), a target (file path or command), and a description
28
+ - Order matters — read before edit, edit before test
29
+ - Include verification steps (run tests, lint, etc.)
30
+ - Keep descriptions to one sentence
31
+ - Do not include markdown, code fences, or text outside the JSON
32
+
33
+ ${PLANNER_SCHEMA}`;
34
+
35
+ function buildPlannerPrompt({ task, goal, contract, memory, episodicContext }) {
36
+ const parts = [];
37
+ if (task?.name) parts.push(`Task: ${task.name}`);
38
+ if (goal) parts.push(`Goal: ${goal}`);
39
+ if (task?.chosenOption) {
40
+ const option = task.lastDecision?.options?.find((o) => o.id === task.chosenOption);
41
+ if (option) parts.push(`Chosen approach: ${option.title} — ${option.summary}`);
42
+ }
43
+ if (contract?.paths?.length) parts.push(`Contract scope: ${contract.paths.join(", ")}`);
44
+ if (contract?.commands?.length) parts.push(`Verification commands: ${contract.commands.join(", ")}`);
45
+ if (memory) parts.push(`Project context:\n${memory}`);
46
+ if (episodicContext) parts.push(`Recent history:\n${episodicContext}`);
47
+ parts.push("Create a step-by-step implementation plan as JSON.");
48
+ return parts.join("\n\n");
49
+ }
50
+
51
+ export function normalizePlan(plan) {
52
+ if (!plan || typeof plan !== "object") return null;
53
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
54
+ return {
55
+ summary: String(plan.summary || "").trim(),
56
+ steps: steps.map((s, i) => ({
57
+ number: i + 1,
58
+ action: ["read", "edit", "create", "delete", "run"].includes(String(s.action || "").trim())
59
+ ? String(s.action).trim()
60
+ : "read",
61
+ target: String(s.target || "").trim(),
62
+ description: String(s.description || "").trim()
63
+ })),
64
+ risks: Array.isArray(plan.risks) ? plan.risks.map(String) : [],
65
+ estimate: String(plan.estimate || "").trim()
66
+ };
67
+ }
68
+
69
+ export async function runPlannerPass({ apiKey, baseUrl, model, task, goal, contract, memory, episodicContext, signal }) {
70
+ if (!goal && !task?.name) throw new Error("goal or task name required for planner");
71
+
72
+ const messages = [
73
+ { role: "system", content: PLANNER_SYSTEM_PROMPT },
74
+ { role: "user", content: buildPlannerPrompt({ task, goal, contract, memory, episodicContext }) }
75
+ ];
76
+
77
+ const completion = await createJsonCompletion({
78
+ apiKey,
79
+ baseUrl,
80
+ model,
81
+ messages,
82
+ temperature: 0.2,
83
+ signal
84
+ });
85
+
86
+ const plan = normalizePlan(completion.json);
87
+ if (!plan || plan.steps.length === 0) {
88
+ throw new Error("Planner returned no steps");
89
+ }
90
+
91
+ return { plan, usage: completion.usage || null };
92
+ }
93
+
94
+ export function formatPlanForDisplay(plan) {
95
+ if (!plan) return "No plan available.";
96
+ const lines = [];
97
+ if (plan.summary) lines.push(plan.summary);
98
+ lines.push("");
99
+ for (const step of plan.steps) {
100
+ const icon = step.action === "read" ? "📖" : step.action === "edit" ? "✏️" : step.action === "create" ? "📄" : step.action === "delete" ? "🗑️" : step.action === "run" ? "▶" : "•";
101
+ lines.push(` ${step.number}. ${icon} ${step.action} ${step.target}`);
102
+ if (step.description) lines.push(` ${step.description}`);
103
+ }
104
+ if (plan.risks.length > 0) {
105
+ lines.push("");
106
+ for (const risk of plan.risks) {
107
+ lines.push(` ⚠ ${risk}`);
108
+ }
109
+ }
110
+ if (plan.estimate) lines.push(`\n ${plan.estimate}`);
111
+ return lines.join("\n");
112
+ }
113
+
114
+ export function formatPlanForExecutor(plan) {
115
+ if (!plan || !plan.steps?.length) return "";
116
+ const lines = ["Implementation plan — follow these steps in order:"];
117
+ for (const step of plan.steps) {
118
+ lines.push(`${step.number}. [${step.action}] ${step.target} — ${step.description}`);
119
+ }
120
+ if (plan.risks.length > 0) {
121
+ lines.push("\nRisks to watch for:");
122
+ for (const risk of plan.risks) {
123
+ lines.push(`- ${risk}`);
124
+ }
125
+ }
126
+ lines.push("\nExecute each step. Do not skip steps or improvise beyond the plan.");
127
+ return lines.join("\n");
128
+ }
package/src/workflow.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createTask, findTaskByName, saveTask, slugify } from "./task-store.js";
2
2
  import { computeImpactMap, summarizeImpactMap } from "./impact.js";
3
3
  import { reviewTurn, challengeReceipt } from "./reviewer.js";
4
+ import { runPlannerPass, formatPlanForExecutor, formatPlanForDisplay } from "./planner.js";
4
5
 
5
6
  export async function runBuildWorkflow({
6
7
  agent,
@@ -12,13 +13,44 @@ export async function runBuildWorkflow({
12
13
  if (!task) throw new Error("no active task");
13
14
  if (!promptText) throw new Error("build requires a prompt");
14
15
 
16
+ // Planner/Executor split: if plannerModel is configured, run planner first
17
+ const plannerModel = context.runtime?.plannerModel;
18
+ let planBlock = "";
19
+ if (plannerModel) {
20
+ try {
21
+ if (handlers.onStateChange) handlers.onStateChange("planning");
22
+ const { plan } = await runPlannerPass({
23
+ apiKey: context.runtime.apiKey,
24
+ baseUrl: context.runtime.baseUrl,
25
+ model: plannerModel,
26
+ task,
27
+ goal: promptText,
28
+ contract: task.activeContract || null,
29
+ memory: context.runtime.projectMemory?.promptText || "",
30
+ episodicContext: context.runtime.episodicMemory || "",
31
+ signal: handlers.signal
32
+ });
33
+ task.lastPlan = plan;
34
+ planBlock = formatPlanForExecutor(plan);
35
+ // Show the plan to the user
36
+ if (handlers.onPlan) {
37
+ handlers.onPlan(plan);
38
+ }
39
+ } catch (error) {
40
+ // Planner failure is non-fatal — fall back to unplanned execution
41
+ planBlock = "";
42
+ }
43
+ }
44
+
15
45
  // Inject execution context into agent system prompt
16
- agent.setExecutionContext({
46
+ const executionCtx = {
17
47
  taskName: task.name,
18
48
  chosenOption: task.chosenOption || null,
19
49
  contractSummary: task.activeContract?.summary || null,
20
50
  phase: "build"
21
- });
51
+ };
52
+ if (planBlock) executionCtx.plan = planBlock;
53
+ agent.setExecutionContext(executionCtx);
22
54
 
23
55
  // Pre-seed contract if task has one
24
56
  if (task.activeContract) {
@@ -35,8 +67,13 @@ export async function runBuildWorkflow({
35
67
  });
36
68
  }
37
69
 
70
+ // Build prompt: prepend plan if available
71
+ const executorPrompt = planBlock
72
+ ? `${planBlock}\n\n---\n\nNow execute: ${promptText}`
73
+ : promptText;
74
+
38
75
  // Run the turn
39
- const response = await agent.runBuildTurn(promptText, handlers);
76
+ const response = await agent.runBuildTurn(executorPrompt, handlers);
40
77
 
41
78
  // Complete turn and get receipt
42
79
  const receipt = await agent.toolRuntime.completeTurn({ signal: handlers.signal });