@tritard/waterbrother 0.16.44 → 0.16.45

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.16.44",
3
+ "version": "0.16.45",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -172,6 +172,7 @@ function buildSystemPrompt(profile, experienceMode = "standard", autonomyMode =
172
172
  }
173
173
  if (executionContext.calibration) ctxLines.push(`Scope calibration (from scored build history):\n${executionContext.calibration}`);
174
174
  if (executionContext.reminders) ctxLines.push(`Scope reminders:\n${executionContext.reminders}`);
175
+ if (executionContext.evolution) ctxLines.push(executionContext.evolution);
175
176
  if (ctxLines.length > 0) base += `\n\nExecution context:\n${ctxLines.join("\n")}`;
176
177
  }
177
178
  if (!memoryBlock) return base;
package/src/cli.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  } from "./frontend.js";
48
48
  import { loadTask, saveTask, listTasks, setActiveTask, getActiveTask, closeTask } from "./task-store.js";
49
49
  import { runDecisionPass, runInventPass, formatDecisionForDisplay, formatDecisionCompact, formatDecisionDetail } from "./decider.js";
50
- import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow } from "./workflow.js";
50
+ import { runBuildWorkflow, startFeatureTask, runChallengeWorkflow, evaluateChainRules, captureRegressionFromBuild, resolveRegressionsFromBuild, evaluatePostBuildEvolution, getEvolutionBlock } from "./workflow.js";
51
51
  import { createPanelRenderer, buildPanelState } from "./panel.js";
52
52
  import { deriveTaskNameFromPrompt, nextActionsForState, routeNaturalInput } from "./router.js";
53
53
  import { compressEpisode, compressSessionEpisode, saveEpisode, loadRecentEpisodes, findRelevantEpisodes, buildEpisodicMemoryBlock, buildReminderBlock } from "./episodic.js";
@@ -94,6 +94,10 @@ import {
94
94
  setSharedRoomMode,
95
95
  upsertSharedMember
96
96
  } from "./shared-project.js";
97
+ import { findRelevantRegressions, buildRegressionWarningBlock } from "./regressions.js";
98
+ import { loadUserSkills, approveSkill, disableSkill, removeSkill, buildSkillExecutionMessage, detectRepeatedPatterns } from "./skills.js";
99
+ import { captureCorrection, findRelevantCorrections, buildLessonBlock, detectUserEdits, findPendingCorrections, extractLessons, saveLessons } from "./corrections.js";
100
+ import { evaluateEvolution, loadProposals, approveProposal, rejectProposal, revertProposal, getActivePatches, buildEvolutionBlock, validateProposalSafety } from "./evolution.js";
97
101
 
98
102
  const execFileAsync = promisify(execFile);
99
103
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -226,7 +230,18 @@ const INTERACTIVE_COMMANDS = [
226
230
  { name: "/cost", description: "Show session token usage and cost breakdown" },
227
231
  { name: "/diff", description: "Show git changes in the current repo" },
228
232
  { name: "/voice", description: "Toggle voice dictation (press space to record)" },
229
- { name: "/speak", description: "Toggle TTS — agent reads responses aloud (esc to stop)" }
233
+ { name: "/speak", description: "Toggle TTS — agent reads responses aloud (esc to stop)" },
234
+ { name: "/skill list", description: "Show all loaded skills" },
235
+ { name: "/skill approve <name>", description: "Approve or re-approve a skill" },
236
+ { name: "/skill disable <name>", description: "Disable a skill" },
237
+ { name: "/skill remove <name>", description: "Delete a skill file" },
238
+ { name: "/correct <file>", description: "Flag a file as a correction to agent work" },
239
+ { name: "/evolve", description: "Show evolution summary — active patches, pending proposals, stats" },
240
+ { name: "/evolve propose", description: "Manually trigger evolution evaluation" },
241
+ { name: "/evolve approve <id>", description: "Approve a pending evolution proposal" },
242
+ { name: "/evolve reject <id>", description: "Reject a pending evolution proposal" },
243
+ { name: "/evolve revert <id>", description: "Revert an applied evolution patch" },
244
+ { name: "/evolve history", description: "Show full evolution history with score deltas" }
230
245
  ];
231
246
 
232
247
  const AGENT_PROFILES = ["coder", "designer", "reviewer", "planner"];
