@tritard/waterbrother 0.5.13 → 0.6.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.5.13",
3
+ "version": "0.6.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/cli.js CHANGED
@@ -15,9 +15,10 @@ import { AUTONOMY_MODES, buildOperatorIdentity, EXPERIENCE_MODES, modeDefaults,
15
15
  import { computeImpactMap } from "./impact.js";
16
16
  import { reviewTurn } from "./reviewer.js";
17
17
  import { loadTask, saveTask, listTasks, setActiveTask, getActiveTask, closeTask } from "./task-store.js";
18
- import { runDecisionPass, formatDecisionForDisplay } from "./decider.js";
18
+ import { runDecisionPass, runInventPass, formatDecisionForDisplay, formatDecisionCompact, formatDecisionDetail } from "./decider.js";
19
19
  import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow } from "./workflow.js";
20
20
  import { createPanelRenderer, buildPanelState } from "./panel.js";
21
+ import { deriveTaskNameFromPrompt, nextActionsForState, routeNaturalInput } from "./router.js";
21
22
 
22
23
  const execFileAsync = promisify(execFile);
23
24
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -1633,28 +1634,6 @@ async function pathExists(targetPath) {
1633
1634
  }
1634
1635
  }
1635
1636
 
1636
- function detectCasualInput(line) {
1637
- const text = line.trim().toLowerCase();
1638
- const words = text.split(/\s+/);
1639
- // Short messages (1-4 words) that don't look like commands or code tasks
1640
- if (words.length > 8) return false;
1641
- // Greetings and casual phrases
1642
- const casualPatterns = [
1643
- /^(hey|hi|hello|sup|yo|what'?s? up|howdy|hola|heya|hiya|good (morning|evening|afternoon|night))[\s!.?]*$/,
1644
- /^(thanks|thank you|thx|ty|cheers|cool|nice|ok|okay|sure|yep|yup|nope|nah|lol|lmao|haha|heh|hmm|bruh|bro|dude)[\s!.?]*$/,
1645
- /^(how are you|how'?s? it going|what'?s? good|what'?s? new|how do you do)[\s!.?]*$/,
1646
- /^(bye|goodbye|see ya|later|peace|gn|good night|ttyl|cya)[\s!.?]*$/,
1647
- /^(who are you|what are you|tell me about yourself)[\s!.?]*$/,
1648
- ];
1649
- for (const pattern of casualPatterns) {
1650
- if (pattern.test(text)) return true;
1651
- }
1652
- // Very short non-code messages (1-3 words, no code-like tokens)
1653
- if (words.length <= 3 && !/[./\\{}()\[\]<>=;:|`$@#]/.test(text) && !/\b(fix|build|create|add|remove|delete|update|install|run|test|write|read|edit|debug|deploy|commit|push|pull|merge|refactor|implement|check)\b/.test(text)) {
1654
- return true;
1655
- }
1656
- return false;
1657
- }
1658
1637
 
1659
1638
  async function detectDroppedImageInput(line, cwd) {
1660
1639
  const tokens = tokenizeShellInput(line);
@@ -4087,9 +4066,240 @@ async function promptLoop(agent, session, context) {
4087
4066
  cwd: context.cwd,
4088
4067
  task,
4089
4068
  agent,
4090
- receipt
4069
+ receipt,
4070
+ runtime: context.runtime
4091
4071
  }));
4092
4072
  }
4073
+
4074
+ function setCockpitState({ pass = null, summary = null, actions = null } = {}) {
4075
+ if (pass) context.runtime.activePass = String(pass).toUpperCase();
4076
+ if (summary !== null) context.runtime.panelSummary = summary;
4077
+ if (actions) context.runtime.actionHints = actions;
4078
+ const task = context.runtime.activeTask;
4079
+ if (task && pass) task.lastPass = String(pass).toUpperCase();
4080
+ updatePanel();
4081
+ }
4082
+
4083
+ function renderCompactDecisionBlock(task, decision, { invent = false } = {}) {
4084
+ const mode = agent.getExperienceMode();
4085
+ const title = task?.name || decision?.goal || (invent ? "invent" : "decision");
4086
+ console.log(formatDecisionCompact(decision, { mode, title }));
4087
+ const actions = decision.options.map((_, i) => String(i + 1));
4088
+ actions.push("go");
4089
+ if (decision.options.length >= 2) actions.push("tell me more about 2");
4090
+ setCockpitState({
4091
+ pass: invent ? "INVENT" : "DECIDE",
4092
+ summary: invent ? "divergent options ready" : "options ready",
4093
+ actions
4094
+ });
4095
+ }
4096
+
4097
+ function renderDecisionDetailBlock(task, decision, optionIndex) {
4098
+ console.log(formatDecisionDetail(decision, optionIndex));
4099
+ const actions = decision.options.map((_, i) => String(i + 1));
4100
+ actions.push("go");
4101
+ setCockpitState({ pass: task?.lastPass || "DECIDE", summary: `details for option ${optionIndex}`, actions });
4102
+ }
4103
+
4104
+ function renderReviewCockpit(task, receipt) {
4105
+ const concerns = [];
4106
+ if (receipt?.review?.concerns?.length) concerns.push(...receipt.review.concerns);
4107
+ if (receipt?.challenge?.concerns?.length) {
4108
+ for (const c of receipt.challenge.concerns) if (!concerns.includes(c)) concerns.push(c);
4109
+ }
4110
+ const verificationFailed = Array.isArray(receipt?.verification) && receipt.verification.some((v) => !v.ok);
4111
+ const blocked = verificationFailed || receipt?.review?.verdict === "block";
4112
+ if (concerns.length === 0 && !blocked) {
4113
+ console.log(`${green("✓")} no issues found`);
4114
+ console.log(dim("accept · redo · challenge harder · ship it"));
4115
+ setCockpitState({ pass: "CHALLENGE", summary: "no issues found", actions: ["accept", "redo", "challenge harder", "ship it"] });
4116
+ return;
4117
+ }
4118
+ for (const concern of concerns.slice(0, 3)) {
4119
+ console.log(`${yellow("⚠")} ${concern}`);
4120
+ }
4121
+ console.log(dim(blocked ? "fix these · ignore · challenge harder" : "fix these · ignore · challenge harder · ship it"));
4122
+ setCockpitState({ pass: "CHALLENGE", summary: `${concerns.length || 1} issue${(concerns.length || 1) === 1 ? "" : "s"} found`, actions: blocked ? ["fix these", "ignore", "challenge harder"] : ["fix these", "ignore", "challenge harder", "ship it"] });
4123
+ }
4124
+
4125
+ function latestReviewConcerns(receipt) {
4126
+ const items = [];
4127
+ if (receipt?.review?.concerns?.length) items.push(...receipt.review.concerns);
4128
+ if (receipt?.challenge?.concerns?.length) {
4129
+ for (const c of receipt.challenge.concerns) if (!items.includes(c)) items.push(c);
4130
+ }
4131
+ return items;
4132
+ }
4133
+
4134
+ function canShipReceipt(receipt) {
4135
+ if (!receipt) return { ok: false, reason: "no receipt available" };
4136
+ if (Array.isArray(receipt.verification) && receipt.verification.some((v) => !v.ok)) {
4137
+ return { ok: false, reason: "verification has failing checks" };
4138
+ }
4139
+ if (receipt.review?.verdict === "block") {
4140
+ return { ok: false, reason: "sentinel marked this receipt as blocking" };
4141
+ }
4142
+ return { ok: true, reason: "" };
4143
+ }
4144
+
4145
+ async function ensureTaskFromNaturalInput(line) {
4146
+ const existing = context.runtime.activeTask;
4147
+ if (existing) return existing;
4148
+ const taskName = deriveTaskNameFromPrompt(line);
4149
+ const branchPrefix = context.runtime.taskDefaults?.branchPrefix || "wb/";
4150
+ const { task } = await startFeatureTask({
4151
+ cwd: context.cwd,
4152
+ name: taskName,
4153
+ sessionId: currentSession.id,
4154
+ mode: agent.getExperienceMode(),
4155
+ autonomy: agent.getAutonomyMode(),
4156
+ branchPrefix
4157
+ });
4158
+ attachTaskToSession(currentSession, task.id);
4159
+ context.runtime.activeTask = task;
4160
+ agent.toolRuntime.setTaskContext({ taskId: task.id, taskName: task.name });
4161
+ await saveCurrentSession(currentSession, agent);
4162
+ return task;
4163
+ }
4164
+
4165
+ async function runNaturalDecision({ task, goal, invent = false }) {
4166
+ const spinner = createProgressSpinner(invent ? "thinking wider..." : "running decision pass...");
4167
+ try {
4168
+ const decisionModel = context.runtime.decisionModel || agent.getModel();
4169
+ const runner = invent ? runInventPass : runDecisionPass;
4170
+ const { decision } = await runner({
4171
+ apiKey: context.runtime.apiKey,
4172
+ baseUrl: context.runtime.baseUrl,
4173
+ model: decisionModel,
4174
+ goal,
4175
+ taskName: task.name,
4176
+ memory: context.runtime.projectMemory?.promptText || "",
4177
+ signal: null
4178
+ });
4179
+ spinner.stop();
4180
+ task.goal = goal;
4181
+ task.decisionId = `dec_${Date.now()}`;
4182
+ task.lastDecision = decision;
4183
+ task.lastDecisionType = invent ? "invent" : "decide";
4184
+ task.state = "decision-ready";
4185
+ task.lastPass = invent ? "INVENT" : "DECIDE";
4186
+ await saveTask({ cwd: context.cwd, task });
4187
+ context.runtime.activeTask = task;
4188
+ renderCompactDecisionBlock(task, decision, { invent });
4189
+ return decision;
4190
+ } catch (error) {
4191
+ spinner.stop();
4192
+ throw error;
4193
+ }
4194
+ }
4195
+
4196
+ async function chooseDecisionOption(task, optionIndexOrId) {
4197
+ if (!task?.lastDecision) throw new Error("no decision available");
4198
+ let option = null;
4199
+ if (typeof optionIndexOrId === "number") {
4200
+ option = task.lastDecision.options[Math.max(0, optionIndexOrId - 1)] || null;
4201
+ } else {
4202
+ const choiceId = String(optionIndexOrId || "").trim().toLowerCase();
4203
+ option = task.lastDecision.options.find((o) => o.id.toLowerCase() === choiceId) || null;
4204
+ }
4205
+ if (!option) throw new Error("option not found");
4206
+ task.chosenOption = option.id;
4207
+ task.activeContract = {
4208
+ summary: `${task.name}: ${option.title}`,
4209
+ paths: option.scope?.paths || [],
4210
+ commands: option.scope?.commands || [],
4211
+ verification: option.scope?.commands || [],
4212
+ risk: option.risk || "medium"
4213
+ };
4214
+ task.state = "build-ready";
4215
+ task.lastPass = task.lastDecisionType === "invent" ? "INVENT" : "DECIDE";
4216
+ agent.toolRuntime.setCurrentContract(task.activeContract);
4217
+ agent.toolRuntime.setTaskContext({ taskId: task.id, taskName: task.name, decisionId: task.decisionId, chosenOption: option.id });
4218
+ await saveTask({ cwd: context.cwd, task });
4219
+ context.runtime.activeTask = task;
4220
+ setCockpitState({ pass: task.lastPass || "DECIDE", summary: `option locked: ${option.title}`, actions: ["build it", "check it", "think deeper"] });
4221
+ console.log(`${cyan("locked")} ${option.title}`);
4222
+ return option;
4223
+ }
4224
+
4225
+ async function acceptLatestReceipt(task, { natural = false } = {}) {
4226
+ if (!task) throw new Error("no active task");
4227
+ if (!task.latestReceiptId) throw new Error("no receipt to accept");
4228
+ const receipt = context.runtime.lastReceipt || await agent.toolRuntime.readReceipt(task.latestReceiptId);
4229
+ const gate = canShipReceipt(receipt);
4230
+ if (!gate.ok) throw new Error(gate.reason);
4231
+ await agent.toolRuntime.markReceiptAccepted(task.latestReceiptId);
4232
+ task.accepted = true;
4233
+ task.state = "accepted";
4234
+ task.lastPass = "CHALLENGE";
4235
+ await saveTask({ cwd: context.cwd, task });
4236
+ context.runtime.activeTask = task;
4237
+ setCockpitState({ pass: "CHALLENGE", summary: natural ? "accepted — ready to close or ship" : "receipt accepted", actions: ["close", "start next task"] });
4238
+ console.log(`receipt ${task.latestReceiptId} accepted`);
4239
+ }
4240
+
4241
+ async function handleNaturalInput(line) {
4242
+ const routed = routeNaturalInput(line, { task: context.runtime.activeTask });
4243
+ if (!routed || routed.kind === "none" || routed.kind === "chat") return false;
4244
+
4245
+ try {
4246
+ if (routed.kind === "start-work") {
4247
+ const task = await ensureTaskFromNaturalInput(line);
4248
+ await runNaturalDecision({ task, goal: line, invent: false });
4249
+ return true;
4250
+ }
4251
+
4252
+ const task = context.runtime.activeTask;
4253
+ if (!task) return false;
4254
+
4255
+ if (routed.kind === "decision-detail") {
4256
+ renderDecisionDetailBlock(task, task.lastDecision, routed.optionIndex);
4257
+ return true;
4258
+ }
4259
+
4260
+ if (routed.kind === "choose") {
4261
+ await chooseDecisionOption(task, routed.optionIndex);
4262
+ return true;
4263
+ }
4264
+
4265
+ if (routed.kind === "choose-recommended-and-build") {
4266
+ const pick = task.lastDecision?.recommendation || 1;
4267
+ await chooseDecisionOption(task, pick);
4268
+ line = "/build";
4269
+ } else if (routed.kind === "decide") {
4270
+ await runNaturalDecision({ task, goal: line, invent: false });
4271
+ return true;
4272
+ } else if (routed.kind === "invent") {
4273
+ await runNaturalDecision({ task, goal: line, invent: true });
4274
+ return true;
4275
+ } else if (routed.kind === "accept") {
4276
+ await acceptLatestReceipt(task, { natural: true });
4277
+ return true;
4278
+ } else if (routed.kind === "fix-review-findings") {
4279
+ const receipt = context.runtime.lastReceipt || await agent.toolRuntime.readReceipt(task.latestReceiptId || "last");
4280
+ const concerns = latestReviewConcerns(receipt);
4281
+ const prompt = concerns.length > 0
4282
+ ? `Fix these review findings for task ${task.name}: ${concerns.join("; ")}`
4283
+ : `Redo the last build for task ${task.name}, tightening any obvious gaps.`;
4284
+ return { rewrittenLine: `/build ${prompt}` };
4285
+ } else if (routed.kind === "build" && (task.state === "build-ready" || task.state === "review-ready")) {
4286
+ line = "/build";
4287
+ } else if (routed.kind === "challenge") {
4288
+ line = "/challenge";
4289
+ } else if (routed.kind === "ignore") {
4290
+ console.log("ignored for now — you can say ship it, build it, or challenge harder");
4291
+ setCockpitState({ pass: "CHALLENGE", summary: "review concerns acknowledged", actions: ["fix these", "challenge harder", "ship it"] });
4292
+ return true;
4293
+ } else {
4294
+ return false;
4295
+ }
4296
+ } catch (error) {
4297
+ console.log(error instanceof Error ? error.message : String(error));
4298
+ return true;
4299
+ }
4300
+
4301
+ return { rewrittenLine: line };
4302
+ }
4093
4303
  if (context.runtime.activeTask) {
4094
4304
  updatePanel();
4095
4305
  }
@@ -4118,7 +4328,7 @@ async function promptLoop(agent, session, context) {
4118
4328
  }
4119
4329
 
4120
4330
  while (true) {
4121
- const line = normalizeInteractiveInput(
4331
+ let line = normalizeInteractiveInput(
4122
4332
  await readInteractiveLine({
4123
4333
  getFooterText(inputBuffer) {
4124
4334
  return buildInteractiveFooter({
@@ -5564,26 +5774,56 @@ async function promptLoop(agent, session, context) {
5564
5774
  continue;
5565
5775
  }
5566
5776
 
5567
- // Intent detection: casual/conversational input runs without tools
5568
- const isCasual = detectCasualInput(line);
5569
- if (isCasual) {
5570
- const prevTools = agent.enableTools;
5571
- agent.enableTools = false;
5572
- try {
5573
- await runTextTurnInteractive({
5574
- agent,
5575
- currentSession,
5576
- context,
5577
- promptText: line,
5578
- pendingInput: line,
5579
- spinnerLabel: "thinking..."
5580
- });
5581
- } finally {
5582
- agent.enableTools = prevTools;
5777
+ // Natural language intent routing
5778
+ const naturalResult = await handleNaturalInput(line);
5779
+ if (naturalResult === true) {
5780
+ continue;
5781
+ }
5782
+ if (naturalResult && typeof naturalResult === "object" && naturalResult.rewrittenLine) {
5783
+ // Rewritten to a slash command — execute it directly as if typed
5784
+ const rewritten = naturalResult.rewrittenLine;
5785
+ if (rewritten === "/build" || rewritten.startsWith("/build ")) {
5786
+ // Trigger the /build handler with the rewritten prompt
5787
+ line = rewritten;
5788
+ // Fall through — the /build handler above already ran or line is now /build
5789
+ // We need to re-trigger, so just run build inline
5790
+ const task = context.runtime.activeTask;
5791
+ if (task) {
5792
+ const buildArg = rewritten.replace("/build", "").trim();
5793
+ const buildPrompt = buildArg || (task.chosenOption ? `Execute the chosen approach: ${task.chosenOption}` : `Build: ${task.name}`);
5794
+ try {
5795
+ await runTextTurnInteractive({
5796
+ agent,
5797
+ currentSession,
5798
+ context,
5799
+ promptText: buildPrompt,
5800
+ pendingInput: buildPrompt,
5801
+ spinnerLabel: "building..."
5802
+ });
5803
+ } catch (error) {
5804
+ console.log(`build failed: ${error instanceof Error ? error.message : String(error)}`);
5805
+ }
5806
+ }
5807
+ } else if (rewritten === "/challenge") {
5808
+ const task = context.runtime.activeTask;
5809
+ const receipt = context.runtime.lastReceipt || (task && await agent.toolRuntime.readReceipt(task?.latestReceiptId || "last"));
5810
+ if (receipt) {
5811
+ try {
5812
+ const challengeResult = await runChallengeWorkflow({ agent, context, receipt });
5813
+ if (challengeResult?.challenge) {
5814
+ renderReviewCockpit(task, { ...receipt, challenge: challengeResult.challenge });
5815
+ }
5816
+ } catch (error) {
5817
+ console.log(`challenge failed: ${error instanceof Error ? error.message : String(error)}`);
5818
+ }
5819
+ }
5583
5820
  }
5584
5821
  continue;
5585
5822
  }
5586
5823
 
5824
+ // Chat fallback: router returned false (chat/unhandled) — send to model without tools
5825
+ const prevTools = agent.enableTools;
5826
+ agent.enableTools = false;
5587
5827
  try {
5588
5828
  await runTextTurnInteractive({
5589
5829
  agent,
@@ -5595,6 +5835,8 @@ async function promptLoop(agent, session, context) {
5595
5835
  });
5596
5836
  } catch (error) {
5597
5837
  console.log(`request failed: ${error instanceof Error ? error.message : String(error)}`);
5838
+ } finally {
5839
+ agent.enableTools = prevTools;
5598
5840
  }
5599
5841
  }
5600
5842
 
package/src/decider.js CHANGED
@@ -1,14 +1,6 @@
1
1
  import { createJsonCompletion } from "./grok-client.js";
2
2
 
3
- const DECISION_SYSTEM_PROMPT = `You are a senior software architect helping a developer choose an implementation strategy.
4
-
5
- You will be given a task goal and optionally some project context. Your job is to:
6
- 1. Analyze the goal and propose 2-4 concrete implementation options
7
- 2. Each option should have a clear scope (file paths, commands)
8
- 3. Recommend one option with a rationale
9
- 4. Flag open risks
10
-
11
- Respond with ONLY a JSON object matching this schema:
3
+ const DECISION_SCHEMA = `Respond with ONLY a JSON object matching this schema:
12
4
  {
13
5
  "goal": "one-line restatement of the goal",
14
6
  "options": [
@@ -29,20 +21,44 @@ Respond with ONLY a JSON object matching this schema:
29
21
  "recommendation": "option_id",
30
22
  "rationale": "Why this option fits best given current constraints",
31
23
  "openRisks": ["risk description"]
32
- }
24
+ }`;
25
+
26
+ const DECISION_SYSTEM_PROMPT = `You are a senior software architect helping a developer choose an implementation strategy.
27
+
28
+ Your job is to:
29
+ 1. Analyze the goal and propose 2-4 concrete implementation options
30
+ 2. Keep options terse, concrete, and scoped
31
+ 3. Recommend one option with a rationale
32
+ 4. Flag open risks
33
33
 
34
34
  Rules:
35
35
  - Always include at least a "minimal" option
36
36
  - Be concrete about file paths and commands — guess from the goal if needed
37
37
  - Keep summaries factual, not promotional
38
38
  - If you cannot determine scope, say so in openRisks
39
- - Do not include markdown, code fences, or explanatory text outside the JSON`;
39
+ - Do not include markdown, code fences, or explanatory text outside the JSON
40
+
41
+ ${DECISION_SCHEMA}`;
42
+
43
+ const INVENT_SYSTEM_PROMPT = `You are a sharp, divergent product-and-engineering strategist.
44
+
45
+ Your job is to generate non-obvious ways to approach the goal.
46
+ - Prefer surprising but plausible alternatives
47
+ - Surface hidden assumptions and second-order effects
48
+ - Do not just rename the same idea three times
49
+ - At least one option should feel unconventional but still implementable
50
+ - Still provide realistic scope and commands when possible
40
51
 
41
- function buildDecisionPrompt({ goal, memory, taskName }) {
52
+ Keep the output terse and concrete.
53
+ Do not include markdown, code fences, or explanatory text outside the JSON.
54
+
55
+ ${DECISION_SCHEMA}`;
56
+
57
+ function buildDecisionPrompt({ goal, memory, taskName, invent = false }) {
42
58
  const parts = [`Goal: ${goal}`];
43
59
  if (taskName) parts.push(`Task: ${taskName}`);
44
60
  if (memory) parts.push(`Project context (WATERBROTHER.md):\n${memory}`);
45
- parts.push("Propose implementation options as JSON.");
61
+ parts.push(invent ? "Generate divergent implementation options as JSON." : "Propose implementation options as JSON.");
46
62
  return parts.join("\n\n");
47
63
  }
48
64
 
@@ -51,13 +67,13 @@ export function normalizeDecision(decision) {
51
67
  const options = Array.isArray(decision.options) ? decision.options : [];
52
68
  return {
53
69
  goal: String(decision.goal || "").trim(),
54
- options: options.map((opt) => ({
55
- id: String(opt.id || "unknown").trim(),
70
+ options: options.map((opt, index) => ({
71
+ id: String(opt.id || `option-${index + 1}`).trim(),
56
72
  title: String(opt.title || "").trim(),
57
73
  summary: String(opt.summary || "").trim(),
58
74
  pros: Array.isArray(opt.pros) ? opt.pros.map(String) : [],
59
75
  cons: Array.isArray(opt.cons) ? opt.cons.map(String) : [],
60
- risk: ["low", "medium", "high"].includes(opt.risk) ? opt.risk : "medium",
76
+ risk: ["low", "medium", "high"].includes(String(opt.risk || "").trim()) ? String(opt.risk).trim() : "medium",
61
77
  fileCount: Number.isFinite(Number(opt.fileCount)) ? Math.max(0, Math.floor(Number(opt.fileCount))) : null,
62
78
  scope: opt.scope && typeof opt.scope === "object"
63
79
  ? {
@@ -72,20 +88,12 @@ export function normalizeDecision(decision) {
72
88
  };
73
89
  }
74
90
 
75
- export async function runDecisionPass({
76
- apiKey,
77
- baseUrl,
78
- model,
79
- goal,
80
- taskName,
81
- memory,
82
- signal
83
- }) {
84
- if (!goal) throw new Error("goal is required for /decide");
91
+ async function runPlannerPass({ apiKey, baseUrl, model, goal, taskName, memory, signal, invent = false }) {
92
+ if (!goal) throw new Error("goal is required for planning");
85
93
 
86
94
  const messages = [
87
- { role: "system", content: DECISION_SYSTEM_PROMPT },
88
- { role: "user", content: buildDecisionPrompt({ goal, memory, taskName }) }
95
+ { role: "system", content: invent ? INVENT_SYSTEM_PROMPT : DECISION_SYSTEM_PROMPT },
96
+ { role: "user", content: buildDecisionPrompt({ goal, memory, taskName, invent }) }
89
97
  ];
90
98
 
91
99
  const completion = await createJsonCompletion({
@@ -93,13 +101,13 @@ export async function runDecisionPass({
93
101
  baseUrl,
94
102
  model,
95
103
  messages,
96
- temperature: 0.3,
104
+ temperature: invent ? 0.7 : 0.3,
97
105
  signal
98
106
  });
99
107
 
100
108
  const decision = normalizeDecision(completion.json);
101
109
  if (!decision || decision.options.length === 0) {
102
- throw new Error("Decision pass returned no options");
110
+ throw new Error(`${invent ? "Invent" : "Decision"} pass returned no options`);
103
111
  }
104
112
 
105
113
  return {
@@ -108,6 +116,14 @@ export async function runDecisionPass({
108
116
  };
109
117
  }
110
118
 
119
+ export async function runDecisionPass(args) {
120
+ return runPlannerPass({ ...args, invent: false });
121
+ }
122
+
123
+ export async function runInventPass(args) {
124
+ return runPlannerPass({ ...args, invent: true });
125
+ }
126
+
111
127
  export function formatDecisionForDisplay(decision) {
112
128
  if (!decision) return "No decision available.";
113
129
  const lines = [];
@@ -129,3 +145,41 @@ export function formatDecisionForDisplay(decision) {
129
145
  if (decision.openRisks.length > 0) lines.push(`Open risks: ${decision.openRisks.join("; ")}`);
130
146
  return lines.join("\n");
131
147
  }
148
+
149
+ export function formatDecisionCompact(decision, { mode = "standard", title = null } = {}) {
150
+ if (!decision) return "No options available.";
151
+ const lines = [];
152
+ const heading = title || decision.goal || "Decision";
153
+ const rule = "─".repeat(Math.min(56, Math.max(28, heading.length + 4)));
154
+ lines.push(rule);
155
+ lines.push(heading);
156
+ lines.push(rule);
157
+ for (let index = 0; index < decision.options.length; index += 1) {
158
+ const opt = decision.options[index];
159
+ const recommended = opt.id === decision.recommendation ? " ← recommended" : "";
160
+ const files = opt.fileCount != null ? `~${opt.fileCount} files` : "scope varies";
161
+ const summary = mode === "guide" ? opt.summary : opt.title;
162
+ lines.push(` ${index + 1}. ${summary} ${files} ${opt.risk} risk${recommended}`);
163
+ }
164
+ lines.push(rule);
165
+ if (mode === "guide" && decision.rationale) {
166
+ lines.push(`Why this one: ${decision.rationale}`);
167
+ }
168
+ return lines.join("\n");
169
+ }
170
+
171
+ export function formatDecisionDetail(decision, selector) {
172
+ if (!decision || !Array.isArray(decision.options) || decision.options.length === 0) return "No option details available.";
173
+ const option = Number.isInteger(selector)
174
+ ? decision.options[Math.max(0, selector - 1)]
175
+ : decision.options.find((opt) => opt.id === selector);
176
+ if (!option) return "Option not found.";
177
+ const lines = [];
178
+ lines.push(`${option.title} (${option.risk} risk)`);
179
+ lines.push(option.summary || "No summary.");
180
+ if (option.pros.length) lines.push(`Pros: ${option.pros.join(", ")}`);
181
+ if (option.cons.length) lines.push(`Cons: ${option.cons.join(", ")}`);
182
+ if (option.scope.paths.length) lines.push(`Scope: ${option.scope.paths.join(", ")}`);
183
+ if (option.scope.commands.length) lines.push(`Commands: ${option.scope.commands.join(", ")}`);
184
+ return lines.join("\n");
185
+ }
package/src/panel.js CHANGED
@@ -1,122 +1,75 @@
1
1
  const ESC = "\x1b";
2
2
  const CSI = `${ESC}[`;
3
- const SAVE_CURSOR = `${ESC}7`;
4
- const RESTORE_CURSOR = `${ESC}8`;
5
3
 
6
- function dim(text) {
7
- return `${CSI}2m${text}${CSI}0m`;
4
+ function dim(text) { return `${CSI}2m${text}${CSI}0m`; }
5
+ function bold(text) { return `${CSI}1m${text}${CSI}0m`; }
6
+ function cyan(text) { return `${CSI}36m${text}${CSI}0m`; }
7
+ function yellow(text) { return `${CSI}33m${text}${CSI}0m`; }
8
+ function green(text) { return `${CSI}32m${text}${CSI}0m`; }
9
+ function red(text) { return `${CSI}31m${text}${CSI}0m`; }
10
+
11
+ function formatPass(pass) {
12
+ const p = String(pass || "idle").toUpperCase();
13
+ if (p === "BUILD") return yellow(p);
14
+ if (p === "CHALLENGE") return red(p);
15
+ if (p === "DECIDE") return cyan(p);
16
+ if (p === "INVENT") return green(p);
17
+ return dim(p);
8
18
  }
9
19
 
10
- function bold(text) {
11
- return `${CSI}1m${text}${CSI}0m`;
12
- }
13
-
14
- function cyan(text) {
15
- return `${CSI}36m${text}${CSI}0m`;
16
- }
17
-
18
- function yellow(text) {
19
- return `${CSI}33m${text}${CSI}0m`;
20
- }
21
-
22
- function green(text) {
23
- return `${CSI}32m${text}${CSI}0m`;
24
- }
25
-
26
- function red(text) {
27
- return `${CSI}31m${text}${CSI}0m`;
28
- }
29
-
30
- function verdictColor(verdict) {
31
- if (verdict === "ship") return green(verdict);
32
- if (verdict === "block") return red(verdict);
33
- if (verdict === "caution") return yellow(verdict);
34
- return verdict || "";
35
- }
36
-
37
- function stateLabel(state) {
38
- if (!state) return dim("idle");
39
- if (state === "build-ready") return green("build-ready");
40
- if (state === "review-ready") return yellow("review-ready");
41
- if (state === "accepted") return green("accepted");
42
- if (state === "closed") return dim("closed");
43
- if (state === "decide-required") return cyan("decide-required");
44
- if (state === "decision-ready") return cyan("decision-ready");
45
- return state;
20
+ function formatVerdict(verdict) {
21
+ if (verdict === "ship") return green("✓ no issues found");
22
+ if (verdict === "block") return red("⚠ issues found");
23
+ if (verdict === "caution") return yellow("⚠ caution");
24
+ return null;
46
25
  }
47
26
 
48
27
  function truncate(text, max) {
49
28
  const s = String(text || "");
50
- return s.length > max ? s.slice(0, max - 1) + "…" : s;
29
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
51
30
  }
52
31
 
53
- function formatPanelLine(state) {
54
- const parts = [];
55
-
56
- // Line 1: repo + task + mode
57
- const l1 = [];
58
- if (state.repoName) l1.push(bold(state.repoName));
59
- if (state.taskName) l1.push(`task:${cyan(state.taskName)}`);
60
- if (state.branch) l1.push(dim(state.branch));
61
- if (state.mode) l1.push(`mode:${state.mode}`);
62
- if (state.autonomy && state.autonomy !== "scoped") l1.push(`autonomy:${state.autonomy}`);
63
- if (l1.length > 0) parts.push(l1.join(" "));
64
-
65
- // Line 2: state + contract + verdict
66
- const l2 = [];
67
- if (state.taskState) l2.push(stateLabel(state.taskState));
68
- if (state.contractSummary) l2.push(dim(truncate(state.contractSummary, 50)));
69
- if (state.verdict) l2.push(`sentinel:${verdictColor(state.verdict)}`);
70
- if (state.readOnly) l2.push(dim("[read-only]"));
71
- if (l2.length > 0) parts.push(l2.join(" "));
72
-
73
- return parts;
32
+ function buildLines(state) {
33
+ const lines = [];
34
+ const width = Math.min(72, state.columns || 72);
35
+ const rule = dim("─".repeat(width));
36
+ lines.push(rule);
37
+ const title = truncate(state.taskName || state.repoName || "waterbrother", Math.max(24, width - 28));
38
+ const scope = state.contractSummary ? `scope: ${truncate(state.contractSummary, 28)}` : null;
39
+ const headline = [bold(title), "▸", formatPass(state.activePass || state.taskState || "idle"), scope ? dim(scope) : null].filter(Boolean).join(" ");
40
+ lines.push(headline);
41
+ if (state.summary) lines.push(truncate(state.summary, width));
42
+ if (state.issues && state.issues.length > 0) {
43
+ for (const issue of state.issues.slice(0, 2)) {
44
+ lines.push(` ${yellow("⚠")} ${truncate(issue, width - 6)}`);
45
+ }
46
+ } else if (state.verdict) {
47
+ const verdictLine = formatVerdict(state.verdict);
48
+ if (verdictLine) lines.push(` ${verdictLine}`);
49
+ }
50
+ if (state.actions?.length > 0) {
51
+ lines.push(dim(` ${state.actions.join(" · ")}`));
52
+ }
53
+ lines.push(rule);
54
+ return lines;
74
55
  }
75
56
 
76
57
  export function createPanelRenderer({ output } = {}) {
77
58
  const stream = output || process.stdout;
78
59
  let enabled = true;
79
60
  let lastState = null;
80
- let visible = false;
81
61
 
82
62
  function renderPanel(state) {
83
- if (!enabled || !stream.isTTY) return;
63
+ if (!enabled) return;
84
64
  lastState = state;
85
- const lines = formatPanelLine(state);
86
- if (lines.length === 0) return;
87
-
88
- // Print panel lines separated by border
89
- const width = Math.min(stream.columns || 80, 120);
90
- const border = dim("─".repeat(width));
91
- const panelText = lines.join("\n");
92
-
93
- if (visible) {
94
- // Just print the panel as a status update
95
- stream.write(`${border}\n${panelText}\n${border}\n`);
96
- } else {
97
- stream.write(`${border}\n${panelText}\n${border}\n`);
98
- visible = true;
99
- }
100
- }
101
-
102
- function hidePanel() {
103
- visible = false;
104
- }
105
-
106
- function showPanel() {
107
- if (lastState) renderPanel(lastState);
108
- }
109
-
110
- function setEnabled(next) {
111
- enabled = Boolean(next);
112
- if (!enabled) hidePanel();
65
+ const lines = buildLines({ ...state, columns: stream.columns || 80 });
66
+ stream.write(`${lines.join("\n")}\n`);
113
67
  }
114
68
 
115
- function dispose() {
116
- enabled = false;
117
- visible = false;
118
- lastState = null;
119
- }
69
+ function hidePanel() {}
70
+ function showPanel() { if (lastState) renderPanel(lastState); }
71
+ function setEnabled(next) { enabled = Boolean(next); }
72
+ function dispose() { enabled = false; lastState = null; }
120
73
 
121
74
  return {
122
75
  renderPanel,
@@ -125,30 +78,31 @@ export function createPanelRenderer({ output } = {}) {
125
78
  setEnabled,
126
79
  dispose,
127
80
  get enabled() { return enabled; },
128
- get visible() { return visible; }
81
+ get visible() { return Boolean(lastState); }
129
82
  };
130
83
  }
131
84
 
132
- export function buildPanelState({
133
- cwd,
134
- task,
135
- agent,
136
- receipt,
137
- impactSummary
138
- } = {}) {
85
+ export function buildPanelState({ cwd, task, agent, receipt, runtime } = {}) {
139
86
  const repoName = cwd ? cwd.split(/[/\\]/).pop() : "";
87
+ const review = receipt?.review || null;
88
+ const challenge = receipt?.challenge || null;
89
+ const issues = [];
90
+ if (review?.concerns?.length) issues.push(...review.concerns);
91
+ if (challenge?.concerns?.length) {
92
+ for (const concern of challenge.concerns) {
93
+ if (!issues.includes(concern)) issues.push(concern);
94
+ }
95
+ }
140
96
  return {
141
97
  repoName,
142
98
  taskName: task?.name || null,
143
- branch: task?.branch || null,
144
- mode: agent?.getExperienceMode?.() || null,
145
- autonomy: agent?.getAutonomyMode?.() || null,
146
- taskState: task?.state || null,
147
99
  contractSummary: task?.activeContract?.summary || null,
148
- checkpointId: task?.currentCheckpoint || null,
149
- latestReceiptId: task?.latestReceiptId || receipt?.id || null,
150
- verdict: receipt?.review?.verdict || task?.lastVerdict || null,
151
- impactSummary: impactSummary || null,
100
+ taskState: task?.state || null,
101
+ activePass: runtime?.activePass || task?.lastPass || null,
102
+ summary: runtime?.panelSummary || challenge?.summary || review?.summary || null,
103
+ issues,
104
+ verdict: review?.verdict || task?.lastVerdict || null,
105
+ actions: Array.isArray(runtime?.actionHints) ? runtime.actionHints : [],
152
106
  readOnly: agent?.getExperienceMode?.() === "auditor"
153
107
  };
154
108
  }
package/src/router.js ADDED
@@ -0,0 +1,88 @@
1
+ function normalize(text) {
2
+ return String(text || "").trim();
3
+ }
4
+
5
+ function lower(text) {
6
+ return normalize(text).toLowerCase();
7
+ }
8
+
9
+ function looksLikeWorkRequest(text) {
10
+ const raw = normalize(text);
11
+ if (!raw) return false;
12
+ if (raw.startsWith("/")) return false;
13
+ const l = raw.toLowerCase();
14
+ if (/^(hi|hello|thanks|thank you|help|what can you do)\b/.test(l)) return false;
15
+ if (/^(build it|go|check it|ship it|fix these|ignore|challenge harder|think deeper)\b/.test(l)) return false;
16
+ if (/^[1-9]\d*$/.test(l)) return false;
17
+ const verbStart = /^(add|build|implement|create|fix|refactor|remove|update|wire|support|migrate|improve|audit|review|debug|investigate|clean up|set up|setup|rewrite|port|optimize)\b/;
18
+ const suffixHint = /( to the | for the | in the | across | using | with | without )/;
19
+ return verbStart.test(l) || suffixHint.test(l) || (!l.endsWith("?") && raw.split(/\s+/).length >= 3);
20
+ }
21
+
22
+ function inferPassFromText(text) {
23
+ const l = lower(text);
24
+ if (!l) return null;
25
+ if (/^(ship it|accept|merge it|looks good ship it)\b/.test(l)) return { intent: "accept", confidence: "high" };
26
+ if (/^(fix these|fix that|address those|redo|rebuild|try again|patch it|build it|go ahead|go$|implement it)\b/.test(l)) return { intent: "build", confidence: "high" };
27
+ if (/^(check it|what'?s wrong|what is wrong|review it|challenge( harder)?|find bugs|poke holes|stress test it)\b/.test(l)) return { intent: "challenge", confidence: "high" };
28
+ if (/^(what am i not thinking of|what else|what if|surprise me|invent|give me alternatives|think wider|out of the box)\b/.test(l)) return { intent: "invent", confidence: "high" };
29
+ if (/^(should we|which should|which option|what should we|think deeper|tell me more about|compare |tradeoff|jwt or |sessions or |oauth|oauth2)\b/.test(l)) return { intent: "decide", confidence: "medium" };
30
+ if (/^[1-9]\d*$/.test(l)) return { intent: "choose", confidence: "high", optionIndex: Number.parseInt(l, 10) };
31
+ const moreMatch = l.match(/^(tell me more about|more on|details on|expand)\s+(\d+)\b/);
32
+ if (moreMatch) return { intent: "decision-detail", confidence: "high", optionIndex: Number.parseInt(moreMatch[2], 10) };
33
+ if (/^ignore\b/.test(l)) return { intent: "ignore", confidence: "medium" };
34
+ return null;
35
+ }
36
+
37
+ export function deriveTaskNameFromPrompt(text) {
38
+ const raw = normalize(text)
39
+ .replace(/["'`]/g, "")
40
+ .replace(/[?!.]+$/g, "")
41
+ .replace(/^please\s+/i, "")
42
+ .trim();
43
+ if (!raw) return `task-${Date.now().toString(36)}`;
44
+ const words = raw.split(/\s+/).slice(0, 6);
45
+ return words.join(" ");
46
+ }
47
+
48
+ export function routeNaturalInput(text, { task = null } = {}) {
49
+ const raw = normalize(text);
50
+ if (!raw || raw.startsWith("/")) return { kind: "none", confidence: "low" };
51
+
52
+ const routed = inferPassFromText(raw);
53
+ if (routed) return { kind: routed.intent, confidence: routed.confidence, raw, ...routed };
54
+
55
+ if (!task && looksLikeWorkRequest(raw)) {
56
+ return { kind: "start-work", confidence: "medium", raw, taskName: deriveTaskNameFromPrompt(raw) };
57
+ }
58
+
59
+ if (task) {
60
+ if (task.state === "decision-ready") {
61
+ if (/^(go|build it|do it|sounds good|recommended)\b/i.test(raw)) {
62
+ return { kind: "choose-recommended-and-build", confidence: "high", raw };
63
+ }
64
+ }
65
+ if (task.state === "review-ready") {
66
+ if (/^(fix these|fix them|address those|redo)\b/i.test(raw)) {
67
+ return { kind: "fix-review-findings", confidence: "high", raw };
68
+ }
69
+ if (/^(think deeper|re-think|rethink|step back)\b/i.test(raw)) {
70
+ return { kind: "decide", confidence: "high", raw };
71
+ }
72
+ }
73
+ }
74
+
75
+ return { kind: "chat", confidence: "low", raw };
76
+ }
77
+
78
+ export function nextActionsForState({ task, receipt } = {}) {
79
+ if (!task) return [];
80
+ if (task.state === "decision-ready") return ["1", "2", "3", "go", "tell me more about 2"];
81
+ if (task.state === "build-ready") return ["build it", "check it", "think deeper"];
82
+ if (task.state === "review-ready") {
83
+ const blocked = receipt?.review?.verdict === "block" || (Array.isArray(receipt?.verification) && receipt.verification.some((v) => !v.ok));
84
+ return blocked ? ["fix these", "ignore", "challenge harder"] : ["accept", "redo", "challenge harder", "ship it"];
85
+ }
86
+ if (task.state === "accepted") return ["close", "start next task"];
87
+ return [];
88
+ }