@tritard/waterbrother 0.16.96 → 0.16.98

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.96",
3
+ "version": "0.16.98",
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/gateway.js CHANGED
@@ -9,7 +9,7 @@ import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayBridge, loadGatewayStat
9
9
  import { getGatewayStatus, getChannelSpec } from "./channels.js";
10
10
  import { getConfigPath, loadConfigLayers, saveConfig } from "./config.js";
11
11
  import { canonicalizeLoosePath } from "./path-utils.js";
12
- import { acceptSharedInvite, addSharedRoomNote, addSharedTask, approveSharedInvite, assignSharedTask, claimSharedOperator, claimSharedTask, commentSharedTask, createSharedInvite, enableSharedProject, getSharedTaskHistory, listSharedEvents, listSharedInvites, listSharedTasks, loadSharedProject, moveSharedTask, rejectSharedInvite, releaseSharedOperator, setSharedRoom, setSharedRoomMode, setSharedRuntimeProfile, removeSharedMember, upsertSharedAgent, upsertSharedMember } from "./shared-project.js";
12
+ import { acceptSharedInvite, addSharedRoomNote, addSharedTask, addVerificationResult, approveSharedInvite, assignSharedTask, claimSharedOperator, claimSharedTask, commentSharedTask, createSharedInvite, enableSharedProject, getLatestVerificationResult, getSharedTaskHistory, listSharedEvents, listSharedInvites, listSharedTasks, loadSharedProject, moveSharedTask, rejectSharedInvite, releaseSharedOperator, removeSharedMember, setSharedRoom, setSharedRoomMode, setSharedRuntimeProfile, setSharedVerificationMode, upsertSharedAgent, upsertSharedMember } from "./shared-project.js";
13
13
  import { buildSelfAwarenessManifest, formatAboutWaterbrother, formatSelfState, resolveLocalConceptQuestion } from "./self-awareness.js";
14
14
 
15
15
  const execFileAsync = promisify(execFile);
