@tritard/waterbrother 0.8.27 → 0.8.28

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +78 -0
  3. package/src/tools.js +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.8.27",
3
+ "version": "0.8.28",
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
@@ -606,6 +606,50 @@ function buildSyntheticAssistantOutput(receipt) {
606
606
  return null;
607
607
  }
608
608
 
609
+ function hasFrontendCodeEcho(text) {
610
+ const body = String(text || "");
611
+ return /```(?:html|css|js|javascript|jsx|tsx)?[\s\S]{120,}```/i.test(body) || /<!DOCTYPE html>/i.test(body);
612
+ }
613
+
614
+ function hasWriteMutationTool(receipt) {
615
+ const tools = Array.isArray(receipt?.tools) ? receipt.tools : [];
616
+ return tools.some((tool) => ["write_file", "replace_in_file", "apply_patch"].includes(tool?.name));
617
+ }
618
+
619
+ function deriveContractWriteTarget(contract) {
620
+ const paths = Array.isArray(contract?.paths) ? contract.paths : [];
621
+ for (const item of paths) {
622
+ const raw = String(item || "").trim();
623
+ if (!raw) continue;
624
+ const normalized = raw.replace(/\/\*\*?$/g, "").replace(/\*+$/g, "");
625
+ if (normalized) return normalized;
626
+ }
627
+ return null;
628
+ }
629
+
630
+ function shouldRecoverFrontendCodeEcho({ frontendExecutionContext, receipt, assistantText }) {
631
+ if (!frontendExecutionContext?.frontend) return false;
632
+ if (!receipt || receipt.mutated) return false;
633
+ if (!hasFrontendCodeEcho(assistantText)) return false;
634
+ if (hasWriteMutationTool(receipt)) return false;
635
+ const tools = Array.isArray(receipt.tools) ? receipt.tools : [];
636
+ return tools.some((tool) => tool?.name === "declare_contract") || tools.some((tool) => tool?.name === "make_directory");
637
+ }
638
+
639
+ function buildFrontendWriteRecoveryPrompt({ originalPrompt, contract }) {
640
+ const target = deriveContractWriteTarget(contract);
641
+ const lines = [
642
+ "You already planned the work but printed code into chat instead of writing files.",
643
+ `Original task: ${String(originalPrompt || "").trim()}`,
644
+ target ? `Write the generated frontend into the declared scope now: ${target}` : "Write the generated frontend into the declared contract scope now.",
645
+ "Do not print long code blocks in chat.",
646
+ "Use write_file (or replace_in_file/apply_patch if needed) to create the actual site files.",
647
+ "If this is a new site in a folder, default to writing index.html there unless multiple files are clearly justified.",
648
+ "After writing, reply briefly with only the files created or updated."
649
+ ];
650
+ return lines.join("\n\n");
651
+ }
652
+
609
653
  function color256(fg, text) {
610
654
  return `\x1b[38;5;${fg}m${text}\x1b[0m`;
611
655
  }
@@ -3576,6 +3620,40 @@ async function runTextTurnInteractive({
3576
3620
  precomputedReceipt = await agent.toolRuntime.completeTurn({ signal: abortController?.signal });
3577
3621
  renderedAssistantText = buildSyntheticAssistantOutput(precomputedReceipt) || renderedAssistantText;
3578
3622
  }
3623
+ if (!precomputedReceipt && frontendExecutionContext) {
3624
+ const candidateReceipt = await agent.toolRuntime.completeTurn({ signal: abortController?.signal });
3625
+ if (shouldRecoverFrontendCodeEcho({ frontendExecutionContext, receipt: candidateReceipt, assistantText: response.content || "" })) {
3626
+ const recoverySpinner = createProgressSpinner("writing files...");
3627
+ printLiveTrace("frontend recovery: assistant echoed code, retrying with write_file", context.runtime.traceMode);
3628
+ agent.toolRuntime.setReadOnlyRoots(readOnlyRoots);
3629
+ agent.toolRuntime.setWriteRoots(writeRoots);
3630
+ if (frontendExecutionContext) {
3631
+ const merged = { ...(previousExecutionContext || {}), ...frontendExecutionContext };
3632
+ if (previousExecutionContext?.reminders && frontendExecutionContext.reminders) {
3633
+ merged.reminders = `${previousExecutionContext.reminders}\n${frontendExecutionContext.reminders}`;
3634
+ }
3635
+ agent.setExecutionContext(merged);
3636
+ }
3637
+ try {
3638
+ response = await agent.runTurn(buildFrontendWriteRecoveryPrompt({ originalPrompt: effectivePromptText, contract: candidateReceipt?.contract }), {
3639
+ signal: abortController?.signal,
3640
+ onStateChange(state) {
3641
+ printLiveTrace(`state=${state}`, context.runtime.traceMode, { verboseOnly: true });
3642
+ }
3643
+ });
3644
+ renderedAssistantText = response.content || "";
3645
+ } finally {
3646
+ recoverySpinner.stop();
3647
+ agent.toolRuntime.setReadOnlyRoots([]);
3648
+ agent.toolRuntime.setWriteRoots([]);
3649
+ if (frontendExecutionContext) {
3650
+ agent.setExecutionContext(previousExecutionContext);
3651
+ }
3652
+ }
3653
+ } else {
3654
+ precomputedReceipt = candidateReceipt;
3655
+ }
3656
+ }
3579
3657
  printAssistantOutput(renderedAssistantText);
3580
3658
  await setSessionRunState(currentSession, agent, "done");
3581
3659
  printTurnSummary(turnSummary, response, { modelId: agent.getModel(), costTracker: context.costTracker, traceMode: context.runtime.traceMode });
package/src/tools.js CHANGED
@@ -1037,12 +1037,23 @@ export function createToolRuntime({
1037
1037
  return { decision: "ask", reason: "No matching allow rule" };
1038
1038
  }
1039
1039
 
1040
+ function getContractWriteRoots() {
1041
+ const rawPaths = Array.isArray(currentTurn.contract?.paths) ? currentTurn.contract.paths : [];
1042
+ return normalizePathList(
1043
+ rawPaths
1044
+ .map((item) => String(item || "").trim())
1045
+ .filter(Boolean)
1046
+ .map((item) => item.replace(/\/\*\*?$/g, "").replace(/\*+$/g, ""))
1047
+ .filter(Boolean)
1048
+ );
1049
+ }
1050
+
1040
1051
  function contractAllows(toolName, args = {}) {
1041
1052
  if (!currentTurn.contract) return { ok: !toolRequiresContract(toolName, args), reason: "No active contract" };
1042
1053
  if ((toolName === "write_file" || toolName === "replace_in_file" || toolName === "make_directory" || toolName === "delete_path") && args.path) {
1043
1054
  try {
1044
1055
  resolveSandboxPath(cwd, args.path, allowOutsideCwd, {
1045
- allowedWriteRoots: getCurrentWriteRoots()
1056
+ allowedWriteRoots: [...getCurrentWriteRoots(), ...getContractWriteRoots()]
1046
1057
  });
1047
1058
  } catch {
1048
1059
  const touchedPaths = getTouchedPathsForTool(toolName, args);