@wrongstack/webui-server 0.302.2 → 0.305.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/dist/index.js CHANGED
@@ -28,7 +28,14 @@ function isRecord(value) {
28
28
  var AUTONOMY_VALUES = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
29
29
  var CONTEXT_STRATEGY_VALUES = /* @__PURE__ */ new Set(["hybrid", "intelligent", "selective"]);
30
30
  var CONTEXT_MODE_VALUES = /* @__PURE__ */ new Set(["balanced", "frugal", "deep"]);
31
- var TOKEN_SAVING_TIER_VALUES = /* @__PURE__ */ new Set(["off", "minimal", "light", "medium", "aggressive"]);
31
+ var TOKEN_SAVING_TIER_VALUES = /* @__PURE__ */ new Set([
32
+ "auto",
33
+ "off",
34
+ "minimal",
35
+ "light",
36
+ "medium",
37
+ "aggressive"
38
+ ]);
32
39
  var ENHANCE_LANGUAGE_VALUES = /* @__PURE__ */ new Set(["original", "english"]);
33
40
  var LOG_LEVEL_VALUES = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
34
41
  var AUDIT_LEVEL_VALUES = /* @__PURE__ */ new Set(["minimal", "standard", "full"]);
@@ -285,12 +292,8 @@ function validatePreferenceValue(key, value) {
285
292
  if (!Array.isArray(value)) return `prefs.update payload.${key} must be an array`;
286
293
  for (let i = 0; i < value.length; i++) {
287
294
  const item = value[i];
288
- if (!isRecord(item))
289
- return `prefs.update payload.${key}[${i}] must be an object`;
290
- const error2 = arrayValidator(
291
- item,
292
- `prefs.update payload.${key}[${i}]`
293
- );
295
+ if (!isRecord(item)) return `prefs.update payload.${key}[${i}] must be an object`;
296
+ const error2 = arrayValidator(item, `prefs.update payload.${key}[${i}]`);
294
297
  if (error2) return error2;
295
298
  }
296
299
  return null;
@@ -1099,11 +1102,11 @@ import { randomBytes } from "node:crypto";
1099
1102
  import { scrubErrorDetail } from "@wrongstack/core/security";
1100
1103
  import { WebSocket } from "ws";
1101
1104
  var WEBUI_WS_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
1102
- function sendSerialized(ws, data) {
1105
+ function sendSerialized(ws, data, frameBytes) {
1103
1106
  if (ws.readyState !== WebSocket.OPEN) return false;
1104
1107
  const buffered = Number.isFinite(ws.bufferedAmount) ? ws.bufferedAmount : 0;
1105
- const frameBytes = Buffer.byteLength(data, "utf8");
1106
- if (buffered + frameBytes > WEBUI_WS_MAX_BUFFERED_BYTES) {
1108
+ const bytes = frameBytes ?? Buffer.byteLength(data, "utf8");
1109
+ if (buffered + bytes > WEBUI_WS_MAX_BUFFERED_BYTES) {
1107
1110
  try {
1108
1111
  ws.terminate();
1109
1112
  } catch {
@@ -1126,8 +1129,9 @@ function send(ws, msg) {
1126
1129
  }
1127
1130
  function broadcast(clients, msg) {
1128
1131
  const data = JSON.stringify(msg);
1132
+ const frameBytes = Buffer.byteLength(data, "utf8");
1129
1133
  for (const [ws] of clients) {
1130
- sendSerialized(ws, data);
1134
+ sendSerialized(ws, data, frameBytes);
1131
1135
  }
1132
1136
  }
1133
1137
  function sendResult2(ws, success, message) {
@@ -1464,7 +1468,13 @@ function indexDbVersion(projectRoot, indexDir) {
1464
1468
  try {
1465
1469
  const dir = resolveIndexDir(projectRoot, indexDir);
1466
1470
  const st = fs.statSync(path.join(dir, DB_FILE));
1467
- return `${st.mtimeMs}:${st.size}`;
1471
+ let wal = "";
1472
+ try {
1473
+ const walSt = fs.statSync(path.join(dir, `${DB_FILE}-wal`));
1474
+ wal = `:${walSt.mtimeMs}:${walSt.size}`;
1475
+ } catch {
1476
+ }
1477
+ return `${st.mtimeMs}:${st.size}${wal}`;
1468
1478
  } catch {
1469
1479
  return "missing";
1470
1480
  }
@@ -2196,6 +2206,7 @@ var CollaborationWebSocketHandler = class {
2196
2206
  this.broadcast(sessionId, this.stateMessage(sessionId));
2197
2207
  }
2198
2208
  }, 2e3);
2209
+ this.broadcastInterval.unref?.();
2199
2210
  }
2200
2211
  stopBroadcast() {
2201
2212
  if (this.broadcastInterval) {
@@ -7211,13 +7222,18 @@ var GoalWebSocketHandler = class {
7211
7222
  const { execFile: execFile2 } = await import("node:child_process");
7212
7223
  const result = await new Promise((resolve17) => {
7213
7224
  const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";
7214
- execFile2(npxCommand, ["tsc", "--noEmit"], { cwd, timeout: 6e4 }, (err, stdout, stderr) => {
7215
- if (err && err.code === "ENOENT") {
7216
- resolve17("[verify] tsc not found \u2014 skipping");
7217
- return;
7225
+ execFile2(
7226
+ npxCommand,
7227
+ ["tsc", "--noEmit"],
7228
+ { cwd, timeout: 6e4 },
7229
+ (err, stdout, stderr) => {
7230
+ if (err && err.code === "ENOENT") {
7231
+ resolve17("[verify] tsc not found \u2014 skipping");
7232
+ return;
7233
+ }
7234
+ resolve17(stdout + stderr);
7218
7235
  }
7219
- resolve17(stdout + stderr);
7220
- });
7236
+ );
7221
7237
  });
7222
7238
  if (result.includes("[verify]") || result.trim().length === 0) {
7223
7239
  return { ok: true };
@@ -7519,6 +7535,7 @@ ${result_.finalText.slice(0, 2e3)}`
7519
7535
  if (progress) this.broadcast({ type: "goal.progress", payload: progress });
7520
7536
  this.broadcastState();
7521
7537
  }, 2e3);
7538
+ this.broadcastInterval.unref?.();
7522
7539
  }
7523
7540
  stopBroadcast() {
7524
7541
  if (this.broadcastInterval) {
@@ -7643,8 +7660,9 @@ ${result_.finalText.slice(0, 2e3)}`
7643
7660
  }
7644
7661
  broadcast(msg) {
7645
7662
  const data = JSON.stringify(msg);
7663
+ const frameBytes = Buffer.byteLength(data, "utf8");
7646
7664
  for (const client of this.clients) {
7647
- sendSerialized(client.ws, data);
7665
+ sendSerialized(client.ws, data, frameBytes);
7648
7666
  }
7649
7667
  }
7650
7668
  send(client, msg) {
@@ -7689,12 +7707,34 @@ function handleTodosGet(ctx, ws) {
7689
7707
  payload: sessionPayload(ctx, { todos: [...ctx.context.todos] })
7690
7708
  });
7691
7709
  }
7692
- function handleTodosClear(ctx, ws) {
7693
- ctx.replaceTodos?.([]);
7694
- sendResult3(ctx, ws, true, "Todos cleared");
7695
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: [] }) });
7710
+ async function commitTodos(ctx, todos) {
7711
+ if (ctx.mutateTodos) {
7712
+ const result = await ctx.mutateTodos(todos);
7713
+ return { todos: result.todos, warnings: result.warnings ?? [] };
7714
+ }
7715
+ ctx.replaceTodos?.(todos);
7716
+ return { todos: [...todos], warnings: [] };
7717
+ }
7718
+ function managedProjectionMessage() {
7719
+ return "Kanban-bound todos are task projections. Change or remove the task from Kanban.";
7720
+ }
7721
+ async function handleTodosClear(ctx, ws) {
7722
+ if (ctx.context.todos.some((todo) => todo.kanbanBoardId && todo.kanbanTaskId)) {
7723
+ sendResult3(ctx, ws, false, managedProjectionMessage());
7724
+ return;
7725
+ }
7726
+ try {
7727
+ const result = await commitTodos(ctx, []);
7728
+ sendResult3(ctx, ws, true, "Todos cleared");
7729
+ ctx.broadcast({
7730
+ type: "todos.updated",
7731
+ payload: sessionPayload(ctx, { todos: result.todos })
7732
+ });
7733
+ } catch (error2) {
7734
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7735
+ }
7696
7736
  }
7697
- function handleTodosRemove(ctx, ws, payload) {
7737
+ async function handleTodosRemove(ctx, ws, payload) {
7698
7738
  if (!payload) {
7699
7739
  sendResult3(ctx, ws, false, "Missing id or index");
7700
7740
  return;
@@ -7711,12 +7751,27 @@ function handleTodosRemove(ctx, ws, payload) {
7711
7751
  sendResult3(ctx, ws, false, "Todo not found");
7712
7752
  return;
7713
7753
  }
7754
+ if (removed.kanbanBoardId && removed.kanbanTaskId) {
7755
+ sendResult3(ctx, ws, false, managedProjectionMessage());
7756
+ return;
7757
+ }
7714
7758
  const next = [...todos.slice(0, targetIndex), ...todos.slice(targetIndex + 1)];
7715
- ctx.replaceTodos?.(next);
7716
- sendResult3(ctx, ws, true, `Removed: ${removed.content}`);
7717
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
7759
+ try {
7760
+ const result = await commitTodos(ctx, next);
7761
+ sendResult3(ctx, ws, true, `Removed: ${removed.content}`);
7762
+ ctx.broadcast({
7763
+ type: "todos.updated",
7764
+ payload: sessionPayload(ctx, { todos: result.todos })
7765
+ });
7766
+ } catch (error2) {
7767
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7768
+ }
7718
7769
  }
7719
- function handleTodoUpdate(ctx, ws, payload) {
7770
+ async function handleTodoUpdate(ctx, ws, payload) {
7771
+ if (!payload || typeof payload.id !== "string" || payload.status !== void 0 && payload.status !== "pending" && payload.status !== "in_progress" && payload.status !== "completed" || payload.activeForm !== void 0 && typeof payload.activeForm !== "string") {
7772
+ sendResult3(ctx, ws, false, "Invalid todo update payload");
7773
+ return;
7774
+ }
7720
7775
  const index = ctx.context.todos.findIndex((todo) => todo.id === payload.id);
7721
7776
  const existing = ctx.context.todos[index];
7722
7777
  if (index === -1 || !existing) {
@@ -7729,9 +7784,25 @@ function handleTodoUpdate(ctx, ws, payload) {
7729
7784
  status: payload.status ?? existing.status,
7730
7785
  activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
7731
7786
  };
7732
- ctx.replaceTodos?.(next);
7733
- sendResult3(ctx, ws, true, `Todo "${existing.content}" updated`);
7734
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
7787
+ try {
7788
+ const result = await commitTodos(ctx, next);
7789
+ const projected = result.todos.find((todo) => todo.id === existing.id);
7790
+ const requestedStatus = payload.status ?? existing.status;
7791
+ const projectionRejected = Boolean(existing.kanbanBoardId && existing.kanbanTaskId) && projected?.status !== requestedStatus;
7792
+ const warning = result.warnings[0];
7793
+ sendResult3(
7794
+ ctx,
7795
+ ws,
7796
+ !projectionRejected,
7797
+ projectionRejected ? warning ?? `Kanban kept "${existing.content}" at ${projected?.status ?? "its current state"}.` : warning ? `Todo "${existing.content}" updated. ${warning}` : `Todo "${existing.content}" updated`
7798
+ );
7799
+ ctx.broadcast({
7800
+ type: "todos.updated",
7801
+ payload: sessionPayload(ctx, { todos: result.todos })
7802
+ });
7803
+ } catch (error2) {
7804
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7805
+ }
7735
7806
  }
7736
7807
  async function handleTasksGet(ctx, ws) {
7737
7808
  const taskPath = taskPathOf(ctx);
@@ -7759,14 +7830,32 @@ async function handleTaskUpdate(ctx, ws, payload) {
7759
7830
  return;
7760
7831
  }
7761
7832
  try {
7762
- const file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
7763
- const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
7764
- if (!task) return tasks;
7765
- task.status = payload.status;
7766
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7767
- return tasks;
7768
- });
7769
- sendResult3(ctx, ws, true, `Task status updated to "${payload.status}".`);
7833
+ let file;
7834
+ if (ctx.mutateTaskStatus) {
7835
+ const result = await ctx.mutateTaskStatus(payload.id, payload.status);
7836
+ if (!result.ok) {
7837
+ sendResult3(ctx, ws, false, result.message);
7838
+ return;
7839
+ }
7840
+ file = await loadTasks(taskPath);
7841
+ if (!file) throw new Error("Task mutation succeeded but its persisted snapshot is missing.");
7842
+ sendResult3(ctx, ws, true, result.message);
7843
+ } else {
7844
+ let matched = false;
7845
+ file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
7846
+ const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
7847
+ if (!task) return tasks;
7848
+ matched = true;
7849
+ task.status = payload.status;
7850
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7851
+ return tasks;
7852
+ });
7853
+ if (!matched) {
7854
+ sendResult3(ctx, ws, false, `Task "${payload.id}" not found.`);
7855
+ return;
7856
+ }
7857
+ sendResult3(ctx, ws, true, `Task status updated to "${payload.status}".`);
7858
+ }
7770
7859
  ctx.broadcast({
7771
7860
  type: "tasks.updated",
7772
7861
  payload: sessionPayload(ctx, { tasks: file.tasks })
@@ -7813,6 +7902,18 @@ async function handlePlanTemplateUse(ctx, ws, template) {
7813
7902
  return;
7814
7903
  }
7815
7904
  try {
7905
+ if (ctx.mutatePlan) {
7906
+ const result = await ctx.mutatePlan({ action: "template_use", template });
7907
+ if (!result.ok) {
7908
+ sendResult3(ctx, ws, false, result.message);
7909
+ return;
7910
+ }
7911
+ const plan2 = await loadPlan(planPath);
7912
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
7913
+ sendResult3(ctx, ws, true, result.message);
7914
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
7915
+ return;
7916
+ }
7816
7917
  const templateDefinition = getPlanTemplate(template);
7817
7918
  if (!templateDefinition) {
7818
7919
  sendResult3(ctx, ws, false, `Unknown template "${template}".`);
@@ -7841,6 +7942,22 @@ async function handlePlanItemUpdate(ctx, ws, payload) {
7841
7942
  return;
7842
7943
  }
7843
7944
  try {
7945
+ if (ctx.mutatePlan) {
7946
+ const result = await ctx.mutatePlan({
7947
+ action: "status",
7948
+ target: payload.target,
7949
+ status: payload.status
7950
+ });
7951
+ if (!result.ok) {
7952
+ sendResult3(ctx, ws, false, result.message);
7953
+ return;
7954
+ }
7955
+ const plan2 = await loadPlan(planPath);
7956
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
7957
+ sendResult3(ctx, ws, true, result.message);
7958
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
7959
+ return;
7960
+ }
7844
7961
  let changed = false;
7845
7962
  const plan = await mutatePlan(planPath, currentSessionId(ctx), async (currentPlan) => {
7846
7963
  const before = currentPlan.updatedAt;
@@ -7864,13 +7981,17 @@ async function handleWorklistMessage(ctx, ws, message) {
7864
7981
  handleTodosGet(ctx, ws);
7865
7982
  return;
7866
7983
  case "todos.clear":
7867
- handleTodosClear(ctx, ws);
7984
+ await handleTodosClear(ctx, ws);
7868
7985
  return;
7869
7986
  case "todos.remove":
7870
- handleTodosRemove(ctx, ws, message.payload);
7987
+ await handleTodosRemove(
7988
+ ctx,
7989
+ ws,
7990
+ message.payload
7991
+ );
7871
7992
  return;
7872
7993
  case "todo.update":
7873
- handleTodoUpdate(
7994
+ await handleTodoUpdate(
7874
7995
  ctx,
7875
7996
  ws,
7876
7997
  message.payload
@@ -9452,7 +9573,19 @@ function isTrustedLoopbackOrigin(origin, hostHeader) {
9452
9573
  if (!host) return false;
9453
9574
  const hostUrl = new URL(`${url.protocol}//${host}`);
9454
9575
  if (!isLoopbackHostname(hostUrl.hostname)) return false;
9455
- return effectivePort(url) === effectivePort(hostUrl);
9576
+ return normalizeHostname(url.hostname) === normalizeHostname(hostUrl.hostname) && effectivePort(url) === effectivePort(hostUrl);
9577
+ } catch {
9578
+ return false;
9579
+ }
9580
+ }
9581
+ function originMatchesHost(origin, hostHeader) {
9582
+ try {
9583
+ const originUrl = new URL(origin);
9584
+ if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return false;
9585
+ const host = (hostHeader ?? "").trim();
9586
+ if (!host) return false;
9587
+ const requestUrl = new URL(`${originUrl.protocol}//${host}`);
9588
+ return normalizeHostname(originUrl.hostname) === normalizeHostname(requestUrl.hostname) && effectivePort(originUrl) === effectivePort(requestUrl);
9456
9589
  } catch {
9457
9590
  return false;
9458
9591
  }
@@ -9557,13 +9690,15 @@ function verifyClient(input) {
9557
9690
  try {
9558
9691
  const { hostname: originHostname } = new URL(origin);
9559
9692
  if (isLoopbackHostname(originHostname)) {
9560
- if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
9693
+ if (requireToken || !isLoopbackBind(wsHost)) {
9694
+ return cookieTokenOk && (originMatchesHost(origin, hostHeader) || Boolean(allowCrossPortLoopbackCookie));
9695
+ }
9561
9696
  if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
9562
9697
  return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
9563
9698
  }
9564
9699
  return true;
9565
9700
  }
9566
- return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
9701
+ return cookieTokenOk && originMatchesHost(origin, hostHeader) || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
9567
9702
  } catch {
9568
9703
  return false;
9569
9704
  }
@@ -10528,7 +10663,8 @@ async function handleIntrospectionRoute(ctx, ws, message) {
10528
10663
  switch (message.type) {
10529
10664
  case "diag.get": {
10530
10665
  if (!sessionAllowed(ctx, ws, message)) return true;
10531
- const tools = ctx.agent.tools.list();
10666
+ const registry = ctx.agent.tools;
10667
+ const tools = registry.listForProvider?.() ?? registry.list();
10532
10668
  ctx.send(ws, {
10533
10669
  type: "diag.get",
10534
10670
  payload: {
@@ -10611,6 +10747,7 @@ async function handleIntrospectionRoute(ctx, ws, message) {
10611
10747
  description: tool.description ?? "",
10612
10748
  params: schema.properties ? Object.keys(schema.properties) : [],
10613
10749
  disabled: registry.isDisabled?.(tool.name) ?? false,
10750
+ direct: registry.isExposedToProvider?.(tool.name) ?? true,
10614
10751
  mutating: !!tool.mutating,
10615
10752
  permission: tool.permission ?? "auto"
10616
10753
  };
@@ -10920,7 +11057,6 @@ async function handleKanbanHostRoute(ws, msg, handlers) {
10920
11057
  import { deserializeTaskGraph, serializeTaskGraph } from "@wrongstack/core/tasking";
10921
11058
  import {
10922
11059
  addCheckToTask,
10923
- addColumn,
10924
11060
  addGoalMetricToTask,
10925
11061
  addNoteToTask,
10926
11062
  addTask,
@@ -10928,14 +11064,17 @@ import {
10928
11064
  claimReadyTask,
10929
11065
  copyTaskToBoard,
10930
11066
  createBoard,
11067
+ createBoardFromText,
10931
11068
  duplicateBoard,
10932
11069
  exportBoardToTaskGraph,
10933
- createBoardFromText,
10934
11070
  getBoard,
10935
11071
  getKanbanOrchestrationSnapshot,
10936
11072
  getKanbanQueueHealth,
10937
11073
  getTaskChain,
11074
+ hasKanbanQueueAnomalies,
11075
+ kanbanQueueAnomalyCount,
10938
11076
  listBoards as listBoards2,
11077
+ listBoardHistory,
10939
11078
  listReadyTasks,
10940
11079
  mergeTasks,
10941
11080
  moveTask,
@@ -10944,7 +11083,6 @@ import {
10944
11083
  recoverStaleTaskAssignments,
10945
11084
  releaseTaskClaim,
10946
11085
  removeBoard,
10947
- removeColumn,
10948
11086
  setTaskChain,
10949
11087
  splitTask,
10950
11088
  syncBoardFromTaskGraph,
@@ -10956,14 +11094,16 @@ import {
10956
11094
  updateTask as updateTask2
10957
11095
  } from "@wrongstack/kanban";
10958
11096
 
10959
- // src/server/kanban-decomposition-routes.ts
11097
+ // src/server/kanban-contract-routes.ts
10960
11098
  import {
10961
- listBoards,
10962
- resolveDecompositionProposal,
10963
- updateTask,
10964
- verifyTaskCompletion
11099
+ addContractEdge,
11100
+ configureContractGraph,
11101
+ evaluateTaskContractGraph,
11102
+ getContractGraph,
11103
+ removeContractEdge,
11104
+ removeContractNode,
11105
+ upsertContractNode
10965
11106
  } from "@wrongstack/kanban";
10966
- import { recordKanbanVerificationEvidence as recordKanbanVerificationEvidence2 } from "@wrongstack/tools";
10967
11107
 
10968
11108
  // src/server/kanban-route-helpers.ts
10969
11109
  import { touchKanbanPresence } from "@wrongstack/kanban";
@@ -11015,7 +11155,150 @@ function findTask2(tasks, taskId) {
11015
11155
  return tasks.find((task) => task.id === taskId || task.id.startsWith(taskId));
11016
11156
  }
11017
11157
 
11158
+ // src/server/kanban-contract-routes.ts
11159
+ async function handleKanbanContractRoute(ws, type, payload, ctx) {
11160
+ switch (type) {
11161
+ case "kanban.contract.get":
11162
+ await handleGet(ws, type, payload, ctx);
11163
+ return true;
11164
+ case "kanban.contract.configure":
11165
+ await handleConfigure(ws, type, payload, ctx);
11166
+ return true;
11167
+ case "kanban.contract.node.upsert":
11168
+ await handleNodeUpsert(ws, type, payload, ctx);
11169
+ return true;
11170
+ case "kanban.contract.node.remove":
11171
+ await handleNodeRemove(ws, type, payload, ctx);
11172
+ return true;
11173
+ case "kanban.contract.edge.add":
11174
+ await handleEdgeAdd(ws, type, payload, ctx);
11175
+ return true;
11176
+ case "kanban.contract.edge.remove":
11177
+ await handleEdgeRemove(ws, type, payload, ctx);
11178
+ return true;
11179
+ default:
11180
+ return false;
11181
+ }
11182
+ }
11183
+ var str = (payload, key) => typeof payload?.[key] === "string" ? payload[key] : void 0;
11184
+ async function publishBoard(ctx, board) {
11185
+ await publishKanbanBoard((message) => ctx.broadcast?.(message), board);
11186
+ }
11187
+ async function handleGet(ws, type, payload, ctx) {
11188
+ const boardId = str(payload, "boardId");
11189
+ if (!boardId) return fail(ws, type, "boardId required");
11190
+ const found = await getContractGraph(ctx.projectRoot, boardId);
11191
+ if (!found) return fail(ws, type, "Board not found");
11192
+ const taskId = str(payload, "taskId");
11193
+ const evaluated = taskId ? await evaluateTaskContractGraph(ctx.projectRoot, boardId, taskId) : null;
11194
+ if (taskId && !evaluated) return fail(ws, type, "Task not found on this board");
11195
+ ok(ws, type, {
11196
+ boardId,
11197
+ graph: found.graph,
11198
+ ...evaluated ? { evaluation: evaluated.evaluation } : {}
11199
+ });
11200
+ }
11201
+ async function handleConfigure(ws, type, payload, ctx) {
11202
+ const boardId = str(payload, "boardId");
11203
+ if (!boardId) return fail(ws, type, "boardId required");
11204
+ const enforcement = str(payload, "enforcement") ?? "advisory";
11205
+ const board = await configureContractGraph(ctx.projectRoot, boardId, enforcement);
11206
+ if (!board) return fail(ws, type, "Board not found");
11207
+ await publishBoard(ctx, board);
11208
+ ok(ws, type, { boardId, graph: board.contractGraph ?? null });
11209
+ }
11210
+ async function handleNodeUpsert(ws, type, payload, ctx) {
11211
+ const boardId = str(payload, "boardId");
11212
+ const taskId = str(payload, "taskId");
11213
+ const kind = str(payload, "kind");
11214
+ const title = str(payload, "title");
11215
+ if (!boardId || !taskId || !kind || !title) {
11216
+ return fail(ws, type, "boardId, taskId, kind, and title required");
11217
+ }
11218
+ const state = str(payload, "state");
11219
+ const waiverActor = str(payload, "waiverActor");
11220
+ const waiverReason = str(payload, "waiverReason");
11221
+ if (state === "waived" && (!waiverActor?.trim() || !waiverReason?.trim())) {
11222
+ return fail(ws, type, "A waived contract node requires waiverActor and waiverReason");
11223
+ }
11224
+ try {
11225
+ const result = await upsertContractNode(ctx.projectRoot, boardId, {
11226
+ taskId,
11227
+ kind,
11228
+ title,
11229
+ ...str(payload, "nodeId") !== void 0 ? { id: str(payload, "nodeId") } : {},
11230
+ ...str(payload, "description") !== void 0 ? { description: str(payload, "description") } : {},
11231
+ ...state !== void 0 ? { state } : {},
11232
+ ...str(payload, "enforcement") !== void 0 ? { enforcement: str(payload, "enforcement") } : {},
11233
+ ...str(payload, "checkId") !== void 0 ? { checkId: str(payload, "checkId") } : {},
11234
+ ...str(payload, "metricId") !== void 0 ? { metricId: str(payload, "metricId") } : {},
11235
+ ...state === "waived" ? {
11236
+ waiver: {
11237
+ actor: waiverActor,
11238
+ reason: waiverReason,
11239
+ at: (/* @__PURE__ */ new Date()).toISOString()
11240
+ }
11241
+ } : {},
11242
+ ...str(payload, "createdBy") !== void 0 ? { createdBy: str(payload, "createdBy") } : { createdBy: "webui" }
11243
+ });
11244
+ if (!result) return fail(ws, type, "Board or task not found");
11245
+ await publishBoard(ctx, result.board);
11246
+ ok(ws, type, { boardId, node: result.node, graph: result.board.contractGraph ?? null });
11247
+ } catch (err) {
11248
+ fail(ws, type, err instanceof Error ? err.message : String(err));
11249
+ }
11250
+ }
11251
+ async function handleNodeRemove(ws, type, payload, ctx) {
11252
+ const boardId = str(payload, "boardId");
11253
+ const nodeId = str(payload, "nodeId");
11254
+ if (!boardId || !nodeId) return fail(ws, type, "boardId and nodeId required");
11255
+ const board = await removeContractNode(ctx.projectRoot, boardId, nodeId);
11256
+ if (!board) return fail(ws, type, "Contract node not found");
11257
+ await publishBoard(ctx, board);
11258
+ ok(ws, type, { boardId, graph: board.contractGraph ?? null });
11259
+ }
11260
+ async function handleEdgeAdd(ws, type, payload, ctx) {
11261
+ const boardId = str(payload, "boardId");
11262
+ const from = str(payload, "from");
11263
+ const to = str(payload, "to");
11264
+ const edgeType = str(payload, "edgeType");
11265
+ if (!boardId || !from || !to || !edgeType) {
11266
+ return fail(ws, type, "boardId, from, to, and edgeType required");
11267
+ }
11268
+ try {
11269
+ const result = await addContractEdge(ctx.projectRoot, boardId, {
11270
+ from,
11271
+ to,
11272
+ type: edgeType,
11273
+ ...str(payload, "enforcement") !== void 0 ? { enforcement: str(payload, "enforcement") } : {},
11274
+ ...str(payload, "rationale") !== void 0 ? { rationale: str(payload, "rationale") } : {},
11275
+ createdBy: str(payload, "createdBy") ?? "webui"
11276
+ });
11277
+ if (!result) return fail(ws, type, "Board not found");
11278
+ await publishBoard(ctx, result.board);
11279
+ ok(ws, type, { boardId, edge: result.edge, graph: result.board.contractGraph ?? null });
11280
+ } catch (err) {
11281
+ fail(ws, type, err instanceof Error ? err.message : String(err));
11282
+ }
11283
+ }
11284
+ async function handleEdgeRemove(ws, type, payload, ctx) {
11285
+ const boardId = str(payload, "boardId");
11286
+ const edgeId = str(payload, "edgeId");
11287
+ if (!boardId || !edgeId) return fail(ws, type, "boardId and edgeId required");
11288
+ const board = await removeContractEdge(ctx.projectRoot, boardId, edgeId);
11289
+ if (!board) return fail(ws, type, "Contract edge not found");
11290
+ await publishBoard(ctx, board);
11291
+ ok(ws, type, { boardId, graph: board.contractGraph ?? null });
11292
+ }
11293
+
11018
11294
  // src/server/kanban-decomposition-routes.ts
11295
+ import {
11296
+ listBoards,
11297
+ resolveDecompositionProposal,
11298
+ updateTask,
11299
+ verifyTaskCompletion
11300
+ } from "@wrongstack/kanban";
11301
+ import { recordKanbanVerificationEvidence as recordKanbanVerificationEvidence2 } from "@wrongstack/tools";
11019
11302
  async function handleKanbanDecompositionRoute(ws, type, payload, ctx) {
11020
11303
  switch (type) {
11021
11304
  case "kanban.decomposition.approve":
@@ -11118,8 +11401,35 @@ async function handleTaskVerification(ws, type, payload, ctx) {
11118
11401
  }
11119
11402
  }
11120
11403
 
11404
+ // src/server/kanban-route-pagination.ts
11405
+ function paginateKanbanBoards(boards, input) {
11406
+ const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize)));
11407
+ const activeSessionIds = new Set(input.activeSessionIds ?? []);
11408
+ const isActive = (board) => board.presence?.some((entry) => entry.active) === true || board.tags?.some((tag) => tag.startsWith("session:") && activeSessionIds.has(tag.slice(8))) === true;
11409
+ const sorted = [...boards].sort((left, right) => {
11410
+ const activityOrder = Number(isActive(right)) - Number(isActive(left));
11411
+ return activityOrder || right.updatedAt.localeCompare(left.updatedAt);
11412
+ });
11413
+ const activeTotal = sorted.filter(isActive).length;
11414
+ const total = sorted.length;
11415
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
11416
+ const requestedPage = Number.isFinite(input.page) ? Math.floor(input.page) : 1;
11417
+ const page = Math.min(totalPages, Math.max(1, requestedPage));
11418
+ const start = (page - 1) * pageSize;
11419
+ return {
11420
+ items: sorted.slice(start, start + pageSize),
11421
+ total,
11422
+ page,
11423
+ pageSize,
11424
+ totalPages,
11425
+ activeTotal,
11426
+ orphanedTotal: total - activeTotal
11427
+ };
11428
+ }
11429
+
11121
11430
  // src/server/kanban-task-routes.ts
11122
11431
  import {
11432
+ getKanbanWorkbench,
11123
11433
  getTask,
11124
11434
  listTaskActivity,
11125
11435
  recordTaskActivity,
@@ -11127,6 +11437,16 @@ import {
11127
11437
  } from "@wrongstack/kanban";
11128
11438
  async function handleKanbanTaskRoute(ws, type, payload, ctx) {
11129
11439
  switch (type) {
11440
+ case "kanban.workbench":
11441
+ ok(
11442
+ ws,
11443
+ type,
11444
+ await getKanbanWorkbench(ctx.projectRoot, {
11445
+ ...typeof payload?.limitPerLane === "number" ? { limitPerLane: payload.limitPerLane } : {},
11446
+ ...typeof payload?.alertLimit === "number" ? { alertLimit: payload.alertLimit } : {}
11447
+ })
11448
+ );
11449
+ return true;
11130
11450
  case "kanban.task.remove":
11131
11451
  await handleTaskRemove(ws, type, payload, ctx);
11132
11452
  return true;
@@ -11219,45 +11539,20 @@ async function handleTaskActivityAdd(ws, type, payload, ctx) {
11219
11539
  outcome,
11220
11540
  ...typeof payload?.details === "string" && payload.details.trim() ? { details: payload.details.trim() } : {}
11221
11541
  },
11222
- activityContext(
11223
- ctx,
11224
- payload?.actor ?? ctx.context?.agentId ?? "webui"
11225
- )
11542
+ activityContext(ctx, payload?.actor ?? ctx.context?.agentId ?? "webui")
11226
11543
  );
11227
11544
  board ? ok(ws, type, board) : fail(ws, type, "Board or task not found");
11228
11545
  }
11229
11546
 
11230
- // src/server/kanban-route-pagination.ts
11231
- function paginateKanbanBoards(boards, input) {
11232
- const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize)));
11233
- const activeSessionIds = new Set(input.activeSessionIds ?? []);
11234
- const isActive = (board) => board.presence?.some((entry) => entry.active) === true || board.tags?.some((tag) => tag.startsWith("session:") && activeSessionIds.has(tag.slice(8))) === true;
11235
- const sorted = [...boards].sort((left, right) => {
11236
- const activityOrder = Number(isActive(right)) - Number(isActive(left));
11237
- return activityOrder || right.updatedAt.localeCompare(left.updatedAt);
11238
- });
11239
- const activeTotal = sorted.filter(isActive).length;
11240
- const total = sorted.length;
11241
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
11242
- const requestedPage = Number.isFinite(input.page) ? Math.floor(input.page) : 1;
11243
- const page = Math.min(totalPages, Math.max(1, requestedPage));
11244
- const start = (page - 1) * pageSize;
11245
- return {
11246
- items: sorted.slice(start, start + pageSize),
11247
- total,
11248
- page,
11249
- pageSize,
11250
- totalPages,
11251
- activeTotal,
11252
- orphanedTotal: total - activeTotal
11253
- };
11254
- }
11255
-
11256
11547
  // src/server/kanban-route-protocol.ts
11257
11548
  var KANBAN_CLIENT_MESSAGE_TYPES = [
11258
11549
  "kanban.capabilities",
11259
- "kanban.column.add",
11260
- "kanban.column.remove",
11550
+ "kanban.contract.configure",
11551
+ "kanban.contract.edge.add",
11552
+ "kanban.contract.edge.remove",
11553
+ "kanban.contract.get",
11554
+ "kanban.contract.node.remove",
11555
+ "kanban.contract.node.upsert",
11261
11556
  "kanban.create",
11262
11557
  "kanban.decomposition.approve",
11263
11558
  "kanban.decomposition.reject",
@@ -11297,7 +11592,8 @@ var KANBAN_CLIENT_MESSAGE_TYPES = [
11297
11592
  "kanban.task.verify",
11298
11593
  "kanban.taskgraph.export",
11299
11594
  "kanban.taskgraph.sync",
11300
- "kanban.update"
11595
+ "kanban.update",
11596
+ "kanban.workbench"
11301
11597
  ];
11302
11598
 
11303
11599
  // src/server/kanban-routes.ts
@@ -11307,6 +11603,7 @@ async function handleKanbanRoute(ws, msg, ctx) {
11307
11603
  const type = msg.type;
11308
11604
  try {
11309
11605
  if (await handleKanbanDecompositionRoute(ws, type, payload, ctx)) return true;
11606
+ if (await handleKanbanContractRoute(ws, type, payload, ctx)) return true;
11310
11607
  if (await handleKanbanTaskRoute(ws, type, payload, ctx)) return true;
11311
11608
  switch (type) {
11312
11609
  case "kanban.list": {
@@ -11345,7 +11642,14 @@ async function handleKanbanRoute(ws, msg, ctx) {
11345
11642
  fail(ws, type, "boardId required");
11346
11643
  return true;
11347
11644
  }
11348
- ok(ws, type, await getKanbanQueueHealth(ctx.projectRoot, { boardId: hBoardId }));
11645
+ ok(
11646
+ ws,
11647
+ type,
11648
+ await getKanbanQueueHealth(ctx.projectRoot, {
11649
+ boardId: hBoardId,
11650
+ includeClassifications: false
11651
+ })
11652
+ );
11349
11653
  return true;
11350
11654
  }
11351
11655
  case "kanban.supervisor.status":
@@ -11375,16 +11679,16 @@ async function handleKanbanRoute(ws, msg, ctx) {
11375
11679
  reason: "On-demand standalone Kanban supervisor audit."
11376
11680
  }) : null;
11377
11681
  if (recovered) health = await getKanbanQueueHealth(ctx.projectRoot, { boardId });
11378
- const anomalyCount = health.staleAssignments.count + health.dependencyBlocked.count + health.failedRetryable.count + health.counts.failed + health.counts.blocked;
11682
+ const anomalyCount = kanbanQueueAnomalyCount(health);
11379
11683
  ok(ws, type, {
11380
11684
  boardId,
11381
- status: board.supervisor?.enabled === false ? "disabled" : anomalyCount ? "attention" : "healthy",
11685
+ status: board.supervisor?.enabled === false ? "disabled" : hasKanbanQueueAnomalies(health) ? "attention" : "healthy",
11382
11686
  mode: board.supervisor?.mode ?? "deterministic",
11383
11687
  lastAuditAt: (/* @__PURE__ */ new Date()).toISOString(),
11384
11688
  reconciledTaskIds: reconciled?.tasks.map((task) => task.id) ?? [],
11385
11689
  staleRecoveredTaskIds: recovered?.tasks.map((task) => task.id) ?? [],
11386
11690
  anomalyCount,
11387
- summary: `${health.counts.running} running \xB7 ${health.counts.ready} ready \xB7 ${health.counts.review} review \xB7 ${health.counts.blocked} blocked \xB7 ${health.counts.failed} failed`
11691
+ summary: `${health.counts.running} running \xB7 ${health.counts.startable} ready \xB7 ${health.counts.review} review \xB7 ${health.counts.blocked} blocked \xB7 ${health.counts.failed} failed`
11388
11692
  });
11389
11693
  return true;
11390
11694
  }
@@ -11401,7 +11705,6 @@ async function handleKanbanRoute(ws, msg, ctx) {
11401
11705
  title,
11402
11706
  ...payload?.description ? { description: payload.description } : {},
11403
11707
  ...payload?.tags ? { tags: payload.tags } : {},
11404
- ...payload?.columns ? { columns: payload.columns } : {},
11405
11708
  ...has(payload, "lifecycle") ? { lifecycle: payload?.lifecycle } : {},
11406
11709
  ...has(payload, "boundary") ? { boundary: payload?.boundary } : {}
11407
11710
  })
@@ -11418,7 +11721,6 @@ async function handleKanbanRoute(ws, msg, ctx) {
11418
11721
  ...payload?.title ? { title: payload.title } : {},
11419
11722
  ...payload?.description ? { description: payload.description } : {},
11420
11723
  ...payload?.tags ? { tags: payload.tags } : {},
11421
- ...payload?.columns ? { columns: payload.columns } : {},
11422
11724
  ...has(payload, "lifecycle") ? {
11423
11725
  lifecycle: payload?.lifecycle ?? null
11424
11726
  } : {},
@@ -11465,6 +11767,12 @@ async function handleKanbanRoute(ws, msg, ctx) {
11465
11767
  });
11466
11768
  return true;
11467
11769
  }
11770
+ case "kanban.board.history": {
11771
+ const boardId = payload?.boardId;
11772
+ const history = await listBoardHistory(ctx.projectRoot, boardId);
11773
+ ok(ws, type, history);
11774
+ return true;
11775
+ }
11468
11776
  case "kanban.generate": {
11469
11777
  const description = payload?.description;
11470
11778
  if (!description) {
@@ -11926,8 +12234,14 @@ async function handleKanbanRoute(ws, msg, ctx) {
11926
12234
  taskId,
11927
12235
  {
11928
12236
  description,
12237
+ // The old cast listed `manual | auto | agent | test | review` —
12238
+ // three of which have no verifier plugin, while the six that do
12239
+ // (command, file_exists, file_matches, git_diff, metric, test)
12240
+ // were unreachable. `notes` carries the executable body every
12241
+ // deterministic plugin reads.
11929
12242
  type: payload?.checkType ?? "manual",
11930
- status: payload?.status ?? "pending"
12243
+ status: payload?.status ?? "pending",
12244
+ ...typeof payload?.notes === "string" ? { notes: payload.notes } : {}
11931
12245
  },
11932
12246
  activityContext(
11933
12247
  ctx,
@@ -11992,30 +12306,6 @@ async function handleKanbanRoute(ws, msg, ctx) {
11992
12306
  case "kanban.capabilities":
11993
12307
  ok(ws, type, { dispatchSupported: Boolean(ctx.dispatchTask) });
11994
12308
  return true;
11995
- case "kanban.column.add": {
11996
- const boardId = payload?.boardId;
11997
- const title = payload?.title;
11998
- if (!boardId || !title) {
11999
- fail(ws, type, "boardId and title required");
12000
- return true;
12001
- }
12002
- const result = await addColumn(ctx.projectRoot, boardId, { title });
12003
- result ? ok(ws, type, result.board.columns) : fail(ws, type, `Board not found: ${boardId}`);
12004
- return true;
12005
- }
12006
- case "kanban.column.remove": {
12007
- const boardId = payload?.boardId;
12008
- const columnId = payload?.columnId;
12009
- if (!boardId || !columnId) {
12010
- fail(ws, type, "boardId and columnId required");
12011
- return true;
12012
- }
12013
- const board = await removeColumn(ctx.projectRoot, boardId, columnId, {
12014
- moveTasksToColumnId: payload?.moveTasksToColumnId
12015
- });
12016
- board ? ok(ws, type, { removed: true, boardId: board.id, columnId, board }) : fail(ws, type, `Column not found: ${columnId}`);
12017
- return true;
12018
- }
12019
12309
  default:
12020
12310
  fail(ws, type, `Unknown kanban message type: ${type}`);
12021
12311
  return true;
@@ -12078,7 +12368,9 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
12078
12368
  projectRoot,
12079
12369
  async (event) => {
12080
12370
  const family = event.event?.split(".")[0];
12081
- if (family !== "board" && family !== "task" && family !== "column") return;
12371
+ if (family !== "board" && family !== "task" && family !== "column" && family !== "contract") {
12372
+ return;
12373
+ }
12082
12374
  const evData = event.data;
12083
12375
  const boardId = evData?.boardId;
12084
12376
  if (!boardId) return;
@@ -13283,6 +13575,34 @@ async function handleSageRecover(ws, msg, memoryStore) {
13283
13575
  send(ws, { type: "memory.sage.recover", payload: { error: errMessage(err) } });
13284
13576
  }
13285
13577
  }
13578
+ async function handleSageListCandidates(ws, msg, memoryStore) {
13579
+ const Sage = getSageSurface(memoryStore);
13580
+ if (!Sage) {
13581
+ send(ws, {
13582
+ type: "memory.sage.listCandidates",
13583
+ payload: { error: requiresSage("memory.sage.listCandidates") }
13584
+ });
13585
+ return;
13586
+ }
13587
+ try {
13588
+ const payload = msg.payload ?? {};
13589
+ const includeResolved = payload["includeResolved"] === true;
13590
+ if (typeof Sage.listCandidates !== "function") {
13591
+ send(ws, {
13592
+ type: "memory.sage.listCandidates",
13593
+ payload: { error: "listCandidates is not available on this SAGE surface" }
13594
+ });
13595
+ return;
13596
+ }
13597
+ const candidates = await Sage.listCandidates(includeResolved);
13598
+ send(ws, { type: "memory.sage.listCandidates", payload: { candidates } });
13599
+ } catch (err) {
13600
+ send(ws, {
13601
+ type: "memory.sage.listCandidates",
13602
+ payload: { error: errMessage(err) }
13603
+ });
13604
+ }
13605
+ }
13286
13606
  async function handleSageCandidateResolve(ws, msg, memoryStore) {
13287
13607
  const Sage = getSageSurface(memoryStore);
13288
13608
  if (!Sage) {
@@ -13395,14 +13715,17 @@ async function handleSageForFile(ws, msg, memoryStore) {
13395
13715
  send(ws, { type: "memory.sage.forFile", payload: { error: "filePath is required" } });
13396
13716
  return;
13397
13717
  }
13718
+ const includeSuperseded = typeof payload["showSuperseded"] === "boolean" ? payload["showSuperseded"] : typeof payload["includeSuperseded"] === "boolean" ? payload["includeSuperseded"] : void 0;
13719
+ const includeDeleted = payload["showDeleted"] === true || payload["includeDeleted"] === true;
13398
13720
  try {
13399
13721
  const response = await Sage.findMemoriesForFile(filePath, {
13400
13722
  ...typeof payload["lineStart"] === "number" ? { lineStart: payload["lineStart"] } : {},
13401
13723
  ...typeof payload["lineEnd"] === "number" ? { lineEnd: payload["lineEnd"] } : {},
13402
13724
  ...typeof payload["limit"] === "number" ? { limit: payload["limit"] } : {},
13403
- ...payload["includeDeleted"] === true ? { includeDeleted: true } : {}
13725
+ ...includeSuperseded !== void 0 ? { includeSuperseded } : {},
13726
+ ...includeDeleted ? { includeDeleted: true } : {}
13404
13727
  });
13405
- send(ws, { type: "memory.sage.forFile", payload: response });
13728
+ send(ws, { type: "memory.sage.forFile", payload: { response } });
13406
13729
  } catch (err) {
13407
13730
  send(ws, { type: "memory.sage.forFile", payload: { error: errMessage(err) } });
13408
13731
  }
@@ -13458,6 +13781,9 @@ async function handleMemoryRoute(ctx, ws, message) {
13458
13781
  case "memory.sage.recover":
13459
13782
  await handleSageRecover(ws, message, store);
13460
13783
  return true;
13784
+ case "memory.sage.listCandidates":
13785
+ await handleSageListCandidates(ws, message, store);
13786
+ return true;
13461
13787
  case "memory.sage.candidateResolve":
13462
13788
  await handleSageCandidateResolve(ws, message, store);
13463
13789
  return true;
@@ -15024,6 +15350,7 @@ import {
15024
15350
  finalizeTaskCompletion,
15025
15351
  getBoard as getBoard3,
15026
15352
  getKanbanQueueHealth as getKanbanQueueHealth2,
15353
+ kanbanQueueAnomalyCount as kanbanQueueAnomalyCount2,
15027
15354
  listBoards as listBoards4,
15028
15355
  reconcileKanbanBoard as reconcileKanbanBoard2,
15029
15356
  recoverStaleTaskAssignments as recoverStaleTaskAssignments2,
@@ -15104,8 +15431,9 @@ function createKanbanSupervisor(deps2) {
15104
15431
  mode: config.recoveryMode ?? "auto",
15105
15432
  reason: "Kanban supervisor found an expired worker lease."
15106
15433
  }) : null;
15107
- if (recovered) health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
15108
- const anomalyCount = countAnomalies(health);
15434
+ if (recovered)
15435
+ health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
15436
+ const anomalyCount = kanbanQueueAnomalyCount2(health);
15109
15437
  const snapshot = {
15110
15438
  boardId: board.id,
15111
15439
  status: anomalyCount > 0 ? "attention" : "healthy",
@@ -15199,7 +15527,9 @@ function createKanbanSupervisor(deps2) {
15199
15527
  } else {
15200
15528
  const summaries = await listBoards4(resolveProjectRoot(deps2));
15201
15529
  pruneAbsentBoards(new Set(summaries.map((summary) => summary.id)));
15202
- boards = (await Promise.all(summaries.map((summary) => getBoard3(resolveProjectRoot(deps2), summary.id)))).filter((board) => Boolean(board));
15530
+ boards = (await Promise.all(
15531
+ summaries.map((summary) => getBoard3(resolveProjectRoot(deps2), summary.id))
15532
+ )).filter((board) => Boolean(board));
15203
15533
  }
15204
15534
  const results = [];
15205
15535
  for (const board of boards) results.push(await auditBoard(board));
@@ -15302,13 +15632,10 @@ function dispatchRoute(routing) {
15302
15632
  ...routing.fallbackModels?.length ? { fallbackModels: routing.fallbackModels } : {}
15303
15633
  };
15304
15634
  }
15305
- function countAnomalies(health) {
15306
- return health.staleAssignments.count + health.heartbeatDue.count + health.counts.failed + health.counts.blocked;
15307
- }
15308
15635
  function healthSummary(health) {
15309
15636
  return [
15310
15637
  `${health.counts.running} running`,
15311
- `${health.counts.ready} ready`,
15638
+ `${health.counts.startable} ready`,
15312
15639
  `${health.counts.review} review`,
15313
15640
  `${health.counts.blocked} blocked`,
15314
15641
  `${health.counts.failed} failed`,
@@ -16070,7 +16397,7 @@ import { makeProviderFromConfig } from "@wrongstack/providers";
16070
16397
  import * as fs14 from "node:fs/promises";
16071
16398
  import * as path19 from "node:path";
16072
16399
  import { DefaultSessionStore } from "@wrongstack/core/storage";
16073
- import { resolveWstackPaths as resolveWstackPaths5 } from "@wrongstack/core/utils";
16400
+ import { activateProjectStateGuard, resolveWstackPaths as resolveWstackPaths5 } from "@wrongstack/core/utils";
16074
16401
  function createProjectHandlers(ctx) {
16075
16402
  const sendTo = (ws, message) => {
16076
16403
  if (ctx.sendMessage) ctx.sendMessage(ws, message);
@@ -16235,6 +16562,7 @@ function createProjectHandlers(ctx) {
16235
16562
  try {
16236
16563
  await ctx.onSessionSwapped?.(next.id, identityTarget);
16237
16564
  await ctx.onBeforeSessionTodosReplaced?.(next.id, paths.projectSessions);
16565
+ await activateProjectStateGuard(resolved);
16238
16566
  } catch (err) {
16239
16567
  try {
16240
16568
  await ctx.onBeforeSessionTodosReplaced?.(previous.id, previousPaths.projectSessions);
@@ -17252,6 +17580,7 @@ var CLIENT_KNOWLEDGE_MESSAGE_TYPES = [
17252
17580
  "memory.sage.get",
17253
17581
  "memory.sage.graph",
17254
17582
  "memory.sage.list",
17583
+ "memory.sage.listCandidates",
17255
17584
  "memory.sage.listPage",
17256
17585
  "memory.sage.recover",
17257
17586
  "memory.sage.remember",
@@ -17531,6 +17860,7 @@ var SERVER_KNOWLEDGE_MESSAGE_TYPES = [
17531
17860
  "memory.sage.get",
17532
17861
  "memory.sage.graph",
17533
17862
  "memory.sage.list",
17863
+ "memory.sage.listCandidates",
17534
17864
  "memory.sage.listPage",
17535
17865
  "memory.sage.recover",
17536
17866
  "memory.sage.remember",
@@ -18382,6 +18712,7 @@ function createSessionHandlers(ctx) {
18382
18712
  ctx.abortActiveRun?.(current2.id);
18383
18713
  } catch {
18384
18714
  }
18715
+ await ctx.context.flushConversationJournal?.().catch(() => void 0);
18385
18716
  await finalizeSession(current2);
18386
18717
  }
18387
18718
  ctx.setSession(next);
@@ -19071,89 +19402,60 @@ function createEmbeddedProjectRoutes(ctx) {
19071
19402
 
19072
19403
  // src/server/embedded-message-router.ts
19073
19404
  import { makeProviderFromConfig as makeProviderFromConfig2 } from "@wrongstack/providers";
19405
+ import { planTool, taskTool, todoTool } from "@wrongstack/tools";
19074
19406
 
19075
19407
  // src/server/agent-roster-handlers.ts
19076
19408
  import {
19077
19409
  applyProjectAgentConfig,
19078
- buildConsolidationInstruction,
19079
19410
  captureLearnedFromAgentOutputDetailed,
19080
19411
  clearProjectAgentConsolidated,
19412
+ clearProjectSkillAugmentation,
19081
19413
  createProjectAgent,
19414
+ DEFAULT_EAGER_SKILL_LIMIT,
19082
19415
  detectLearnedConflicts,
19416
+ evaluateAutoOptimize,
19083
19417
  FLEET_ROSTER,
19084
19418
  getProjectAgentLearnStats,
19085
19419
  isConsolidated,
19086
19420
  listProjectAgentLearnedEntries,
19087
19421
  listProjectAgentRoles,
19422
+ listProjectSkillAugmentations,
19088
19423
  loadConsolidationMetadata,
19089
19424
  loadProjectAgentConfig,
19090
19425
  loadProjectAgentConsolidated,
19091
19426
  loadProjectAgentIdentity,
19092
19427
  loadProjectAgentLearned,
19093
19428
  loadProjectAgentProfile,
19429
+ loadProjectSkillAugmentation,
19430
+ loadSkillAffinity,
19431
+ optimizeProjectAgentLearning,
19432
+ rankRoleSkills,
19433
+ readQuarantinedDirectives,
19434
+ readRawLearnedEntries,
19094
19435
  resetProjectAgentIdentity,
19436
+ resolveAutoOptimizePolicy,
19437
+ resolveRoleSkillCandidates,
19095
19438
  saveProjectAgentConsolidated,
19439
+ saveProjectSkillAugmentation,
19440
+ scoreSkillAffinity,
19441
+ setSkillPinned,
19096
19442
  slugifyProjectAgentRole,
19097
19443
  updateProjectAgentConfig,
19098
19444
  updateProjectAgentIdentity,
19099
19445
  updateProjectAgentLearned,
19100
19446
  updateProjectAgentLearningPolicy
19101
19447
  } from "@wrongstack/core/coordination";
19102
- import { isTextBlock } from "@wrongstack/core/types";
19103
- var CONSOLIDATION_MAX_TOKENS = 8e3;
19104
- var CONSOLIDATION_TIMEOUT_MS = 12e4;
19105
19448
  var AgentRosterWSHandler = class {
19106
19449
  getProjectRoot;
19107
19450
  getLlm;
19108
19451
  broadcast;
19452
+ getAutoOptimizeSettings;
19109
19453
  constructor(opts) {
19110
19454
  this.getProjectRoot = typeof opts.projectRoot === "function" ? opts.projectRoot : () => opts.projectRoot;
19111
19455
  this.getLlm = opts.getLlm ?? (() => void 0);
19112
19456
  this.broadcast = opts.broadcast ?? (() => {
19113
19457
  });
19114
- }
19115
- /**
19116
- * Run the consolidation LLM synthesis headlessly and return the cleaned
19117
- * document text. Returns undefined when no LLM is available so the caller
19118
- * can fall back to the instruction-only path.
19119
- */
19120
- async synthesizeConsolidation(instruction) {
19121
- const llm = this.getLlm();
19122
- if (!llm) return void 0;
19123
- const req = {
19124
- model: llm.model,
19125
- system: [
19126
- {
19127
- type: "text",
19128
- text: "You are a precise technical editor. You consolidate an AI agent role's captured learning entries into a single, durable, role-scoped instruction document. Output ONLY the consolidated markdown document \u2014 no preamble, no code fences, no commentary about the consolidation process."
19129
- }
19130
- ],
19131
- messages: [{ role: "user", content: instruction }],
19132
- maxTokens: CONSOLIDATION_MAX_TOKENS
19133
- };
19134
- const timer = new AbortController();
19135
- let timedOut = false;
19136
- const to = setTimeout(() => {
19137
- timedOut = true;
19138
- timer.abort(new Error("consolidation timeout"));
19139
- }, CONSOLIDATION_TIMEOUT_MS);
19140
- to.unref();
19141
- try {
19142
- const res = await llm.provider.complete(req, { signal: timer.signal });
19143
- const text2 = res.content.filter(isTextBlock).map((block) => block.text).join("\n").trim();
19144
- const wholeDocFence = /^```(?:markdown|md)?[^\n]*\n([\s\S]*?)\n?```\s*$/i;
19145
- const wrapped = wholeDocFence.exec(text2);
19146
- const inner = wrapped?.[1];
19147
- const unfenced = inner !== void 0 ? inner.trim() : text2;
19148
- return { content: unfenced, model: llm.model };
19149
- } catch (err) {
19150
- if (timedOut) {
19151
- throw new Error(`consolidation timed out after ${CONSOLIDATION_TIMEOUT_MS}ms`);
19152
- }
19153
- throw err;
19154
- } finally {
19155
- clearTimeout(to);
19156
- }
19458
+ this.getAutoOptimizeSettings = opts.getAutoOptimizeSettings;
19157
19459
  }
19158
19460
  /** Handle an incoming client message. Returns a response payload. */
19159
19461
  async handleMessage(_ws, type, payload) {
@@ -19358,70 +19660,47 @@ ${String(p.content ?? "")}`;
19358
19660
  const conflicts = detectLearnedConflicts(projectRoot);
19359
19661
  return { type, payload: { conflicts } };
19360
19662
  }
19361
- // ── Consolidate learned entries (headless LLM synthesis) ──────────
19362
- // Runs the whole optimization on the server: read raw entries
19363
- // synthesize with the active model write consolidated.md +
19364
- // consolidation.json. No chat round-trip. When no LLM is available the
19365
- // handler degrades to returning the instruction so a caller (e.g. the
19366
- // chat agent) can still perform the consolidation manually.
19367
- case "agent-roster.consolidate": {
19663
+ // ── Optimize: distil captures into skill addenda + a consolidated doc,
19664
+ // then archive and reset the raw buffer. Shared implementation with the
19665
+ // CLI (`optimizeProjectAgentLearning`) so both surfaces persist the same
19666
+ // artifacts instead of the CLI producing markdown nobody saved.
19667
+ case "agent-roster.consolidate":
19668
+ case "agent-roster.optimize": {
19368
19669
  if (!role) return { type, payload: { error: "role required" } };
19369
- const { instruction, rawEntries, hasExistingConsolidation } = buildConsolidationInstruction(role, projectRoot);
19370
- if (rawEntries.length === 0) {
19670
+ const hasExistingConsolidation = isConsolidated(role, projectRoot);
19671
+ const pending = readRawLearnedEntries(role, projectRoot);
19672
+ if (pending.length === 0) {
19371
19673
  return {
19372
19674
  type: "agent-roster.consolidate",
19373
19675
  payload: {
19374
19676
  role,
19375
19677
  consolidated: false,
19376
19678
  rawEntryCount: 0,
19679
+ skills: [],
19377
19680
  hasExistingConsolidation,
19378
19681
  currentStats: getProjectAgentLearnStats(role, projectRoot)
19379
19682
  }
19380
19683
  };
19381
19684
  }
19382
- let synth;
19383
- try {
19384
- synth = await this.synthesizeConsolidation(instruction);
19385
- } catch (err) {
19386
- return {
19387
- type: "agent-roster.consolidate",
19388
- payload: {
19389
- role,
19390
- consolidated: false,
19391
- rawEntryCount: rawEntries.length,
19392
- hasExistingConsolidation,
19393
- currentStats: getProjectAgentLearnStats(role, projectRoot),
19394
- error: err instanceof Error ? err.message : "consolidation failed"
19395
- }
19396
- };
19397
- }
19398
- if (synth && synth.content.length > 0) {
19399
- let stats;
19400
- let metadata;
19401
- try {
19402
- saveProjectAgentConsolidated(role, synth.content, projectRoot, {
19403
- trigger: "manual",
19404
- model: synth.model
19405
- });
19406
- stats = getProjectAgentLearnStats(role, projectRoot);
19407
- metadata = loadConsolidationMetadata(role, projectRoot);
19408
- } catch (err) {
19409
- return {
19410
- type: "agent-roster.consolidate",
19411
- payload: {
19412
- role,
19413
- consolidated: false,
19414
- rawEntryCount: rawEntries.length,
19415
- hasExistingConsolidation,
19416
- currentStats: getProjectAgentLearnStats(role, projectRoot),
19417
- error: err instanceof Error ? err.message : "failed to persist consolidation"
19418
- }
19419
- };
19420
- }
19685
+ const llm = this.getLlm();
19686
+ const result = await optimizeProjectAgentLearning(role, projectRoot, {
19687
+ ...llm ? { llm } : {},
19688
+ trigger: "manual"
19689
+ });
19690
+ const currentStats = getProjectAgentLearnStats(role, projectRoot);
19691
+ const metadata = loadConsolidationMetadata(role, projectRoot);
19692
+ const basePayload = {
19693
+ role,
19694
+ rawEntryCount: result.rawEntryCount,
19695
+ skills: result.skills,
19696
+ hasExistingConsolidation,
19697
+ currentStats
19698
+ };
19699
+ if (result.status === "optimized") {
19421
19700
  try {
19422
19701
  this.broadcast({
19423
19702
  type: "agent-roster.updated",
19424
- payload: { role, reason: "consolidated", currentStats: stats, metadata }
19703
+ payload: { role, reason: "consolidated", currentStats, metadata }
19425
19704
  });
19426
19705
  } catch (e) {
19427
19706
  console.warn(
@@ -19437,44 +19716,111 @@ ${String(p.content ?? "")}`;
19437
19716
  return {
19438
19717
  type: "agent-roster.consolidate",
19439
19718
  payload: {
19440
- role,
19719
+ ...basePayload,
19441
19720
  consolidated: true,
19442
- rawEntryCount: rawEntries.length,
19443
- content: synth.content,
19444
- model: synth.model,
19445
- currentStats: stats,
19721
+ content: result.content,
19722
+ model: result.model,
19723
+ pruned: result.pruned,
19446
19724
  metadata
19447
19725
  }
19448
19726
  };
19449
19727
  }
19450
- if (synth) {
19451
- return {
19452
- type: "agent-roster.consolidate",
19453
- payload: {
19454
- role,
19455
- consolidated: false,
19456
- emptySynthesis: true,
19457
- model: synth.model,
19458
- rawEntryCount: rawEntries.length,
19459
- hasExistingConsolidation,
19460
- currentStats: getProjectAgentLearnStats(role, projectRoot)
19461
- }
19462
- };
19463
- }
19464
19728
  return {
19465
19729
  type: "agent-roster.consolidate",
19466
19730
  payload: {
19467
- role,
19731
+ ...basePayload,
19468
19732
  consolidated: false,
19469
- instruction,
19470
- rawEntryCount: rawEntries.length,
19471
- hasExistingConsolidation,
19472
- currentStats: getProjectAgentLearnStats(role, projectRoot),
19473
- // Instruction for the leader agent to execute the consolidation
19474
- leaderInstruction: `Optimize what the "${role}" agent has learned. Read its raw learned entries, synthesize them into a single narrowly-scoped document preserving every fact, and save the result. The instruction text contains the full details and raw entries.`
19733
+ ...result.status === "empty-synthesis" ? { emptySynthesis: true, model: result.model } : {},
19734
+ ...result.status === "failed" ? { error: result.error } : {},
19735
+ ...result.status === "no-llm" ? {
19736
+ instruction: result.instruction,
19737
+ leaderInstruction: `Optimize what the "${role}" agent has learned. Read its raw learned entries, synthesize them into a single narrowly-scoped document preserving every fact, and save the result. The instruction text contains the full details and raw entries.`
19738
+ } : {}
19739
+ }
19740
+ };
19741
+ }
19742
+ // ── Automatic-optimization status ─────────────────────────────────
19743
+ // Read-only: says whether the background scheduler considers each role
19744
+ // eligible right now, and why not when it does not. Surfacing the reason
19745
+ // is what keeps "nothing happened" from looking like a broken feature.
19746
+ case "agent-roster.auto-optimize-status": {
19747
+ const policy = resolveAutoOptimizePolicy(this.getAutoOptimizeSettings?.() ?? void 0);
19748
+ const roles = role ? [role] : listProjectAgentRoles(projectRoot);
19749
+ return {
19750
+ type,
19751
+ payload: {
19752
+ policy,
19753
+ roles: roles.map((current2) => {
19754
+ try {
19755
+ const decision = evaluateAutoOptimize(current2, projectRoot, policy);
19756
+ return { role: current2, ...decision };
19757
+ } catch {
19758
+ return { role: current2, eligible: false, reason: "disabled" };
19759
+ }
19760
+ })
19761
+ }
19762
+ };
19763
+ }
19764
+ // ── Skill layer: what this project has developed for each role skill ──
19765
+ case "agent-roster.skills": {
19766
+ if (!role) return { type, payload: { error: "role required" } };
19767
+ const candidates = resolveRoleSkillCandidates(role, projectRoot);
19768
+ const developed = listProjectSkillAugmentations(role, projectRoot);
19769
+ const affinity = loadSkillAffinity(role, projectRoot);
19770
+ const eager = new Set(rankRoleSkills(role, candidates, projectRoot));
19771
+ return {
19772
+ type,
19773
+ payload: {
19774
+ role,
19775
+ eagerLimit: DEFAULT_EAGER_SKILL_LIMIT,
19776
+ skills: candidates.map((skill) => ({
19777
+ skill,
19778
+ developed: developed.includes(skill),
19779
+ affinity: affinity.entries[skill] ?? null,
19780
+ score: scoreSkillAffinity(affinity.entries[skill]) + (developed.includes(skill) ? 1 : 0),
19781
+ eager: eager.has(skill)
19782
+ }))
19475
19783
  }
19476
19784
  };
19477
19785
  }
19786
+ // ── Directives the loop stopped believing ─────────────────────────────
19787
+ case "agent-roster.quarantine": {
19788
+ if (!role) return { type, payload: { error: "role required" } };
19789
+ return {
19790
+ type,
19791
+ payload: { role, retired: readQuarantinedDirectives(role, projectRoot) }
19792
+ };
19793
+ }
19794
+ case "agent-roster.read-skill": {
19795
+ const skill = typeof p.skill === "string" ? p.skill : "";
19796
+ if (!role || !skill) return { type, payload: { error: "role and skill required" } };
19797
+ return {
19798
+ type,
19799
+ payload: { role, skill, content: loadProjectSkillAugmentation(role, skill, projectRoot) }
19800
+ };
19801
+ }
19802
+ case "agent-roster.save-skill": {
19803
+ const skill = typeof p.skill === "string" ? p.skill : "";
19804
+ if (!role || !skill || typeof p.content !== "string") {
19805
+ return { type, payload: { error: "role, skill and content required" } };
19806
+ }
19807
+ const savedPath = saveProjectSkillAugmentation(role, skill, p.content, projectRoot);
19808
+ return { type, payload: { role, skill, path: savedPath, success: true } };
19809
+ }
19810
+ case "agent-roster.clear-skill": {
19811
+ const skill = typeof p.skill === "string" ? p.skill : "";
19812
+ if (!role) return { type, payload: { error: "role required" } };
19813
+ clearProjectSkillAugmentation(role, skill || void 0, projectRoot);
19814
+ return { type, payload: { role, skill: skill || null, success: true } };
19815
+ }
19816
+ case "agent-roster.pin-skill": {
19817
+ const skill = typeof p.skill === "string" ? p.skill : "";
19818
+ if (!role || !skill || typeof p.pinned !== "boolean") {
19819
+ return { type, payload: { error: "role, skill and boolean pinned required" } };
19820
+ }
19821
+ const affinity = setSkillPinned(role, skill, p.pinned, projectRoot);
19822
+ return { type, payload: { role, skill, pinned: p.pinned, affinity, success: true } };
19823
+ }
19478
19824
  // ── Save consolidated document ────────────────────────────────────
19479
19825
  case "agent-roster.save-consolidated": {
19480
19826
  if (!role || typeof p.content !== "string") {
@@ -20447,7 +20793,20 @@ function createEmbeddedMessageRouter(deps2) {
20447
20793
  },
20448
20794
  send: send2,
20449
20795
  broadcast: deps2.providerCtx.broadcast,
20450
- replaceTodos: (todos) => opts.agent.ctx.state.replaceTodos(todos)
20796
+ replaceTodos: (todos) => opts.agent.ctx.state.replaceTodos(todos),
20797
+ mutateTodos: async (todos) => {
20798
+ const result = await todoTool.execute({ todos }, opts.agent.ctx, {
20799
+ signal: AbortSignal.timeout(3e4)
20800
+ });
20801
+ return {
20802
+ todos: [...opts.agent.ctx.todos],
20803
+ ...result.kanban_warnings ? { warnings: result.kanban_warnings } : {}
20804
+ };
20805
+ },
20806
+ mutateTaskStatus: async (id, status) => taskTool.execute({ action: "status", id, status }, opts.agent.ctx, {
20807
+ signal: AbortSignal.timeout(3e4)
20808
+ }),
20809
+ mutatePlan: async (operation) => planTool.execute(operation, opts.agent.ctx, { signal: AbortSignal.timeout(3e4) })
20451
20810
  })
20452
20811
  });
20453
20812
  const processRoutes = {
@@ -20531,6 +20890,7 @@ function createEmbeddedMessageRouter(deps2) {
20531
20890
  agentRoster: {
20532
20891
  rosterHandler: new AgentRosterWSHandler({
20533
20892
  projectRoot,
20893
+ getAutoOptimizeSettings: () => deps2.agentConfigCtx.getConfig?.()?.fleet?.learning?.autoOptimize,
20534
20894
  getLlm: () => {
20535
20895
  const ctx = opts.agent.ctx;
20536
20896
  return ctx.provider && ctx.model ? { provider: ctx.provider, model: ctx.model } : void 0;
@@ -21836,6 +22196,7 @@ function registerSetupEventsStatusWatcher(deps2) {
21836
22196
  const logWatcherMetricsEnabled = shouldLogWatcherStats();
21837
22197
  const logWatcherMetrics = () => logFileWatcherMetrics(watcherMetrics);
21838
22198
  const metricsInterval = logWatcherMetricsEnabled ? setInterval(logWatcherMetrics, 6e4) : void 0;
22199
+ metricsInterval?.unref?.();
21839
22200
  const broadcastStatus = (_projectHash, statusData, actualDelayMs) => {
21840
22201
  broadcast2(clients, { type: "client.status_update", payload: statusData });
21841
22202
  if (watcherMetrics) {
@@ -23055,14 +23416,7 @@ import {
23055
23416
  toErrorMessage as toErrorMessage10
23056
23417
  } from "@wrongstack/core/utils";
23057
23418
  import { makeLightSubagentFactory } from "@wrongstack/runtime";
23058
- import {
23059
- createSageContextMonitorMiddleware,
23060
- createSageToolCallMiddleware,
23061
- createSageTurnMiddleware,
23062
- getSageRetrieval,
23063
- getSageService,
23064
- InjectionTracker
23065
- } from "@wrongstack/sage";
23419
+ import { getSageService, setupSage } from "@wrongstack/sage";
23066
23420
 
23067
23421
  // src/server/discover-mailbox-bridge.ts
23068
23422
  import { spawn as spawn4 } from "node:child_process";
@@ -23865,6 +24219,7 @@ var WorktreeWebSocketHandler = class {
23865
24219
  this.broadcast(this.stateMessage());
23866
24220
  if (this.broadcastInterval) return;
23867
24221
  this.broadcastInterval = setInterval(() => this.broadcast(this.stateMessage()), 2e3);
24222
+ this.broadcastInterval.unref?.();
23868
24223
  }
23869
24224
  stopBroadcast() {
23870
24225
  this.broadcast(this.stateMessage());
@@ -23916,62 +24271,15 @@ async function createAgentServices(input) {
23916
24271
  const collabPause = collabPauseMiddleware(collabBus, { logger });
23917
24272
  pipelines.toolCall.prepend(collabPause);
23918
24273
  installDesignStudioMiddleware({ pipelines, ctx: context });
23919
- const memoryRetrieval = getSageRetrieval(memoryStore);
23920
- if (config.features.memory !== false && config.Sage?.enabled !== false && memoryRetrieval) {
23921
- const sageInjectionTracker = new InjectionTracker();
23922
- const getSageSessionId = () => input.sessionGetter().id;
23923
- if (config.Sage?.inject?.toolResults !== false) {
23924
- pipelines.toolCall.use(
23925
- createSageToolCallMiddleware({
23926
- memory: memoryRetrieval,
23927
- maxHintsPerTool: config.Sage?.inject?.maxHintsPerTool,
23928
- maxCharsPerTool: config.Sage?.inject?.maxCharsPerTool,
23929
- taskAware: config.Sage?.inject?.taskAware,
23930
- minScore: config.Sage?.inject?.minScore,
23931
- minImportance: config.Sage?.inject?.minImportance,
23932
- // Forward the explicit relation floor so an operator-configured
23933
- // `Sage.inject.relationFloor` is honored in WebUI sessions. Without
23934
- // this we silently fall back to MIN_RELATION_STRENGTH (0.85), which
23935
- // is the CLI default but masks operator overrides.
23936
- relationFloor: config.Sage?.inject?.relationFloor,
23937
- repeatCooldownMs: config.Sage?.inject?.repeatCooldownMs,
23938
- verifyOnMutation: config.Sage?.hygiene?.autoOnFileChange,
23939
- triggers: config.Sage?.inject?.triggers,
23940
- // Resolve the live session so retrieval and cooldown are session-scoped
23941
- // (matching the CLI wiring in wiring/sage.ts). Without this, the
23942
- // middleware falls back to ctx.session.id for cooldown but passes
23943
- // undefined to retrieval, causing owned session-scoped memories to be
23944
- // silently excluded from tool-call injection.
23945
- getSessionId: getSageSessionId,
23946
- tracker: sageInjectionTracker,
23947
- events
23948
- })
23949
- );
23950
- }
23951
- if (config.Sage?.inject?.turnContext === true) {
23952
- pipelines.request.use(
23953
- createSageTurnMiddleware({
23954
- memory: memoryRetrieval,
23955
- maxMemories: config.Sage?.inject?.maxTurnMemories,
23956
- maxChars: config.Sage?.inject?.maxCharsPerTurn,
23957
- minScore: config.Sage?.inject?.minScore,
23958
- // CLI parity: honor `Sage.retrieval.metadataWeight` so the same config
23959
- // value drives both runtimes instead of silently falling back to the
23960
- // 0.3 default. The undefined case keeps the middleware's own default.
23961
- metadataWeight: config.Sage?.retrieval?.metadataWeight,
23962
- getSessionId: getSageSessionId,
23963
- tracker: sageInjectionTracker
23964
- })
23965
- );
23966
- }
23967
- pipelines.request.use(
23968
- createSageContextMonitorMiddleware({
23969
- tracker: sageInjectionTracker,
23970
- events,
23971
- getSessionId: getSageSessionId
23972
- })
23973
- );
23974
- }
24274
+ const runSageSessionHygiene = setupSage({
24275
+ config,
24276
+ pipelines,
24277
+ memoryStore,
24278
+ logger,
24279
+ events,
24280
+ getSessionId: () => input.sessionGetter().id,
24281
+ projectRoot
24282
+ });
23975
24283
  const codebaseIndexing = setupWebUICodebaseIndexing({
23976
24284
  config,
23977
24285
  context,
@@ -24083,7 +24391,12 @@ async function createAgentServices(input) {
24083
24391
  confirmAwaiter: void 0,
24084
24392
  iterationTimeoutMs: config.tools?.iterationTimeoutMs ?? DEFAULT_TOOLS_CONFIG.iterationTimeoutMs,
24085
24393
  perIterationOutputCapBytes: config.tools?.perIterationOutputCapBytes ?? DEFAULT_TOOLS_CONFIG.perIterationOutputCapBytes,
24086
- tracer: void 0
24394
+ tracer: void 0,
24395
+ // Off unless the operator opts in. The WebUI drives the same agent as the
24396
+ // CLI, so it must resolve this identically — a surface-dependent gate
24397
+ // would mean the same repo governs under `wstack` and not in the browser.
24398
+ // See packages/cli/src/wiring/pipeline.ts.
24399
+ requireKanbanGovernance: config.tools?.kanbanGovernance ?? DEFAULT_TOOLS_CONFIG.kanbanGovernance
24087
24400
  });
24088
24401
  input.installToolBoundary?.(pipelines);
24089
24402
  const webuiLogger = container.resolve(TOKENS2.Logger);
@@ -24103,6 +24416,7 @@ async function createAgentServices(input) {
24103
24416
  providers: providerRegistry,
24104
24417
  events,
24105
24418
  pipelines,
24419
+ refreshSystemPrompt: true,
24106
24420
  context,
24107
24421
  maxIterations: config.tools?.maxIterations ?? DEFAULT_TOOLS_CONFIG.maxIterations,
24108
24422
  iterationTimeoutMs: config.tools?.iterationTimeoutMs ?? DEFAULT_TOOLS_CONFIG.iterationTimeoutMs,
@@ -24370,6 +24684,7 @@ async function createAgentServices(input) {
24370
24684
  terminalHandler,
24371
24685
  collabHandler,
24372
24686
  disposeRealtimeHandlers,
24687
+ runSageSessionHygiene,
24373
24688
  updateAutoCompactionMaxContext
24374
24689
  };
24375
24690
  }
@@ -24526,6 +24841,7 @@ async function setupWebUiGovernance(input, dependencies = DEFAULT_DEPENDENCIES)
24526
24841
 
24527
24842
  // src/server/message-dispatcher.ts
24528
24843
  import path28 from "node:path";
24844
+ import { planTool as planTool2, taskTool as taskTool2, todoTool as todoTool2 } from "@wrongstack/tools";
24529
24845
  function createMessageDispatcher(opts) {
24530
24846
  const { state, deps: deps2, routes, promptsCtx, codebaseIndexing, runLock, pendingConfirms } = opts;
24531
24847
  function makeWorklistContext() {
@@ -24537,7 +24853,20 @@ function createMessageDispatcher(opts) {
24537
24853
  },
24538
24854
  send: (w, m) => send(w, m),
24539
24855
  broadcast: (m) => broadcast(state.getClients(), m),
24540
- replaceTodos: (todos) => deps2.context.state.replaceTodos(todos)
24856
+ replaceTodos: (todos) => deps2.context.state.replaceTodos(todos),
24857
+ mutateTodos: async (todos) => {
24858
+ const result = await todoTool2.execute({ todos }, deps2.context, {
24859
+ signal: AbortSignal.timeout(3e4)
24860
+ });
24861
+ return {
24862
+ todos: [...deps2.context.todos],
24863
+ ...result.kanban_warnings ? { warnings: result.kanban_warnings } : {}
24864
+ };
24865
+ },
24866
+ mutateTaskStatus: async (id, status) => taskTool2.execute({ action: "status", id, status }, deps2.context, {
24867
+ signal: AbortSignal.timeout(3e4)
24868
+ }),
24869
+ mutatePlan: async (operation) => planTool2.execute(operation, deps2.context, { signal: AbortSignal.timeout(3e4) })
24541
24870
  };
24542
24871
  }
24543
24872
  function makeSkillsContext() {
@@ -24749,6 +25078,7 @@ function createMessageDispatcher(opts) {
24749
25078
  agentRoster: {
24750
25079
  rosterHandler: new AgentRosterWSHandler({
24751
25080
  projectRoot: state.getProjectRoot,
25081
+ getAutoOptimizeSettings: () => state.getConfig().fleet?.learning?.autoOptimize,
24752
25082
  getLlm: () => {
24753
25083
  const ctx = deps2.agent.ctx;
24754
25084
  return ctx.provider && ctx.model ? { provider: ctx.provider, model: ctx.model } : void 0;
@@ -24834,15 +25164,9 @@ import {
24834
25164
  makeMailInboxTool,
24835
25165
  makeMailSendTool
24836
25166
  } from "@wrongstack/core/coordination";
24837
- import {
24838
- DefaultPromptLoader,
24839
- DefaultSkillLoader
24840
- } from "@wrongstack/core/execution";
24841
- import {
24842
- EventBus,
24843
- TOKENS as TOKENS3
24844
- } from "@wrongstack/core/kernel";
25167
+ import { DefaultPromptLoader, DefaultSkillLoader } from "@wrongstack/core/execution";
24845
25168
  import { DefaultTokenCounter } from "@wrongstack/core/infrastructure";
25169
+ import { EventBus, TOKENS as TOKENS3 } from "@wrongstack/core/kernel";
24846
25170
  import { DefaultModelsRegistry, DefaultModeStore } from "@wrongstack/core/models";
24847
25171
  import { ProviderRegistry, ToolRegistry } from "@wrongstack/core/registry";
24848
25172
  import { SkillInstaller } from "@wrongstack/core/skills";
@@ -25500,6 +25824,7 @@ async function createPreContextServices(input) {
25500
25824
  modeId,
25501
25825
  modePrompt,
25502
25826
  modelCapabilities: () => modelCapabilitiesRef.current,
25827
+ tokenSavingMode: config.features.tokenSavingMode,
25503
25828
  instructionPaths: {
25504
25829
  globalDir: wpaths.globalInstructions,
25505
25830
  projectDir: wpaths.inProjectInstructions,
@@ -25520,7 +25845,8 @@ async function createPreContextServices(input) {
25520
25845
  const systemPrompt = await systemPromptBuilder.build({
25521
25846
  cwd: projectRoot,
25522
25847
  projectRoot,
25523
- tools: toolRegistry.list(),
25848
+ tools: toolRegistry.listForProvider(),
25849
+ catalogTools: toolRegistry.list(),
25524
25850
  provider: config.provider,
25525
25851
  model: config.model,
25526
25852
  onlineAgents
@@ -25538,6 +25864,7 @@ async function createPreContextServices(input) {
25538
25864
  projectRoot,
25539
25865
  model: config.model
25540
25866
  });
25867
+ context.meta["promptOnlineAgents"] = onlineAgents;
25541
25868
  const initialContextPolicy = resolveContextWindowPolicy3(config.context);
25542
25869
  context.meta["contextWindowMode"] = initialContextPolicy.id;
25543
25870
  context.meta["contextWindowPolicy"] = initialContextPolicy;
@@ -25610,6 +25937,7 @@ function createModeHandlers(context) {
25610
25937
  modeId: id,
25611
25938
  modePrompt,
25612
25939
  modelCapabilities: context.modelCapabilities,
25940
+ tokenSavingMode: config.features?.tokenSavingMode,
25613
25941
  instructionPaths: {
25614
25942
  globalDir: paths.globalInstructions,
25615
25943
  projectDir: paths.inProjectInstructions,
@@ -25619,7 +25947,8 @@ function createModeHandlers(context) {
25619
25947
  context.context.systemPrompt = await builder.build({
25620
25948
  cwd: context.projectRoot,
25621
25949
  projectRoot: context.projectRoot,
25622
- tools: context.toolRegistry.list(),
25950
+ tools: context.toolRegistry.listForProvider(),
25951
+ catalogTools: context.toolRegistry.list(),
25623
25952
  provider: config.provider,
25624
25953
  model: config.model
25625
25954
  });
@@ -25959,11 +26288,11 @@ function buildRoutes(state, deps2, cb) {
25959
26288
  }
25960
26289
 
25961
26290
  // src/server/server-runtime.ts
25962
- import * as path33 from "node:path";
25963
26291
  import { createRequire as createRequire4 } from "node:module";
26292
+ import * as path33 from "node:path";
25964
26293
  import { fileURLToPath } from "node:url";
25965
- import { WebSocketServer } from "ws";
25966
26294
  import { toErrorMessage as toErrorMessage13 } from "@wrongstack/core/utils";
26295
+ import { WebSocketServer } from "ws";
25967
26296
  async function resolvePorts(opts) {
25968
26297
  const surface = opts.surface ?? "webui";
25969
26298
  const surfaceDefaults = surface === "simpleui" ? { http: 3466 } : { http: 3456 };
@@ -26894,15 +27223,7 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
26894
27223
  eternalSubscription = null;
26895
27224
  }
26896
27225
  codebaseIndexing.dispose();
26897
- if (config.Sage?.enabled !== false && config.Sage?.hygiene?.autoAfterSession !== false) {
26898
- const candidate = memoryStore;
26899
- await candidate.hygiene?.({
26900
- retentionDays: config.Sage?.hygiene?.retentionDays,
26901
- archiveLowConfidenceAfterDays: config.Sage?.hygiene?.archiveLowConfidenceAfterDays
26902
- }).catch(
26903
- (err) => logger.warn(`sage session hygiene failed: ${toErrorMessage14(err)}`)
26904
- );
26905
- }
27226
+ await agentServices.runSageSessionHygiene().catch((err) => logger.warn(`sage session hygiene failed: ${toErrorMessage14(err)}`));
26906
27227
  await memoryStore.dispose().catch(
26907
27228
  (err) => logger.warn(`sage connection disposal failed: ${toErrorMessage14(err)}`)
26908
27229
  );