@@ -296,6 +296,7 @@ function buildTelegramRoomOnboardingMarkup({ project = null, actorName = "", exe
296
296
  const participants = listProjectParticipants(project);
297
297
  const agents = listProjectAgents(project);
298
298
  const reviewer = chooseReviewerAgent(project);
299
+ const verifier = chooseVerifierAgent(project);
299
300
  const blockingReview = getLatestBlockingReviewPolicy(project);
300
301
  return [
301
302
  "<b>Roundtable room</b>",
@@ -305,6 +306,7 @@ function buildTelegramRoomOnboardingMarkup({ project = null, actorName = "", exe
305
306
  `agents: <code>${escapeTelegramHtml(String(agents.length || 0))}</code>`,
306
307
  executor?.provider && executor?.model ? `active runtime: <code>${escapeTelegramHtml(`${executor.provider}/${executor.model}`)}</code>` : "",
307
308
  reviewer ? `reviewer: <code>${escapeTelegramHtml(reviewer.ownerName || reviewer.label || reviewer.ownerId || reviewer.id || "unknown")}</code>` : "",
309
+ verifier ? `verifier: <code>${escapeTelegramHtml(verifier.ownerName || verifier.label || verifier.ownerId || verifier.id || "unknown")}</code>` : "",
308
310
  `blocking review: <code>${escapeTelegramHtml(blockingReview ? "yes" : "no")}</code>`,
309
311
  "",
310
312
  "<b>What you can do here</b>",
@@ -312,6 +314,7 @@ function buildTelegramRoomOnboardingMarkup({ project = null, actorName = "", exe
312
314
  "• ask <code>who is in the room?</code>",
313
315
  "• say <code>add Austin as editor</code>",
314
316
  "• ask <code>which terminals are live?</code>",
317
+ "• say <code>run verification</code>",
315
318
  "• in execute mode, address Waterbrother directly to build or change code",
316
319
  "",
317
320
  "Use <code>/help</code> for the full command list."
@@ -336,7 +339,9 @@ function buildTelegramRoomGuidanceMarkup({ project = null, member = null, execut
336
339
  "• <code>what project is this chat bound to?</code>",
337
340
  "• <code>who is in the room?</code>",
338
341
  "• <code>who are the bots?</code>",
339
- role === "owner" ? "• <code>add Austin as editor</code>" : "• <code>what mode are we in?</code>"
342
+ role === "owner" ? "• <code>add Austin as editor</code>" : "• <code>what mode are we in?</code>",
343
+ "• <code>who is the verifier?</code>",
344
+ "• <code>run verification</code>"
340
345
  ].filter(Boolean).join("\n");
341
346
  }
342
347
 
@@ -353,6 +358,8 @@ function buildTelegramAgentAnnouncementMarkup(event = {}) {
353
358
  ? "This terminal is the selected executor and can take room work when it is live."
354
359
  : role === "reviewer"
355
360
  ? "This terminal is the room reviewer and can be asked for a second opinion or blocking review."
361
+ : role === "verifier"
362
+ ? "This terminal is the verifier and can run tests, checks, and build verification for the room."
356
363
  : "This terminal is on standby until the room assigns it a more active role.";
357
364
  return [
358
365
  `<b>${escapeTelegramHtml(title)}</b>`,
@@ -363,7 +370,7 @@ function buildTelegramAgentAnnouncementMarkup(event = {}) {
363
370
  runtime ? `runtime: <code>${escapeTelegramHtml(runtime)}</code>` : "",
364
371
  meta.runtimeProfile ? `runtime profile: <code>${escapeTelegramHtml(String(meta.runtimeProfile || ""))}</code>` : "",
365
372
  roleGuidance,
366
- "Examples: <code>which terminals are live?</code>, <code>make this terminal the reviewer</code>, <code>make this terminal the executor</code>"
373
+ "Examples: <code>which terminals are live?</code>, <code>make this terminal the reviewer</code>, <code>make this terminal the verifier</code>, <code>make this terminal the executor</code>"
367
374
  ].filter(Boolean).join("\n");
368
375
  }
369
376
 
@@ -526,6 +533,11 @@ function chooseReviewerAgent(project) {
526
533
  return agents.find((agent) => String(agent?.role || "").trim() === "reviewer") || null;
527
534
  }
528
535
 
536
+ function chooseVerifierAgent(project) {
537
+ const agents = listProjectAgents(project);
538
+ return agents.find((agent) => String(agent?.role || "").trim() === "verifier") || null;
539
+ }
540
+
529
541
  function summarizeExecutorReviewerArbitration(project, fallbackExecutor = {}) {
530
542
  const executorAgent = chooseExecutorAgent(project, fallbackExecutor);
531
543
  const reviewerAgent = chooseReviewerAgent(project);
@@ -541,6 +553,29 @@ function summarizeExecutorReviewerArbitration(project, fallbackExecutor = {}) {
541
553
  };
542
554
  }
543
555
 
556
+ function formatVerificationResultMarkup(result = {}, verifier = null) {
557
+ const lines = [
558
+ "<b>Verification result</b>",
559
+ `outcome: <code>${escapeTelegramHtml(String(result.outcome || "failed"))}</code>`
560
+ ];
561
+ if (verifier) {
562
+ lines.splice(1, 0, `verifier: <code>${escapeTelegramHtml(verifier.ownerName || verifier.label || verifier.ownerId || verifier.id || "unknown")}</code>`);
563
+ }
564
+ if (Array.isArray(result.commands) && result.commands.length) {
565
+ lines.push("commands:");
566
+ for (const command of result.commands) {
567
+ lines.push(`• <code>${escapeTelegramHtml(command)}</code>`);
568
+ }
569
+ }
570
+ if (result.summary) {
571
+ lines.push("", "<b>Summary</b>", escapeTelegramHtml(result.summary));
572
+ }
573
+ if (Array.isArray(result.logs) && result.logs.length) {
574
+ lines.push("", "<b>Top errors</b>", ...result.logs.slice(0, 6).map((line) => `• ${escapeTelegramHtml(line)}`));
575
+ }
576
+ return lines.filter(Boolean).join("\n");
577
+ }
578
+
544
579
  function parseReviewerOutcome(text = "") {
545
580
  const value = String(text || "").trim();
546
581
  const lower = value.toLowerCase();
@@ -783,15 +818,15 @@ function parseTelegramAgentIntent(text = "") {
783
818
  }
784
819
  }
785
820
 
786
- const roleMatch = lowered.match(/\b(executor|reviewer|standby)\b/);
821
+ const roleMatch = lowered.match(/\b(executor|reviewer|verifier|standby)\b/);
787
822
  const role = roleMatch?.[1] || "";
788
823
  if (!role) return null;
789
824
 
790
825
  const patterns = [
791
- /^(?:have|make|set)\s+(.+?)['’]s\s+(?:bot|terminal)\s+(?:be|as|to)?\s*(?:the\s+)?(executor|reviewer|standby)\s*$/i,
792
- /^(?:have|make|set)\s+(.+?)\s+(?:bot|terminal)\s+(?:be|as|to)?\s*(?:the\s+)?(executor|reviewer|standby)\s*$/i,
793
- /^(.+?)['’]s\s+(?:bot|terminal)\s+should\s+(?:be\s+)?(?:the\s+)?(executor|reviewer|standby)\s*$/i,
794
- /^(.+?)\s+(?:bot|terminal)\s+should\s+(?:be\s+)?(?:the\s+)?(executor|reviewer|standby)\s*$/i
826
+ /^(?:have|make|set)\s+(.+?)['’]s\s+(?:bot|terminal)\s+(?:be|as|to)?\s*(?:the\s+)?(executor|reviewer|verifier|standby)\s*$/i,
827
+ /^(?:have|make|set)\s+(.+?)\s+(?:bot|terminal)\s+(?:be|as|to)?\s*(?:the\s+)?(executor|reviewer|verifier|standby)\s*$/i,
828
+ /^(.+?)['’]s\s+(?:bot|terminal)\s+should\s+(?:be\s+)?(?:the\s+)?(executor|reviewer|verifier|standby)\s*$/i,
829
+ /^(.+?)\s+(?:bot|terminal)\s+should\s+(?:be\s+)?(?:the\s+)?(executor|reviewer|verifier|standby)\s*$/i
795
830
  ];
796
831
  for (const pattern of patterns) {
797
832
  const match = value.match(pattern);
@@ -834,9 +869,28 @@ function parseTelegramStateIntent(text = "") {
834
869
  if (/\bwhat can i do here\b/.test(lower) || /\bhow do i use this room\b/.test(lower) || /\bhow do i use this chat\b/.test(lower) || /\bwhat do i do here\b/.test(lower)) {
835
870
  return { action: "room-guidance" };
836
871
  }
837
- if (/\bwho are the bots\b/.test(lower) || /\bwho are the agents\b/.test(lower) || /\bwhat bots are here\b/.test(lower) || /\bwhat agents are here\b/.test(lower)) {
872
+ if (/\bwho is the verifier\b/.test(lower) || /\bwhat is the verifier\b/.test(lower) || /\bwho should verify this\b/.test(lower)) {
873
+ return { action: "verifier-status" };
874
+ }
875
+ if (/\bwho are the (?:bots|boys)\b/.test(lower) || /\bwho are the agents\b/.test(lower) || /\bwhat (?:bots|boys) are here\b/.test(lower) || /\bwhat agents are here\b/.test(lower)) {
838
876
  return { action: "agent-list" };
839
877
  }
878
+ if (/^(?:run|start|do)\s+verification\b/.test(lower) || /^(?:verify)\b/.test(lower) || /\bshow latest verification result\b/.test(lower) || /\bdid verification pass\b/.test(lower)) {
879
+ return { action: /\bshow latest verification result\b/.test(lower) || /\bdid verification pass\b/.test(lower) ? "verification-status" : "run-verification" };
880
+ }
881
+ if (/\bverification mode\b/.test(lower)) {
882
+ const modeMatch = lower.match(/\b(off|manual|auto|blocking)\b/);
883
+ return modeMatch?.[1] ? { action: "verification-mode-set", mode: modeMatch[1] } : { action: "verification-mode-status" };
884
+ }
885
+ if (/\balways verify after execution\b/.test(lower)) {
886
+ return { action: "verification-mode-set", mode: "auto" };
887
+ }
888
+ if (/\bturn off verification\b/.test(lower)) {
889
+ return { action: "verification-mode-set", mode: "off" };
890
+ }
891
+ if (/\bverification should be blocking\b/.test(lower)) {
892
+ return { action: "verification-mode-set", mode: "blocking" };
893
+ }
840
894
  if (/\bwhich terminals are live\b/.test(lower) || /\bwhich bots are live\b/.test(lower) || /\bwho is live\b/.test(lower) || /\bwhat terminals are live\b/.test(lower)) {
841
895
  return { action: "live-hosts" };
842
896
  }
@@ -980,11 +1034,14 @@ function formatTelegramRoomMarkup(project, options = {}) {
980
1034
  const liveHosts = Array.isArray(options.liveHosts) ? options.liveHosts : [];
981
1035
  const selectedExecutor = chooseExecutorAgent(project, executor);
982
1036
  const selectedReviewer = chooseReviewerAgent(project);
1037
+ const selectedVerifier = chooseVerifierAgent(project);
983
1038
  const blockingReview = getLatestBlockingReviewPolicy(project);
984
1039
  const latestReviewResult = getLatestReviewResult(project);
985
1040
  const latestReviewMeta = latestReviewResult?.meta && typeof latestReviewResult.meta === "object" ? latestReviewResult.meta : {};
1041
+ const latestVerificationResult = getLatestVerificationResult(project);
986
1042
  const selectedExecutorLiveHost = selectedExecutor ? findLiveHostForAgent(liveHosts, selectedExecutor) : null;
987
1043
  const selectedReviewerLiveHost = selectedReviewer ? findLiveHostForAgent(liveHosts, selectedReviewer) : null;
1044
+ const selectedVerifierLiveHost = selectedVerifier ? findLiveHostForAgent(liveHosts, selectedVerifier) : null;
988
1045
  const executorBits = [
989
1046
  `surface: <code>${escapeTelegramHtml(executor.surface || "telegram")}</code>`,
990
1047
  `provider: <code>${escapeTelegramHtml(executor.provider || "unknown")}</code>`,
@@ -1012,8 +1069,12 @@ function formatTelegramRoomMarkup(project, options = {}) {
1012
1069
  `executor live: <code>${escapeTelegramHtml(selectedExecutorLiveHost ? "yes" : "no")}</code>`,
1013
1070
  `reviewer: <code>${escapeTelegramHtml(selectedReviewer ? (selectedReviewer.ownerName || selectedReviewer.label || selectedReviewer.ownerId || selectedReviewer.id || "none") : "none")}</code>`,
1014
1071
  `reviewer live: <code>${escapeTelegramHtml(selectedReviewerLiveHost ? "yes" : "no")}</code>`,
1072
+ `verifier: <code>${escapeTelegramHtml(selectedVerifier ? (selectedVerifier.ownerName || selectedVerifier.label || selectedVerifier.ownerId || selectedVerifier.id || "none") : "none")}</code>`,
1073
+ `verifier live: <code>${escapeTelegramHtml(selectedVerifierLiveHost ? "yes" : "no")}</code>`,
1015
1074
  `blocking review: <code>${escapeTelegramHtml(blockingReview ? "yes" : "no")}</code>`,
1016
1075
  `latest reviewer outcome: <code>${escapeTelegramHtml(String(latestReviewMeta.outcome || "").trim() || "none")}</code>`,
1076
+ `verification mode: <code>${escapeTelegramHtml(String(project.verificationMode || "manual"))}</code>`,
1077
+ `latest verification outcome: <code>${escapeTelegramHtml(String(latestVerificationResult?.outcome || "").trim() || "none")}</code>`,
1017
1078
  "<b>Executor</b>",
1018
1079
  ...executorBits,
1019
1080
  "<b>Runtime Split</b>",
@@ -1946,7 +2007,31 @@ class TelegramGateway {
1946
2007
  throw new Error("Only a shared-project owner can change terminal roles.");
1947
2008
  }
1948
2009
  const actor = this.describeTelegramUser(message?.from || {});
1949
- const targetAgent = resolveProjectAgent(project, intent.target);
2010
+ const normalizedTarget = String(intent.target || "").trim().toLowerCase();
2011
+ let targetAgent = resolveProjectAgent(project, intent.target);
2012
+ if (!targetAgent && /^(?:this|my)(?:\s+(?:bot|terminal))?$/.test(normalizedTarget)) {
2013
+ const host = await this.getLiveBridgeHost();
2014
+ if (host) {
2015
+ targetAgent = listProjectAgents(project).find((agent) => (
2016
+ String(agent?.sessionId || "").trim() && String(agent.sessionId || "").trim() === String(host.sessionId || "").trim()
2017
+ ) || (
2018
+ String(agent?.ownerId || "").trim() && String(agent.ownerId || "").trim() === String(host.ownerId || "").trim()
2019
+ )) || {
2020
+ id: `agent:telegram-bridge:${String(host.sessionId || host.pid || "current").trim()}`,
2021
+ ownerId: String(host.ownerId || actor.userId || "").trim(),
2022
+ ownerName: String(host.ownerName || actor.displayName || "").trim(),
2023
+ label: String(host.label || `${actor.displayName || actor.userId || "current"} terminal`).trim(),
2024
+ surface: String(host.surface || "live-tui").trim(),
2025
+ role: "standby",
2026
+ provider: String(host.provider || "").trim(),
2027
+ model: String(host.model || "").trim(),
2028
+ runtimeProfile: String(host.runtimeProfile || "").trim(),
2029
+ sessionId: String(host.sessionId || "").trim(),
2030
+ cwd: String(session.cwd || this.cwd).trim(),
2031
+ chatId: String(message?.chat?.id || "").trim()
2032
+ };
2033
+ }
2034
+ }
1950
2035
  if (!targetAgent) {
1951
2036
  throw new Error(`No terminal found for ${intent.target}. Ask them to connect their Waterbrother bot/terminal first.`);
1952
2037
  }
@@ -1958,17 +2043,21 @@ class TelegramGateway {
1958
2043
  actorName: actor.displayName || actorId
1959
2044
  });
1960
2045
  const runtimeLabel = targetAgent.provider && targetAgent.model ? `${targetAgent.provider}/${targetAgent.model}` : "unknown";
1961
- const actionTitle = intent.action === "agent-review"
2046
+ const actionTitle = intent.action === "agent-review" || intent.role === "reviewer"
1962
2047
  ? "Roundtable reviewer assigned"
1963
- : intent.action === "agent-execute"
2048
+ : intent.action === "agent-execute" || intent.role === "executor"
1964
2049
  ? "Roundtable executor assigned"
1965
- : "Roundtable terminal updated";
1966
- const note = intent.action === "agent-review"
2050
+ : intent.role === "verifier"
2051
+ ? "Roundtable verifier assigned"
2052
+ : "Roundtable terminal updated";
2053
+ const note = intent.action === "agent-review" || intent.role === "reviewer"
1967
2054
  ? "This sets the room reviewer role. It does not automatically run a review pass yet."
1968
- : intent.action === "agent-execute"
2055
+ : intent.action === "agent-execute" || intent.role === "executor"
1969
2056
  ? "This sets the room executor role. Claim/mode rules still control actual execution."
1970
- : "";
1971
- const followUp = intent.action === "agent-review"
2057
+ : intent.role === "verifier"
2058
+ ? "This sets the room verifier role. Use run verification when you want Waterbrother to run tests or build checks."
2059
+ : "";
2060
+ const followUp = intent.action === "agent-review" || intent.role === "reviewer"
1972
2061
  ? `Should ${targetAgent.ownerName || targetAgent.label || "that terminal"} review be advisory or blocking?`
1973
2062
  : "";
1974
2063
  return {
@@ -2245,6 +2334,7 @@ class TelegramGateway {
2245
2334
  runtimeProfile: host?.runtimeProfile || project?.runtimeProfile || this.channel.defaultRuntimeProfile || this.gateway.defaultRuntimeProfile || "",
2246
2335
  hostSessionId: host?.sessionId || ""
2247
2336
  };
2337
+ const verifier = chooseVerifierAgent(project);
2248
2338
 
2249
2339
  if (intent.action === "bot-status") {
2250
2340
  const lines = [
@@ -2272,6 +2362,68 @@ class TelegramGateway {
2272
2362
  ].join("\n");
2273
2363
  }
2274
2364
 
2365
+ if (intent.action === "verifier-status") {
2366
+ if (!project?.enabled) {
2367
+ return "This project is not shared.";
2368
+ }
2369
+ if (!verifier) {
2370
+ return [
2371
+ "<b>Verifier</b>",
2372
+ "No verifier is assigned yet.",
2373
+ "Try: <code>make this terminal the verifier</code>"
2374
+ ].join("\n");
2375
+ }
2376
+ return [
2377
+ "<b>Verifier</b>",
2378
+ `verifier: <code>${escapeTelegramHtml(verifier.ownerName || verifier.label || verifier.ownerId || verifier.id || "unknown")}</code>`,
2379
+ `role: <code>${escapeTelegramHtml(verifier.role || "verifier")}</code>`,
2380
+ `runtime: <code>${escapeTelegramHtml(verifier.provider && verifier.model ? `${verifier.provider}/${verifier.model}` : "unknown")}</code>`,
2381
+ `verification mode: <code>${escapeTelegramHtml(String(project.verificationMode || "manual"))}</code>`
2382
+ ].join("\n");
2383
+ }
2384
+
2385
+ if (intent.action === "verification-mode-status") {
2386
+ if (!project?.enabled) {
2387
+ return "This project is not shared.";
2388
+ }
2389
+ return [
2390
+ "<b>Verification mode</b>",
2391
+ `mode: <code>${escapeTelegramHtml(String(project.verificationMode || "manual"))}</code>`
2392
+ ].join("\n");
2393
+ }
2394
+
2395
+ if (intent.action === "verification-mode-set") {
2396
+ if (!project?.enabled) {
2397
+ return "This project is not shared.";
2398
+ }
2399
+ const next = await setSharedVerificationMode(cwd, intent.mode, { actorId, actorName });
2400
+ return [
2401
+ "<b>Verification mode</b>",
2402
+ `mode: <code>${escapeTelegramHtml(String(next.verificationMode || intent.mode || "manual"))}</code>`
2403
+ ].join("\n");
2404
+ }
2405
+
2406
+ if (intent.action === "verification-status") {
2407
+ if (!project?.enabled) {
2408
+ return "This project is not shared.";
2409
+ }
2410
+ const result = getLatestVerificationResult(project);
2411
+ if (!result) {
2412
+ return "<b>Verification result</b>\nNo verification result is on record yet.";
2413
+ }
2414
+ return formatVerificationResultMarkup(result, verifier);
2415
+ }
2416
+
2417
+ if (intent.action === "run-verification") {
2418
+ if (!project?.enabled) {
2419
+ return "This project is not shared.";
2420
+ }
2421
+ const result = await this.runLocalVerification(cwd, verifier);
2422
+ const nextProject = await addVerificationResult(cwd, result, { actorId, actorName });
2423
+ const latest = getLatestVerificationResult(nextProject) || result;
2424
+ return formatVerificationResultMarkup(latest, verifier);
2425
+ }
2426
+
2275
2427
  if (intent.action === "accept-reviewer-concerns") {
2276
2428
  if (!project?.enabled) {
2277
2429
  return "This project is not shared.";
@@ -3146,6 +3298,75 @@ class TelegramGateway {
3146
3298
  return JSON.parse(stdout);
3147
3299
  }
3148
3300
 
3301
+ async planVerificationCommands(cwd) {
3302
+ const packageJsonPath = path.join(cwd, "package.json");
3303
+ let scripts = {};
3304
+ try {
3305
+ const raw = await fs.readFile(packageJsonPath, "utf8");
3306
+ const parsed = JSON.parse(raw);
3307
+ scripts = parsed?.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
3308
+ } catch {}
3309
+ const commands = [];
3310
+ if (scripts.check) commands.push(["npm", ["run", "check"], "npm run check"]);
3311
+ if (scripts.test) commands.push(["npm", ["test"], "npm test"]);
3312
+ if (scripts.build) commands.push(["npm", ["run", "build"], "npm run build"]);
3313
+ if (!commands.length) {
3314
+ throw new Error("No verification commands are available here. Expected package.json scripts like check, test, or build.");
3315
+ }
3316
+ return commands;
3317
+ }
3318
+
3319
+ async runLocalVerification(cwd, verifier = null) {
3320
+ const planned = await this.planVerificationCommands(cwd);
3321
+ const commands = [];
3322
+ const startedAt = new Date().toISOString();
3323
+ const logs = [];
3324
+ let passedCount = 0;
3325
+ for (const [bin, args, label] of planned) {
3326
+ commands.push(label);
3327
+ try {
3328
+ await execFileAsync(bin, args, {
3329
+ cwd,
3330
+ env: { ...process.env },
3331
+ maxBuffer: 8 * 1024 * 1024,
3332
+ timeout: 10 * 60 * 1000
3333
+ });
3334
+ passedCount += 1;
3335
+ } catch (error) {
3336
+ const combined = `${String(error?.stderr || "")}\n${String(error?.stdout || "")}`.trim();
3337
+ const extracted = combined
3338
+ .split("\n")
3339
+ .map((line) => String(line || "").trim())
3340
+ .filter(Boolean)
3341
+ .slice(0, 6);
3342
+ logs.push(...extracted);
3343
+ const outcome = error?.killed || error?.signal === "SIGTERM" ? "timeout" : (passedCount > 0 ? "partial" : "failed");
3344
+ return {
3345
+ id: "",
3346
+ workItemId: "",
3347
+ verifierAgentId: String(verifier?.id || "").trim(),
3348
+ outcome,
3349
+ summary: `${label} failed${passedCount > 0 ? ` after ${passedCount} passing check${passedCount === 1 ? "" : "s"}` : ""}.`,
3350
+ commands,
3351
+ startedAt,
3352
+ completedAt: new Date().toISOString(),
3353
+ logs
3354
+ };
3355
+ }
3356
+ }
3357
+ return {
3358
+ id: "",
3359
+ workItemId: "",
3360
+ verifierAgentId: String(verifier?.id || "").trim(),
3361
+ outcome: "passed",
3362
+ summary: `${commands.length} verification command${commands.length === 1 ? "" : "s"} passed.`,
3363
+ commands,
3364
+ startedAt,
3365
+ completedAt: new Date().toISOString(),
3366
+ logs: []
3367
+ };
3368
+ }
3369
+
3149
3370
  async startTypingLoop(chatId) {
3150
3371
  let stopped = false;
3151
3372
  const tick = async () => {
@@ -8,7 +8,9 @@ const ROUNDTABLE_FILE = "ROUNDTABLE.md";
8
8
  const TASK_STATES = ["open", "active", "blocked", "done"];
9
9
  const RECENT_EVENT_LIMIT = 24;
10
10
  const PARTICIPANT_ROLES = ["owner", "editor", "observer"];
11
- const AGENT_ROLES = ["executor", "reviewer", "standby", "coordinator"];
11
+ const AGENT_ROLES = ["executor", "reviewer", "verifier", "standby", "coordinator"];
12
+ const VERIFICATION_MODES = ["off", "manual", "auto", "blocking"];
13
+ const VERIFICATION_RESULT_LIMIT = 50;
12
14
 
13
15
  function makeId(prefix = "id") {
14
16
  return `${prefix}_${crypto.randomBytes(3).toString("hex")}`;
@@ -54,11 +56,13 @@ function normalizeParticipant(participant = {}) {
54
56
  }
55
57
 
56
58
  function normalizeAgent(agent = {}) {
59
+ const rawLabel = String(agent.label || agent.name || "").trim();
60
+ const cleanLabel = rawLabel.replace(/\bundefined\b/ig, "").replace(/\s+/g, " ").trim();
57
61
  return {
58
62
  id: String(agent.id || "").trim(),
59
63
  ownerId: String(agent.ownerId || "").trim(),
60
64
  ownerName: String(agent.ownerName || "").trim(),
61
- label: String(agent.label || agent.name || "").trim(),
65
+ label: cleanLabel,
62
66
  surface: String(agent.surface || "").trim(),
63
67
  role: AGENT_ROLES.includes(String(agent.role || "").trim()) ? String(agent.role).trim() : "standby",
64
68
  provider: String(agent.provider || "").trim(),
@@ -71,6 +75,73 @@ function normalizeAgent(agent = {}) {
71
75
  };
72
76
  }
73
77
 
78
+ function normalizeVerificationResult(result = {}) {
79
+ const commands = Array.isArray(result.commands)
80
+ ? result.commands.map((item) => String(item || "").trim()).filter(Boolean)
81
+ : [];
82
+ const logs = Array.isArray(result.logs)
83
+ ? result.logs.map((item) => String(item || "").trim()).filter(Boolean)
84
+ : [];
85
+ const artifacts = Array.isArray(result.artifacts)
86
+ ? result.artifacts
87
+ .map((artifact) => ({
88
+ label: String(artifact?.label || "").trim(),
89
+ path: String(artifact?.path || "").trim(),
90
+ url: String(artifact?.url || "").trim()
91
+ }))
92
+ .filter((artifact) => artifact.label || artifact.path || artifact.url)
93
+ : [];
94
+ const outcome = String(result.outcome || "").trim().toLowerCase();
95
+ return {
96
+ id: String(result.id || makeId("vr")).trim(),
97
+ workItemId: String(result.workItemId || "").trim(),
98
+ verifierAgentId: String(result.verifierAgentId || "").trim(),
99
+ outcome: ["passed", "failed", "partial", "timeout"].includes(outcome) ? outcome : "failed",
100
+ summary: String(result.summary || "").trim(),
101
+ commands,
102
+ startedAt: String(result.startedAt || new Date().toISOString()).trim(),
103
+ completedAt: String(result.completedAt || result.startedAt || new Date().toISOString()).trim(),
104
+ logs,
105
+ artifacts
106
+ };
107
+ }
108
+
109
+ function isBrokenAgent(agent = {}) {
110
+ const ownerId = String(agent.ownerId || "").trim();
111
+ const ownerName = String(agent.ownerName || "").trim();
112
+ const label = String(agent.label || "").trim();
113
+ return !ownerId && !ownerName && !label;
114
+ }
115
+
116
+ function dedupeAgents(agents = []) {
117
+ const bySemanticKey = new Map();
118
+ for (const agent of agents) {
119
+ const normalized = normalizeAgent(agent);
120
+ if (!normalized.id || isBrokenAgent(normalized)) continue;
121
+ const semanticKey = [
122
+ String(normalized.ownerId || "").trim(),
123
+ String(normalized.ownerName || "").trim().toLowerCase(),
124
+ String(normalized.label || "").trim().toLowerCase(),
125
+ String(normalized.surface || "").trim().toLowerCase(),
126
+ String(normalized.role || "").trim().toLowerCase(),
127
+ String(normalized.provider || "").trim().toLowerCase(),
128
+ String(normalized.model || "").trim().toLowerCase(),
129
+ String(normalized.chatId || "").trim()
130
+ ].join("|");
131
+ const prior = bySemanticKey.get(semanticKey);
132
+ if (!prior) {
133
+ bySemanticKey.set(semanticKey, normalized);
134
+ continue;
135
+ }
136
+ const priorUpdated = Date.parse(String(prior.updatedAt || "").trim()) || 0;
137
+ const nextUpdated = Date.parse(String(normalized.updatedAt || "").trim()) || 0;
138
+ if (nextUpdated >= priorUpdated) {
139
+ bySemanticKey.set(semanticKey, normalized);
140
+ }
141
+ }
142
+ return [...bySemanticKey.values()];
143
+ }
144
+
74
145
  function areAgentsEquivalent(left = {}, right = {}) {
75
146
  return [
76
147
  "ownerId",
@@ -119,12 +190,8 @@ function buildProjectParticipants(project = {}) {
119
190
  }
120
191
 
121
192
  function buildProjectAgents(project = {}) {
122
- const existing = new Map(
123
- (Array.isArray(project.agents) ? project.agents : [])
124
- .map((agent) => normalizeAgent(agent))
125
- .filter((agent) => agent.id)
126
- .map((agent) => [agent.id, agent])
127
- );
193
+ const existingAgents = dedupeAgents(Array.isArray(project.agents) ? project.agents : []);
194
+ const existing = new Map(existingAgents.map((agent) => [agent.id, agent]));
128
195
  const next = [];
129
196
  const activeOperatorId = String(project.activeOperator?.id || "").trim();
130
197
  if (activeOperatorId) {
@@ -145,7 +212,7 @@ function buildProjectAgents(project = {}) {
145
212
  if (next.some((item) => item.id === agent.id)) continue;
146
213
  next.push(normalizeAgent(agent));
147
214
  }
148
- return next;
215
+ return dedupeAgents(next);
149
216
  }
150
217
 
151
218
  function memberRoleWeight(role = "") {
@@ -183,6 +250,12 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
183
250
  : null;
184
251
  const participants = buildProjectParticipants({ ...project, members });
185
252
  const agents = buildProjectAgents({ ...project, members, activeOperator });
253
+ const verificationResults = Array.isArray(project.verificationResults)
254
+ ? project.verificationResults
255
+ .map((item) => normalizeVerificationResult(item))
256
+ .filter((item) => item.id)
257
+ .slice(-VERIFICATION_RESULT_LIMIT)
258
+ : [];
186
259
 
187
260
  return {
188
261
  version: 1,
@@ -199,10 +272,15 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
199
272
  roomMode: ["chat", "plan", "execute"].includes(String(project.roomMode || "").trim())
200
273
  ? String(project.roomMode).trim()
201
274
  : "chat",
275
+ verificationMode: VERIFICATION_MODES.includes(String(project.verificationMode || "").trim())
276
+ ? String(project.verificationMode).trim()
277
+ : "manual",
202
278
  runtimeProfile: String(project.runtimeProfile || "").trim(),
203
279
  members,
204
280
  participants,
205
281
  agents,
282
+ verificationResults,
283
+ latestVerificationResultId: String(project.latestVerificationResultId || "").trim(),
206
284
  tasks,
207
285
  pendingInvites,
208
286
  recentEvents,
@@ -433,7 +511,12 @@ async function recordSharedProjectEvent(cwd, project, text, { type = "note", act
433
511
  export async function loadSharedProject(cwd) {
434
512
  try {
435
513
  const raw = await fs.readFile(sharedFilePath(cwd), "utf8");
436
- return normalizeSharedProject(JSON.parse(raw), cwd);
514
+ const parsed = JSON.parse(raw);
515
+ const normalized = normalizeSharedProject(parsed, cwd);
516
+ if (JSON.stringify(parsed) !== JSON.stringify(normalized)) {
517
+ await writeJsonAtomically(sharedFilePath(cwd), normalized);
518
+ }
519
+ return normalized;
437
520
  } catch (error) {
438
521
  if (error?.code === "ENOENT") return null;
439
522
  throw error;
@@ -537,6 +620,25 @@ export async function setSharedRuntimeProfile(cwd, runtimeProfile = "", options
537
620
  })).project;
538
621
  }
539
622
 
623
+ export async function setSharedVerificationMode(cwd, verificationMode = "manual", options = {}) {
624
+ const existing = await loadSharedProject(cwd);
625
+ requireOwner(existing, options.actorId);
626
+ const normalized = String(verificationMode || "").trim().toLowerCase();
627
+ if (!VERIFICATION_MODES.includes(normalized)) {
628
+ throw new Error(`Invalid verification mode. Expected one of ${VERIFICATION_MODES.join(", ")}.`);
629
+ }
630
+ const next = await saveSharedProject(cwd, {
631
+ ...existing,
632
+ verificationMode: normalized
633
+ });
634
+ return (await recordSharedProjectEvent(cwd, next, `verification mode set to ${normalized}`, {
635
+ type: "verification-mode",
636
+ actorId: String(options.actorId || "").trim(),
637
+ actorName: String(options.actorName || "").trim(),
638
+ meta: { verificationMode: normalized }
639
+ })).project;
640
+ }
641
+
540
642
  export function getSharedMember(project, memberId = "") {
541
643
  const normalizedId = String(memberId || "").trim();
542
644
  if (!normalizedId || !project?.members?.length) return null;
@@ -663,6 +765,46 @@ export async function upsertSharedAgent(cwd, agent = {}, options = {}) {
663
765
  })).project;
664
766
  }
665
767
 
768
+ export function getLatestVerificationResult(project) {
769
+ const latestId = String(project?.latestVerificationResultId || "").trim();
770
+ const results = Array.isArray(project?.verificationResults) ? project.verificationResults : [];
771
+ if (latestId) {
772
+ const exact = results.find((result) => String(result?.id || "").trim() === latestId);
773
+ if (exact) return exact;
774
+ }
775
+ return [...results].sort((a, b) => String(b.completedAt || "").localeCompare(String(a.completedAt || "")))[0] || null;
776
+ }
777
+
778
+ export async function addVerificationResult(cwd, result = {}, options = {}) {
779
+ const existing = await loadSharedProject(cwd);
780
+ requireSharedProject(existing);
781
+ if (options.actorId) {
782
+ requireMember(existing, options.actorId);
783
+ }
784
+ const verificationResult = normalizeVerificationResult(result);
785
+ const verificationResults = [...(existing.verificationResults || []), verificationResult].slice(-VERIFICATION_RESULT_LIMIT);
786
+ const next = await saveSharedProject(cwd, {
787
+ ...existing,
788
+ verificationResults,
789
+ latestVerificationResultId: verificationResult.id
790
+ });
791
+ return (await recordSharedProjectEvent(
792
+ cwd,
793
+ next,
794
+ `verification ${verificationResult.outcome} [${verificationResult.id}] ${verificationResult.summary || "result recorded"}`.trim(),
795
+ {
796
+ type: "verification-result",
797
+ actorId: String(options.actorId || "").trim(),
798
+ actorName: String(options.actorName || "").trim(),
799
+ meta: {
800
+ verificationResultId: verificationResult.id,
801
+ verifierAgentId: verificationResult.verifierAgentId,
802
+ outcome: verificationResult.outcome
803
+ }
804
+ }
805
+ )).project;
806
+ }
807
+
666
808
  export async function addSharedRoomNote(cwd, text = "", options = {}) {
667
809
  const existing = await loadSharedProject(cwd);
668
810
  requireSharedProject(existing);
@@ -1084,12 +1226,15 @@ export function formatSharedProjectStatus(project) {
1084
1226
  room: project.room,
1085
1227
  mode: project.mode,
1086
1228
  roomMode: project.roomMode,
1229
+ verificationMode: project.verificationMode,
1087
1230
  runtimeProfile: project.runtimeProfile,
1088
1231
  approvalPolicy: project.approvalPolicy,
1089
1232
  activeOperator: project.activeOperator,
1090
1233
  members: project.members,
1091
1234
  participants: project.participants,
1092
1235
  agents: project.agents,
1236
+ verificationResults: project.verificationResults,
1237
+ latestVerificationResultId: project.latestVerificationResultId,
1093
1238
  pendingInvites: project.pendingInvites,
1094
1239
  tasks: project.tasks,
1095
1240
  recentEvents: project.recentEvents