@@ -1253,6 +1268,7 @@ async function enrichTurnArtifacts({ agent, context, promptText, assistantText,
1253
1268
  receipt = await agent.toolRuntime.updateReceipt(receipt.id, updates) || receipt;
1254
1269
  }
1255
1270
 
1271
+ receipt._buildCompletedAt = receipt._buildCompletedAt || new Date().toISOString();
1256
1272
  context.runtime.lastReceipt = receipt;
1257
1273
  context.runtime.lastImpact = impact || receipt.impact || null;
1258
1274
  return receipt;
@@ -1364,7 +1380,8 @@ async function finalizeReceiptArtifacts({
1364
1380
  const finalReceipt = Object.keys(updates).length > 0
1365
1381
  ? (await agent.toolRuntime.updateReceipt(receipt.id, updates) || receipt)
1366
1382
  : receipt;
1367
- context.runtime.lastReceipt = finalReceipt;
1383
+ finalReceipt._buildCompletedAt = new Date().toISOString();
1384
+ context.runtime.lastReceipt = finalReceipt;
1368
1385
  context.runtime.lastImpact = artifacts?.impact || finalReceipt.impact || null;
1369
1386
  return finalReceipt;
1370
1387
  }
@@ -7325,7 +7342,30 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7325
7342
  }
7326
7343
  }
7327
7344
 
7328
- if (!routed || routed.kind === "none" || routed.kind === "chat") return false;
7345
+ // Phase 2A: Skill dispatch check user-authored skills when built-in router has no match
7346
+ if (!routed || routed.kind === "none" || routed.kind === "chat") {
7347
+ try {
7348
+ const loadedSkills = await loadUserSkills(context.cwd);
7349
+ const matchedSkill = loadedSkills.find((s) => line === s.name || line.startsWith(s.name + " "));
7350
+ if (matchedSkill) {
7351
+ const skillArgs = line.slice(matchedSkill.name.length).trim();
7352
+ const skillMessage = buildSkillExecutionMessage(matchedSkill, skillArgs);
7353
+ // Inject as user-role message, NOT system prompt
7354
+ const skillSpinner = createProgressSpinner(`running skill ${matchedSkill.name}...`);
7355
+ try {
7356
+ const skillResponse = await agent.runBuildTurn(skillMessage, {});
7357
+ skillSpinner.stop();
7358
+ const skillContent = skillResponse?.content || "";
7359
+ if (skillContent) printAssistantOutput(skillContent);
7360
+ } catch (skillErr) {
7361
+ skillSpinner.stop();
7362
+ console.log(`Skill ${matchedSkill.name} failed: ${skillErr instanceof Error ? skillErr.message : String(skillErr)}`);
7363
+ }
7364
+ return true;
7365
+ }
7366
+ } catch {}
7367
+ return false;
7368
+ }
7329
7369
 
7330
7370
  try {
7331
7371
  if (routed.kind === "start-work") {
@@ -7725,8 +7765,19 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7725
7765
  if (line.startsWith("/") && !line.includes(" ")) {
7726
7766
  const hasExact = INTERACTIVE_COMMANDS.some((command) => command.name === line);
7727
7767
  if (!hasExact) {
7728
- printSlashMenu(line);
7729
- continue;
7768
+ // Check user-authored skills before showing "no match"
7769
+ let isSkill = false;
7770
+ try {
7771
+ const { loadUserSkills } = await import("./skills.js");
7772
+ const skills = await loadUserSkills(context.cwd);
7773
+ isSkill = skills.some((s) => s.name === line);
7774
+ } catch (skillErr) {
7775
+ process.stderr.write(`[skills] Failed to load skills: ${skillErr instanceof Error ? skillErr.message : String(skillErr)}\n`);
7776
+ }
7777
+ if (!isSkill) {
7778
+ printSlashMenu(line);
7779
+ continue;
7780
+ }
7730
7781
  }
7731
7782
  }
7732
7783
 
@@ -8988,25 +9039,57 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
8988
9039
 
8989
9040
  await maybeAutoCompactConversation({ agent, currentSession, context, pendingInput: buildPrompt });
8990
9041
 
8991
- // Inject adaptive reminders from episodic memory
9042
+ // Inject adaptive reminders from episodic memory + regressions + corrections
8992
9043
  try {
8993
9044
  const contractPaths = task.activeContract?.paths || [];
8994
9045
  const taskTags = [task.name, task.goal].filter(Boolean).join(" ").toLowerCase().split(/\s+/).filter((w) => w.length >= 3);
8995
9046
  const relevant = await findRelevantEpisodes({ cwd: context.cwd, filePatterns: contractPaths, tags: taskTags, limit: 3 });
9047
+ let reminders = "";
8996
9048
  if (relevant.length > 0) {
8997
- const reminders = buildReminderBlock({
9049
+ reminders = buildReminderBlock({
8998
9050
  episodes: relevant,
8999
9051
  memoryText: context.runtime.projectMemory?.raw || "",
9000
9052
  contractPaths
9001
9053
  });
9002
- if (reminders) {
9003
- agent.setExecutionContext({
9004
- taskName: task.name,
9005
- chosenOption: task.chosenOption || null,
9006
- contractSummary: task.activeContract?.summary || null,
9007
- phase: "build",
9008
- reminders
9009
- });
9054
+ }
9055
+ // Phase 1B: Inject regression warnings for overlapping paths
9056
+ try {
9057
+ const regressions = await findRelevantRegressions({ cwd: context.cwd, contractPaths, limit: 5 });
9058
+ const regBlock = buildRegressionWarningBlock(regressions);
9059
+ if (regBlock) reminders = (reminders || "") + regBlock;
9060
+ } catch {}
9061
+ // Phase 2B: Inject lesson block from corrections
9062
+ try {
9063
+ const corrections = await findRelevantCorrections({ cwd: context.cwd, contractPaths, limit: 5 });
9064
+ const lessonBlock = buildLessonBlock(corrections);
9065
+ if (lessonBlock) reminders = (reminders || "") + lessonBlock;
9066
+ } catch {}
9067
+ if (reminders) {
9068
+ agent.setExecutionContext({
9069
+ taskName: task.name,
9070
+ chosenOption: task.chosenOption || null,
9071
+ contractSummary: task.activeContract?.summary || null,
9072
+ phase: "build",
9073
+ reminders
9074
+ });
9075
+ }
9076
+ } catch {}
9077
+
9078
+ // Phase 2B: Detect and capture user corrections since last build
9079
+ try {
9080
+ const lastReceipt = context.runtime.lastReceipt;
9081
+ if (lastReceipt) {
9082
+ const editedFiles = await detectUserEdits({ cwd: context.cwd, receipt: lastReceipt });
9083
+ for (const ef of editedFiles) {
9084
+ try {
9085
+ let diff = "";
9086
+ try {
9087
+ const { stdout } = await execFileAsync("git", ["diff", "--", ef], { cwd: context.cwd, maxBuffer: 50 * 1024 });
9088
+ diff = stdout;
9089
+ } catch {}
9090
+ await captureCorrection({ cwd: context.cwd, filePath: ef, diff, type: "user-edit" });
9091
+ console.log(dim(`Correction captured: ${ef} (you edited it after the last build). Use /correct undo to revert if unintentional.`));
9092
+ } catch {}
9010
9093
  }
9011
9094
  }
9012
9095
  } catch {}
@@ -9231,6 +9314,80 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
9231
9314
  await setSessionRunState(currentSession, agent, "done");
9232
9315
  await saveCurrentSession(currentSession, agent);
9233
9316
  updatePanel();
9317
+
9318
+ // Phase 1: Post-build chain rules, regression capture/resolution
9319
+ if (buildResult.receipt) {
9320
+ const finalReceipt = buildResult.receipt;
9321
+ finalReceipt._buildCompletedAt = finalReceipt._buildCompletedAt || new Date().toISOString();
9322
+ context.runtime.lastReceipt = finalReceipt;
9323
+
9324
+ // Phase 1B: Capture regression on failure, resolve on success
9325
+ try {
9326
+ await captureRegressionFromBuild(finalReceipt, task, context.cwd);
9327
+ await resolveRegressionsFromBuild(finalReceipt, context.cwd);
9328
+ } catch {}
9329
+
9330
+ // Phase 1A: Evaluate chain rules
9331
+ try {
9332
+ const chainResult = evaluateChainRules(finalReceipt, buildResult.verifierResults, context.runtime.chainRules, {
9333
+ onChainSuggestion({ key }) {
9334
+ if (key === "build:sentinel-caution") {
9335
+ console.log(dim("Sentinel flagged caution. Use /challenge to adversarial review."));
9336
+ }
9337
+ }
9338
+ });
9339
+
9340
+ if (chainResult?.nextAction === "challenge") {
9341
+ console.log(dim("Sentinel blocked. Auto-challenging..."));
9342
+ const challengeSpinner = createProgressSpinner("challenging...");
9343
+ try {
9344
+ const challengeResult = await runChallengeWorkflow({
9345
+ apiKey: context.runtime.apiKey,
9346
+ baseUrl: context.runtime.baseUrl,
9347
+ model: context.runtime.reviewer?.model || agent.getModel(),
9348
+ receipt: chainResult.receipt,
9349
+ impact: finalReceipt.impact || null,
9350
+ task,
9351
+ maxDiffChars: context.runtime.reviewer?.maxDiffChars,
9352
+ signal: null
9353
+ });
9354
+ challengeSpinner.stop();
9355
+ console.log("--- Auto-Challenge Result ---");
9356
+ console.log(challengeResult.summary);
9357
+ if (challengeResult.concerns.length > 0) {
9358
+ for (const c of challengeResult.concerns) console.log(` ${red("•")} ${c}`);
9359
+ }
9360
+ console.log("---");
9361
+ await agent.toolRuntime.updateReceipt(finalReceipt.id, { challenge: challengeResult });
9362
+ finalReceipt.challenge = challengeResult;
9363
+ finalReceipt._buildCompletedAt = new Date().toISOString();
9364
+ context.runtime.lastReceipt = finalReceipt;
9365
+ } catch (challengeErr) {
9366
+ challengeSpinner.stop();
9367
+ console.log(`auto-challenge failed: ${challengeErr instanceof Error ? challengeErr.message : String(challengeErr)}`);
9368
+ }
9369
+ }
9370
+ } catch {}
9371
+
9372
+ // Phase 3: Post-build evolution evaluation
9373
+ try {
9374
+ const evolutionResult = await evaluatePostBuildEvolution({
9375
+ cwd: context.cwd,
9376
+ receipt: finalReceipt,
9377
+ task
9378
+ });
9379
+ if (evolutionResult.proposal) {
9380
+ const ep = evolutionResult.proposal;
9381
+ console.log(dim(`Evolution proposal: [${ep.category}] ${ep.description}`));
9382
+ console.log(dim(` Approve with: /evolve approve ${ep.id}`));
9383
+ }
9384
+ for (const ab of (evolutionResult.abResults || [])) {
9385
+ if (ab.suggestion === "revert") {
9386
+ console.log(yellow(`A/B: ${ab.message}`));
9387
+ }
9388
+ }
9389
+ } catch {}
9390
+ }
9234
9391
  } catch (error) {
9235
9392
  clearInterval(heartbeatTimer);
9236
9393
  detachInterruptListener();
@@ -9339,6 +9496,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
9339
9496
  // Persist challenge to receipt
9340
9497
  await agent.toolRuntime.updateReceipt(receipt.id, { challenge: result });
9341
9498
  receipt.challenge = result;
9499
+ receipt._buildCompletedAt = receipt._buildCompletedAt || new Date().toISOString();
9342
9500
  context.runtime.lastReceipt = receipt;
9343
9501
  } catch (error) {
9344
9502
  spinner.stop();
@@ -9347,6 +9505,238 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
9347
9505
  continue;
9348
9506
  }
9349
9507
 
9508
+ // Phase 2A: /skill commands
9509
+ if (line === "/skill" || line.startsWith("/skill ")) {
9510
+ const skillArgs = line.replace("/skill", "").trim();
9511
+ const skillParts = skillArgs.split(/\s+/);
9512
+ const subCmd = skillParts[0] || "list";
9513
+ const skillName = skillParts.slice(1).join(" ");
9514
+
9515
+ if (subCmd === "list") {
9516
+ try {
9517
+ const skills = await loadUserSkills(context.cwd);
9518
+ if (skills.length === 0) {
9519
+ console.log("No approved skills loaded.");
9520
+ console.log(dim("Place .md skill files in .waterbrother/skills/ or ~/.waterbrother/skills/"));
9521
+ } else {
9522
+ console.log(`Loaded skills (${skills.length}):`);
9523
+ for (const s of skills) {
9524
+ console.log(` ${cyan(s.name)} — ${s.description || "(no description)"}`);
9525
+ if (s.trigger) console.log(` ${dim("trigger:")} ${s.trigger}`);
9526
+ console.log(` ${dim("source:")} ${s.source}`);
9527
+ }
9528
+ }
9529
+ } catch (err) {
9530
+ console.log(`Failed to load skills: ${err instanceof Error ? err.message : String(err)}`);
9531
+ }
9532
+ } else if (subCmd === "approve" && skillName) {
9533
+ try {
9534
+ const result = await approveSkill(context.cwd, skillName);
9535
+ if (result) {
9536
+ console.log(`Approved skill ${cyan(result.name)} (hash: ${result.contentHash})`);
9537
+ } else {
9538
+ console.log(`Skill "${skillName}" not found.`);
9539
+ }
9540
+ } catch (err) {
9541
+ console.log(`Failed to approve skill: ${err instanceof Error ? err.message : String(err)}`);
9542
+ }
9543
+ } else if (subCmd === "disable" && skillName) {
9544
+ try {
9545
+ const result = await disableSkill(context.cwd, skillName);
9546
+ if (result) {
9547
+ console.log(`Disabled skill ${result.name}`);
9548
+ } else {
9549
+ console.log(`Skill "${skillName}" not found.`);
9550
+ }
9551
+ } catch (err) {
9552
+ console.log(`Failed to disable skill: ${err instanceof Error ? err.message : String(err)}`);
9553
+ }
9554
+ } else if (subCmd === "remove" && skillName) {
9555
+ try {
9556
+ const confirmed = await promptYesNo(`Remove skill "${skillName}" permanently?`);
9557
+ if (!confirmed) { continue; }
9558
+ const result = await removeSkill(context.cwd, skillName);
9559
+ if (result) {
9560
+ console.log(`Removed skill ${result.name} from ${result.source}`);
9561
+ } else {
9562
+ console.log(`Skill "${skillName}" not found.`);
9563
+ }
9564
+ } catch (err) {
9565
+ console.log(`Failed to remove skill: ${err instanceof Error ? err.message : String(err)}`);
9566
+ }
9567
+ } else {
9568
+ console.log("Usage: /skill list | approve <name> | disable <name> | remove <name>");
9569
+ }
9570
+ continue;
9571
+ }
9572
+
9573
+ // Phase 2B: /correct command
9574
+ if (line === "/correct" || line.startsWith("/correct ")) {
9575
+ const correctArgs = line.replace("/correct", "").trim();
9576
+ if (!correctArgs) {
9577
+ console.log("Usage: /correct <file> — Flag a file as a correction to agent work");
9578
+ continue;
9579
+ }
9580
+ try {
9581
+ let diff = "";
9582
+ try {
9583
+ const { stdout } = await execFileAsync("git", ["diff", "--", correctArgs], { cwd: context.cwd, maxBuffer: 50 * 1024 });
9584
+ diff = stdout;
9585
+ } catch {}
9586
+ if (!diff) {
9587
+ try {
9588
+ const { stdout } = await execFileAsync("git", ["diff", "HEAD", "--", correctArgs], { cwd: context.cwd, maxBuffer: 50 * 1024 });
9589
+ diff = stdout;
9590
+ } catch {}
9591
+ }
9592
+ const entry = await captureCorrection({ cwd: context.cwd, filePath: correctArgs, diff, type: "explicit" });
9593
+ console.log(`Captured correction ${dim(entry.id)} for ${correctArgs}`);
9594
+ } catch (err) {
9595
+ console.log(`Failed to capture correction: ${err instanceof Error ? err.message : String(err)}`);
9596
+ }
9597
+ continue;
9598
+ }
9599
+
9600
+ // Phase 3: /evolve commands
9601
+ if (line === "/evolve" || line.startsWith("/evolve ")) {
9602
+ const evolveArgs = line.replace("/evolve", "").trim();
9603
+ const evolveParts = evolveArgs.split(/\s+/);
9604
+ const evolveSubCmd = evolveParts[0] || "";
9605
+ const evolveTarget = evolveParts.slice(1).join(" ").trim();
9606
+
9607
+ try {
9608
+ if (!evolveSubCmd) {
9609
+ // /evolve — Show evolution summary
9610
+ const proposals = await loadProposals(context.cwd);
9611
+ const pending = proposals.filter((p) => p.status === "proposed");
9612
+ const approved = proposals.filter((p) => p.status === "approved");
9613
+ const rejected = proposals.filter((p) => p.status === "rejected");
9614
+ const reverted = proposals.filter((p) => p.status === "reverted");
9615
+
9616
+ console.log("--- Evolution Dashboard ---");
9617
+ console.log(`Proposals: ${proposals.length} total (${pending.length} pending, ${approved.length} active, ${rejected.length} rejected, ${reverted.length} reverted)`);
9618
+
9619
+ if (pending.length > 0) {
9620
+ console.log("\nPending proposals:");
9621
+ for (const p of pending) {
9622
+ console.log(` ${yellow(p.id)} [${p.category}] ${p.description}`);
9623
+ }
9624
+ }
9625
+
9626
+ if (approved.length > 0) {
9627
+ console.log("\nActive patches:");
9628
+ for (const p of approved) {
9629
+ const scoreDelta = p.scoreBeforeApply != null && p.scoreAfterApply != null
9630
+ ? ` (before: ${(p.scoreBeforeApply * 100).toFixed(0)}%, after: ${(p.scoreAfterApply * 100).toFixed(0)}%)`
9631
+ : "";
9632
+ console.log(` ${green(p.id)} [${p.category}] ${p.description}${scoreDelta}`);
9633
+ }
9634
+ }
9635
+
9636
+ if (proposals.length === 0) {
9637
+ console.log(dim("No evolution proposals yet. Proposals are generated automatically after builds."));
9638
+ }
9639
+ } else if (evolveSubCmd === "propose") {
9640
+ // /evolve propose — Manual trigger
9641
+ console.log("Evaluating evolution...");
9642
+ const { proposal, abResults } = await evaluatePostBuildEvolution({
9643
+ cwd: context.cwd,
9644
+ receipt: context.runtime.lastReceipt || {},
9645
+ task: context.runtime.activeTask || null
9646
+ });
9647
+ if (proposal) {
9648
+ console.log(`New proposal: ${yellow(proposal.id)} [${proposal.category}]`);
9649
+ console.log(` ${proposal.description}`);
9650
+ console.log(` Patch: ${proposal.patch}`);
9651
+ console.log(dim(` Approve with: /evolve approve ${proposal.id}`));
9652
+ } else {
9653
+ console.log("No evolution proposals generated from current data.");
9654
+ }
9655
+ for (const ab of abResults) {
9656
+ if (ab.suggestion === "revert") {
9657
+ console.log(yellow(ab.message));
9658
+ } else {
9659
+ console.log(dim(ab.message));
9660
+ }
9661
+ }
9662
+ } else if (evolveSubCmd === "approve") {
9663
+ if (!evolveTarget) {
9664
+ console.log("Usage: /evolve approve <id>");
9665
+ } else {
9666
+ // Load scorecards to get actual build count (not proposal count)
9667
+ let buildCount = 0;
9668
+ try {
9669
+ const scDir = path.join(context.cwd, ".waterbrother", "memory", "scorecards");
9670
+ const scIndex = JSON.parse(await fs.readFile(path.join(scDir, "index.json"), "utf8"));
9671
+ buildCount = Array.isArray(scIndex) ? scIndex.length : 0;
9672
+ } catch {}
9673
+ const safety = validateProposalSafety(
9674
+ await loadProposals(context.cwd).then((all) => all.find((p) => p.id === evolveTarget)) || { category: "unknown", patch: "" },
9675
+ buildCount
9676
+ );
9677
+ if (!safety.safe) {
9678
+ console.log(red(`Safety check failed: ${safety.reason}`));
9679
+ } else {
9680
+ const approved = await approveProposal(context.cwd, evolveTarget);
9681
+ if (!approved) {
9682
+ console.log("Proposal not found.");
9683
+ } else if (approved.status !== "approved") {
9684
+ console.log(`Proposal ${evolveTarget} is in status "${approved.status}" — only pending proposals can be approved.`);
9685
+ } else {
9686
+ console.log(green(`Approved: ${approved.description}`));
9687
+ console.log(dim("Patch will be injected as a Learned Convention in the next build."));
9688
+ }
9689
+ }
9690
+ }
9691
+ } else if (evolveSubCmd === "reject") {
9692
+ if (!evolveTarget) {
9693
+ console.log("Usage: /evolve reject <id>");
9694
+ } else {
9695
+ const rejected = await rejectProposal(context.cwd, evolveTarget);
9696
+ if (!rejected) {
9697
+ console.log("Proposal not found.");
9698
+ } else {
9699
+ console.log(`Rejected: ${rejected.description}`);
9700
+ }
9701
+ }
9702
+ } else if (evolveSubCmd === "revert") {
9703
+ if (!evolveTarget) {
9704
+ console.log("Usage: /evolve revert <id>");
9705
+ } else {
9706
+ const proposals = await loadProposals(context.cwd);
9707
+ const target = proposals.find((p) => p.id === evolveTarget);
9708
+ if (!target) {
9709
+ console.log("Proposal not found.");
9710
+ } else {
9711
+ const reverted = await revertProposal(context.cwd, evolveTarget);
9712
+ if (reverted) {
9713
+ console.log(`Reverted: ${reverted.description}`);
9714
+ }
9715
+ }
9716
+ }
9717
+ } else if (evolveSubCmd === "history") {
9718
+ const proposals = await loadProposals(context.cwd);
9719
+ if (proposals.length === 0) {
9720
+ console.log("No evolution history.");
9721
+ } else {
9722
+ console.log("--- Evolution History ---");
9723
+ for (const p of proposals) {
9724
+ const statusColor = p.status === "approved" ? green : p.status === "rejected" ? red : p.status === "reverted" ? yellow : dim;
9725
+ const scoreDelta = p.scoreBeforeApply != null && p.scoreAfterApply != null
9726
+ ? ` score: ${(p.scoreBeforeApply * 100).toFixed(0)}% -> ${(p.scoreAfterApply * 100).toFixed(0)}%`
9727
+ : "";
9728
+ console.log(` ${statusColor(p.status.padEnd(10))} ${p.id} [${p.category}] ${p.description}${scoreDelta}`);
9729
+ }
9730
+ }
9731
+ } else {
9732
+ console.log("Usage: /evolve [propose|approve <id>|reject <id>|revert <id>|history]");
9733
+ }
9734
+ } catch (err) {
9735
+ console.log(`Evolution error: ${err instanceof Error ? err.message : String(err)}`);
9736
+ }
9737
+ continue;
9738
+ }
9739
+
9350
9740
  if (line === "/experiment" || line.startsWith("/experiment ")) {
9351
9741
  // Parse: /experiment <goal> --metric "command" --attempts N --time M
9352
9742
  const rawArgs = line.replace("/experiment", "").trim();
@@ -10066,7 +10456,23 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
10066
10456
  continue;
10067
10457
  }
10068
10458
 
10459
+ // Check user-authored skills before rejecting unknown slash commands
10069
10460
  if (line.startsWith("/")) {
10461
+ let skillHandled = false;
10462
+ try {
10463
+ const { loadUserSkills, buildSkillExecutionMessage } = await import("./skills.js");
10464
+ const loadedSkills = await loadUserSkills(context.cwd);
10465
+ const matchedSkill = loadedSkills.find((s) => line === s.name || line.startsWith(s.name + " "));
10466
+ if (matchedSkill) {
10467
+ skillHandled = true;
10468
+ const skillArgs = line.slice(matchedSkill.name.length).trim();
10469
+ const skillMessage = buildSkillExecutionMessage(matchedSkill, skillArgs);
10470
+ await runTextTurnInteractive({ agent, currentSession, context, promptText: skillMessage, pendingInput: line, spinnerLabel: `running skill ${matchedSkill.name}...` });
10471
+ }
10472
+ } catch (skillErr) {
10473
+ process.stderr.write(`[skills] Skill dispatch failed: ${skillErr instanceof Error ? skillErr.message : String(skillErr)}\n`);
10474
+ }
10475
+ if (skillHandled) continue;
10070
10476
  console.log("Unknown slash command. Use /help.");
10071
10477
  continue;
10072
10478
  }
@@ -10162,6 +10568,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
10162
10568
  task.state = "review-ready";
10163
10569
  await saveTask({ cwd: context.cwd, task });
10164
10570
  context.runtime.activeTask = task;
10571
+ receipt._buildCompletedAt = receipt._buildCompletedAt || new Date().toISOString();
10165
10572
  context.runtime.lastReceipt = receipt;
10166
10573
  }
10167
10574
  console.log(`────────────────────────────────────────────────────────────────────────`);