openzoo 0.50.54 → 0.50.56

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.
@@ -52,6 +52,8 @@ import {
52
52
  import {
53
53
  desktopAction, displayBounds, imageSize, noteShotMeta, resolveAppName,
54
54
  } from './grokbotDesktop.js';
55
+ import { startHostMcps, hostMcpTools, hostMcpHas, callHostMcp, hostMcpServers } from './mcpbridge.js';
56
+ import * as ship from './ship.js';
55
57
 
56
58
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
57
59
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -1455,6 +1457,27 @@ function seedBriefOnCanvas(agent, log) {
1455
1457
  }
1456
1458
  return kickBriefedAgent(agent, log || ((m) => console.error(m)));
1457
1459
  }
1460
+ /** Overlay prompts ITS user: no factory yet -> paint how to start one. Once per process. */
1461
+ export function shipNudgeText() {
1462
+ return [
1463
+ '[grok ship] No software factory on this Mac yet.',
1464
+ 'Tell me: set up Grok Ship for ~/path/to/repo',
1465
+ 'I will create Firstmate (the one bot you talk to) and a crewmate for that repo. Then give Firstmate ship tasks: it runs a worker on a branch, a fresh review of the diff, and opens the PR only when the review is clean. You merge.',
1466
+ ].join('\n');
1467
+ }
1468
+ let shipNudged = false;
1469
+ function seedShipNudge(list, activeId) {
1470
+ if (shipNudged || !activeId) return false;
1471
+ const roster = Array.isArray(list) ? list : [];
1472
+ if (roster.some((a) => /^firstmate$/i.test(String(a?.name || '')))) { shipNudged = true; return false; }
1473
+ const t = agentTranscript(activeId);
1474
+ if ((t.entries || []).some((e) => /\[grok ship\]/.test(String(e?.content || e?.message?.content || '')))) { shipNudged = true; return false; }
1475
+ shipNudged = true;
1476
+ const nonce = `oz-ship-nudge-${activeId}`;
1477
+ const line = fanoutLine(activeId, 'assistant', shipNudgeText(), { clientNonce: nonce, requestId: nonce });
1478
+ ssePush('transcript', { ...gatewayEntry(line), agentId: activeId });
1479
+ return true;
1480
+ }
1458
1481
  function groupMemberIds(agentId) {
1459
1482
  const a = (cachedAgentList() || []).find((x) => x.id === agentId);
1460
1483
  if (!a?.isGroup) return [];
@@ -2170,10 +2193,89 @@ const LOCAL_TOOLS = [
2170
2193
  },
2171
2194
  },
2172
2195
  },
2196
+ {
2197
+ type: 'function',
2198
+ function: {
2199
+ name: 'ship_crew',
2200
+ description: 'Grok Ship: set up the factory for a repo. Creates Firstmate (once) and one crewmate bot for this repo with standing briefs, detects the forge (gh/glab). Call when the user asks for a software factory / Grok Ship / crew for a repo.',
2201
+ parameters: {
2202
+ type: 'object',
2203
+ properties: { cwd: { type: 'string', description: 'Repo path on this Mac.' } },
2204
+ required: ['cwd'],
2205
+ },
2206
+ },
2207
+ },
2208
+ {
2209
+ type: 'function',
2210
+ function: {
2211
+ name: 'ship_forge',
2212
+ description: 'Grok Ship: detect the source-control forge for a repo (github/gitlab/bitbucket/origin) and which CLI is authenticated (gh/glab). Do not assume GitHub.',
2213
+ parameters: { type: 'object', properties: { cwd: { type: 'string' } }, required: ['cwd'] },
2214
+ },
2215
+ },
2216
+ {
2217
+ type: 'function',
2218
+ function: {
2219
+ name: 'ship_launch_worker',
2220
+ description: 'Grok Ship: start a coding worker (claude-zoo, paid via x402) in a fresh git worktree on a new branch. Returns a task id. Pass the same task id again to send review findings back to the same branch. Never opens a PR.',
2221
+ parameters: {
2222
+ type: 'object',
2223
+ properties: {
2224
+ cwd: { type: 'string', description: 'Repo path.' },
2225
+ title: { type: 'string' },
2226
+ prompt: { type: 'string', description: 'Goal, acceptance criteria, constraints. Or the review findings on a follow-up.' },
2227
+ base: { type: 'string', description: 'Base branch. Default: origin HEAD.' },
2228
+ task: { type: 'string', description: 'Existing task id to resume on its branch.' },
2229
+ },
2230
+ required: ['cwd', 'prompt'],
2231
+ },
2232
+ },
2233
+ },
2234
+ {
2235
+ type: 'function',
2236
+ function: {
2237
+ name: 'ship_status',
2238
+ description: 'Grok Ship: is the worker alive, did it push, what did it commit, log tail. Poll this (with schedule_wakeup) instead of guessing.',
2239
+ parameters: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] },
2240
+ },
2241
+ },
2242
+ {
2243
+ type: 'function',
2244
+ function: {
2245
+ name: 'ship_review',
2246
+ description: 'Grok Ship: FRESH adversarial review of the pushed branch (one-shot, no chat history, only the diff). Returns findings + gate. Run after every push, before any PR.',
2247
+ parameters: {
2248
+ type: 'object',
2249
+ properties: {
2250
+ task: { type: 'string' },
2251
+ cwd: { type: 'string', description: 'Without a task: repo path.' },
2252
+ branch: { type: 'string' },
2253
+ base: { type: 'string' },
2254
+ },
2255
+ },
2256
+ },
2257
+ },
2258
+ {
2259
+ type: 'function',
2260
+ function: {
2261
+ name: 'ship_open_pr',
2262
+ description: 'Grok Ship: open the PR/MR for a task. Refuses unless the last ship_review gate is clean and the branch is on origin. Never merges.',
2263
+ parameters: {
2264
+ type: 'object',
2265
+ properties: { task: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' } },
2266
+ required: ['task'],
2267
+ },
2268
+ },
2269
+ },
2173
2270
  ];
2174
2271
 
2175
2272
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
2176
2273
 
2274
+ function liveTools() {
2275
+ const extra = hostMcpTools();
2276
+ return extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS;
2277
+ }
2278
+
2177
2279
  async function captureScreenshot(log) {
2178
2280
  const dir = path.join(os.tmpdir(), 'openzoo-screens');
2179
2281
  fs.mkdirSync(dir, { recursive: true });
@@ -2361,12 +2463,77 @@ async function runLocalTool(name, args, log, ctx = {}) {
2361
2463
  if (!who) return 'ERROR no such agent';
2362
2464
  return JSON.stringify(cancelAgentWakeup(who.id));
2363
2465
  }
2466
+ if (name.startsWith('ship_')) {
2467
+ return await runShipTool(name, args, log, ctx);
2468
+ }
2469
+ if (hostMcpHas(name)) {
2470
+ return await callHostMcp(name, args);
2471
+ }
2364
2472
  return `unknown tool ${name}`;
2365
2473
  } catch (e) {
2366
2474
  return `ERROR ${e.message}`;
2367
2475
  }
2368
2476
  }
2369
2477
 
2478
+ /** Grok Ship tools. `run` is execLocal so workers/PRs use the same shell the bot does. */
2479
+ async function runShipTool(name, args, log, ctx = {}) {
2480
+ const run = (command, cwd) => execLocal(command, cwd, log);
2481
+ const home = HOME;
2482
+ if (name === 'ship_forge') {
2483
+ const cwd = expandUserPath(String(args.cwd || ''));
2484
+ return JSON.stringify(await ship.probeForge(cwd, run));
2485
+ }
2486
+ if (name === 'ship_crew') {
2487
+ const cwd = expandUserPath(String(args.cwd || ''));
2488
+ const forge = await ship.probeForge(cwd, run);
2489
+ const list = cachedAgentList() || [];
2490
+ let firstmate = list.find((a) => /^firstmate$/i.test(String(a.name || '')));
2491
+ if (!firstmate) {
2492
+ firstmate = mintLocalAgent({ name: 'Firstmate', brief: ship.firstmateBrief() });
2493
+ pushCreatedAgent(firstmate, { select: false });
2494
+ seedBriefOnCanvas(firstmate, log);
2495
+ }
2496
+ const repoName = path.basename(cwd);
2497
+ let crew = list.find((a) => String(a.brief || '').includes(`crewmate for the repo at ${cwd}`));
2498
+ if (!crew) {
2499
+ crew = mintLocalAgent({ name: `Crew · ${repoName}`, brief: ship.crewmateBrief({ repo: cwd, forge: forge.forge }) });
2500
+ pushCreatedAgent(crew, { select: false });
2501
+ seedBriefOnCanvas(crew, log);
2502
+ }
2503
+ log(`cursor-backend: ship_crew repo=${cwd} forge=${forge.forge} cli=${forge.cli} firstmate=${firstmate.id} crew=${crew.id}`);
2504
+ return JSON.stringify({
2505
+ ok: true,
2506
+ forge,
2507
+ firstmate: { id: firstmate.id, name: firstmate.name },
2508
+ crewmate: { id: crew.id, name: crew.name },
2509
+ next: forge.cli ? 'Talk to Firstmate. It hands ship tasks to the crewmate.' : `No authenticated forge CLI (${forge.forge}); run gh auth login / glab auth login on this Mac before ship_open_pr.`,
2510
+ });
2511
+ }
2512
+ if (name === 'ship_launch_worker') {
2513
+ const cwd = expandUserPath(String(args.cwd || ''));
2514
+ const task = await ship.launchWorker({
2515
+ cwd, title: args.title, prompt: args.prompt, base: args.base, taskId: args.task, home, run, log,
2516
+ });
2517
+ return JSON.stringify({ ok: true, task: task.id, branch: task.branch, base: task.base, worktree: task.worktree, log: task.log, pid: task.pid, attempts: task.attempts });
2518
+ }
2519
+ if (name === 'ship_status') {
2520
+ return JSON.stringify(await ship.taskStatus({ taskId: String(args.task || ''), home, run }));
2521
+ }
2522
+ if (name === 'ship_review') {
2523
+ const got = await ship.reviewBranch({
2524
+ taskId: args.task ? String(args.task) : undefined,
2525
+ cwd: args.cwd ? expandUserPath(String(args.cwd)) : undefined,
2526
+ branch: args.branch, base: args.base,
2527
+ model: currentModel(ctx.agentId), home, run, log,
2528
+ });
2529
+ return JSON.stringify(got);
2530
+ }
2531
+ if (name === 'ship_open_pr') {
2532
+ return JSON.stringify(await ship.openPr({ taskId: String(args.task || ''), title: args.title, body: args.body, home, run, log }));
2533
+ }
2534
+ return `unknown tool ${name}`;
2535
+ }
2536
+
2370
2537
  /** Model parked instead of writing files. Host keeps the tool loop going. */
2371
2538
  export function looksStoppedReply(s) {
2372
2539
  return /stopped on research|no (?:new )?app files written|nothing (?:extra )?to open yet|say go again|next message i['’]?ll write|stopped — no new/i.test(String(s || ''));
@@ -2457,11 +2624,28 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2457
2624
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2458
2625
  })(),
2459
2626
  `You HAVE local tools on the user's computer via ${via}.`,
2460
- 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup.',
2627
+ 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr.',
2628
+ 'If no sidebar bot is named Firstmate and the human brings code or repo work, ask ONE question: which repo path to set up Grok Ship for. Then call ship_crew with that cwd. Do not start coding outside the factory.',
2629
+ 'Grok Ship (software factory): ship_crew sets up Firstmate + a crewmate per repo. A crewmate ships with ship_launch_worker -> ship_status until pushed -> ship_review (fresh, diff-only) -> ship_open_pr only when gate.clean. Never merge; the human does.',
2630
+ (() => {
2631
+ const extra = hostMcpTools();
2632
+ const names = extra.map((t) => t.function.name);
2633
+ const servers = hostMcpServers();
2634
+ if (!names.length) {
2635
+ return 'Host MCP servers (Claude/Grok chrome-devtools, brave, …) are connecting. When chrome-devtools__* tools appear, use them for web pages.';
2636
+ }
2637
+ const chrome = names.filter((n) => /chrome|devtools|browser/i.test(n));
2638
+ return [
2639
+ `Host MCP tools from the user's local Claude/Grok config are attached (${servers.join(', ') || 'mcp'}): ${names.slice(0, 36).join(', ')}${names.length > 36 ? '…' : ''}.`,
2640
+ chrome.length
2641
+ ? 'For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only.'
2642
+ : 'Use matching MCP tools instead of inventing shell one-liners.',
2643
+ ].join(' ');
2644
+ })(),
2461
2645
  'create_agent mints a sidebar bot. ALWAYS pass brief so they keep the job across restart. set_brief updates it. list_agents + message_agent talk to other bots (one hop). Do not tell the human to copy-paste between canvases.',
2462
2646
  'NEVER STOP / cron / keep working between human messages: call schedule_wakeup every="5m". That is a host timer. There is no crontab. Do not spawn more bots for persistence. Do not re-read SITREP-NOW.md or STANDING-ORDERS.md every turn — do the next file or click. exec sysctl/uptime is not the job. If the tray already has named workers, answer "no" to spawning more.',
2463
2647
  'You CAN click the Mac and fill/submit browser forms. That is required when the user asks. Do not write a markdown briefing instead of clicking. Do not tell the user to click.',
2464
- 'Form loop: focus_app Brave Browser screenshot click the field (query or x,y) type_text → screenshot to confirm → click Submit / key enter. screenshot.screen is click coordinate space. Prefer click query="Submit".',
2648
+ 'Form loop: if chrome-devtools MCP tools exist, navigate_page + take_snapshot + fill the fields + click the submit button. Else fallback: focus_app Brave Browser → screenshot → click the field type_text screenshot click Submit.',
2465
2649
  'screenshot captures the display and attaches the image. For any on-screen form, dashboard, or click target, screenshot first this turn. Do not guess at UI you have not seen.',
2466
2650
  'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
2467
2651
  'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
@@ -2560,7 +2744,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2560
2744
  throwIfAborted();
2561
2745
  const payload = { model, messages, max_tokens: maxTok };
2562
2746
  if (!chatOnly) {
2563
- payload.tools = LOCAL_TOOLS;
2747
+ payload.tools = liveTools();
2564
2748
  payload.tool_choice = 'auto';
2565
2749
  }
2566
2750
  const { r, data } = await zooPost(payload);
@@ -2970,6 +3154,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2970
3154
  activeAgentId: active,
2971
3155
  });
2972
3156
  }
3157
+ if (seedShipNudge(list, active)) log(`cursor-backend: ship nudge painted on ${active}`);
2973
3158
  log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'} active=${active || 'none'}`);
2974
3159
  return true;
2975
3160
  }
@@ -3463,6 +3648,7 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
3463
3648
  // the platform refuses (v6 disabled), fall back to v4 so we still work.
3464
3649
  server.on('error', (e) => log(`cursor-backend: server error ${e.code || e.message}`));
3465
3650
  const onUp = (what) => log(`cursor-backend: listening on ${what}:${port} as ${CURSOR_HOSTS[0]}`);
3651
+ startHostMcps({ log }).catch((e) => log(`cursor-backend: mcp start ${e.message}`));
3466
3652
  try {
3467
3653
  server.listen({ port, host: '::', ipv6Only: false }, () => onUp('[::]+127.0.0.1'));
3468
3654
  } catch {
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Load the operator's local MCP servers (Grok / Claude / Cursor) and expose
3
+ * them as OpenAI function tools for Grok Bot zoo turns.
4
+ *
5
+ * chrome-devtools is always attached if missing — that is Claude-in-Chrome
6
+ * for this hijack: navigate / snapshot / fill the live page, not osascript.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import net from 'node:net';
12
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
13
+ import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
15
+
16
+ const SKIP = /^(openzoo|openzoo-mcp)$/i;
17
+ const NAME_RE = /[^a-zA-Z0-9_-]/g;
18
+
19
+ const registry = new Map();
20
+ let openaiTools = [];
21
+ let started = null;
22
+ let serversUp = [];
23
+
24
+ export function hostMcpTools() {
25
+ return openaiTools;
26
+ }
27
+
28
+ export function hostMcpHas(name) {
29
+ return registry.has(String(name || ''));
30
+ }
31
+
32
+ export function hostMcpServers() {
33
+ return [...serversUp];
34
+ }
35
+
36
+ export function toolOpenaiName(server, toolName) {
37
+ return `${String(server || 'mcp').replace(NAME_RE, '_') }__${String(toolName || 'tool').replace(NAME_RE, '_')}`.slice(0, 64);
38
+ }
39
+
40
+ export function flattenMcpResult(r) {
41
+ const parts = [];
42
+ const content = Array.isArray(r?.content) ? r.content : [];
43
+ for (const c of content) {
44
+ if (!c || typeof c !== 'object') continue;
45
+ if (c.type === 'text' && c.text) parts.push(String(c.text));
46
+ else if (c.type === 'image') parts.push(`[image ${c.mimeType || 'png'} ${(c.data || '').length}b]`);
47
+ else parts.push(JSON.stringify(c));
48
+ }
49
+ const body = parts.join('\n').trim() || JSON.stringify(r ?? {});
50
+ const out = r?.isError ? `ERROR ${body}` : body;
51
+ return out.slice(0, 20_000);
52
+ }
53
+
54
+ function stripJsonComments(s) {
55
+ return String(s || '').replace(/^\s*\/\/.*$/gm, '');
56
+ }
57
+
58
+ function readJsonFile(p) {
59
+ try { return JSON.parse(stripJsonComments(fs.readFileSync(p, 'utf8'))); } catch { return null; }
60
+ }
61
+
62
+ /** Enough TOML for ~/.grok/config.toml [mcp_servers.*] tables. */
63
+ export function parseTomlMcpServers(text) {
64
+ const servers = {};
65
+ let cur = null;
66
+ let nested = null;
67
+ let arrayKey = null;
68
+ let arrayBuf = [];
69
+ const ensure = (name) => {
70
+ if (!servers[name]) servers[name] = { name };
71
+ return servers[name];
72
+ };
73
+ const flushArray = () => {
74
+ if (!cur || !arrayKey) return;
75
+ const vals = arrayBuf.map((x) => unquote(x)).filter((x) => x !== '');
76
+ cur[arrayKey] = vals;
77
+ arrayKey = null;
78
+ arrayBuf = [];
79
+ };
80
+ const unquote = (raw) => {
81
+ let s = String(raw || '').trim().replace(/,$/, '').trim();
82
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
83
+ s = s.slice(1, -1);
84
+ }
85
+ if (s === 'true') return true;
86
+ if (s === 'false') return false;
87
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
88
+ return s;
89
+ };
90
+ for (const rawLine of String(text || '').split(/\n/)) {
91
+ const line = rawLine.trim();
92
+ if (!line || line.startsWith('#')) continue;
93
+ if (arrayKey) {
94
+ if (line.startsWith(']')) {
95
+ flushArray();
96
+ continue;
97
+ }
98
+ arrayBuf.push(line.replace(/,$/, ''));
99
+ continue;
100
+ }
101
+ const nestedSec = line.match(/^\[mcp_servers\.([^\]]+?)\.([a-zA-Z0-9_-]+)\]$/);
102
+ if (nestedSec) {
103
+ cur = ensure(nestedSec[1]);
104
+ nested = nestedSec[2];
105
+ if (!cur[nested] || typeof cur[nested] !== 'object' || Array.isArray(cur[nested])) cur[nested] = {};
106
+ continue;
107
+ }
108
+ const sec = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
109
+ if (sec) {
110
+ cur = ensure(sec[1]);
111
+ nested = null;
112
+ continue;
113
+ }
114
+ if (line.startsWith('[')) {
115
+ cur = null;
116
+ nested = null;
117
+ continue;
118
+ }
119
+ if (!cur) continue;
120
+ const kv = line.match(/^([a-zA-Z0-9_-]+)\s*=\s*(.*)$/);
121
+ if (!kv) continue;
122
+ const key = kv[1];
123
+ const rest = kv[2].trim();
124
+ if (rest === '[' || rest.startsWith('[')) {
125
+ arrayKey = key;
126
+ arrayBuf = [];
127
+ const inner = rest.replace(/^\[/, '').replace(/\]\s*$/, '').trim();
128
+ if (rest.includes(']') && rest !== '[') {
129
+ if (inner) arrayBuf = inner.split(',').map((x) => x.trim());
130
+ flushArray();
131
+ }
132
+ continue;
133
+ }
134
+ const val = unquote(rest);
135
+ if (nested) cur[nested][key] = val;
136
+ else cur[key] = val;
137
+ }
138
+ flushArray();
139
+ return Object.values(servers);
140
+ }
141
+
142
+ export function fromJsonMcpServers(obj, fallbackName) {
143
+ if (!obj || typeof obj !== 'object') return [];
144
+ const src = obj.mcpServers && typeof obj.mcpServers === 'object' ? obj.mcpServers : obj;
145
+ const out = [];
146
+ for (const [name, raw] of Object.entries(src)) {
147
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
148
+ out.push({
149
+ name: String(name || fallbackName || 'mcp'),
150
+ command: raw.command,
151
+ args: Array.isArray(raw.args) ? raw.args.map(String) : (Array.isArray(raw.command) ? raw.command.slice(1) : undefined),
152
+ url: raw.url,
153
+ type: raw.type,
154
+ env: raw.env && typeof raw.env === 'object' ? raw.env : undefined,
155
+ headers: raw.headers && typeof raw.headers === 'object' ? raw.headers : undefined,
156
+ enabled: raw.enabled !== false,
157
+ });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function shapeServer(raw) {
163
+ const name = String(raw?.name || '').trim();
164
+ if (!name || SKIP.test(name) || raw?.enabled === false) return null;
165
+ const command = Array.isArray(raw.command) ? raw.command[0] : raw.command;
166
+ const args = Array.isArray(raw.args)
167
+ ? raw.args.map(String)
168
+ : (Array.isArray(raw.command) ? raw.command.slice(1).map(String) : []);
169
+ const url = raw.url ? String(raw.url) : '';
170
+ if (!url && !command) return null;
171
+ return {
172
+ name,
173
+ command: command ? String(command) : '',
174
+ args,
175
+ url,
176
+ env: raw.env && typeof raw.env === 'object' ? Object.fromEntries(Object.entries(raw.env).map(([k, v]) => [k, String(v)])) : undefined,
177
+ headers: raw.headers && typeof raw.headers === 'object' ? raw.headers : undefined,
178
+ };
179
+ }
180
+
181
+ export function loadHostMcpConfigs(home = os.homedir()) {
182
+ const piles = [];
183
+ const grokToml = path.join(home, '.grok', 'config.toml');
184
+ try { piles.push(...parseTomlMcpServers(fs.readFileSync(grokToml, 'utf8'))); } catch { /* */ }
185
+ for (const p of [
186
+ path.join(home, '.claude', 'mcp.json'),
187
+ path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
188
+ path.join(home, '.cursor', 'mcp.json'),
189
+ ]) {
190
+ const j = readJsonFile(p);
191
+ if (j) piles.push(...fromJsonMcpServers(j));
192
+ }
193
+ const seen = new Set();
194
+ const out = [];
195
+ for (const raw of piles) {
196
+ const s = shapeServer(raw);
197
+ if (!s || seen.has(s.name)) continue;
198
+ seen.add(s.name);
199
+ out.push(s);
200
+ }
201
+ if (![...seen].some((n) => /chrome|devtools|browser/i.test(n))) {
202
+ out.push({
203
+ name: 'chrome-devtools',
204
+ command: 'npx',
205
+ args: ['-y', 'chrome-devtools-mcp@latest'],
206
+ url: '',
207
+ });
208
+ }
209
+ return out;
210
+ }
211
+
212
+ export function mcpToOpenAiTool(server, tool) {
213
+ const schema = tool?.inputSchema && typeof tool.inputSchema === 'object'
214
+ ? { ...tool.inputSchema }
215
+ : { type: 'object', properties: {} };
216
+ if (!schema.type) schema.type = 'object';
217
+ if (!schema.properties) schema.properties = {};
218
+ delete schema.$schema;
219
+ return {
220
+ type: 'function',
221
+ function: {
222
+ name: toolOpenaiName(server, tool.name),
223
+ description: `[MCP ${server}] ${String(tool.description || tool.name || '').slice(0, 900)}`,
224
+ parameters: schema,
225
+ },
226
+ };
227
+ }
228
+
229
+ function portOpen(port, host = '127.0.0.1', ms = 150) {
230
+ return new Promise((resolve) => {
231
+ const sock = net.connect({ port, host });
232
+ const done = (ok) => {
233
+ try { sock.destroy(); } catch { /* */ }
234
+ resolve(ok);
235
+ };
236
+ sock.setTimeout(ms);
237
+ sock.on('connect', () => done(true));
238
+ sock.on('timeout', () => done(false));
239
+ sock.on('error', () => done(false));
240
+ });
241
+ }
242
+
243
+ async function chromeArgs(baseArgs) {
244
+ const args = [...(baseArgs || ['-y', 'chrome-devtools-mcp@latest'])];
245
+ if (args.some((a) => String(a).includes('browserUrl') || String(a) === '--browserUrl')) return args;
246
+ for (const port of [9222, 9333]) {
247
+ if (await portOpen(port)) {
248
+ args.push('--browserUrl', `http://127.0.0.1:${port}`);
249
+ break;
250
+ }
251
+ }
252
+ return args;
253
+ }
254
+
255
+ async function connectOne(cfg, log) {
256
+ const client = new Client({ name: 'openzoo-grokbot', version: '0.50.55' });
257
+ let transport;
258
+ if (cfg.url) {
259
+ const headers = {};
260
+ for (const [k, v] of Object.entries(cfg.headers || {})) headers[k] = String(v);
261
+ transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
262
+ requestInit: { headers },
263
+ });
264
+ } else {
265
+ let args = cfg.args || [];
266
+ if (cfg.name === 'chrome-devtools') args = await chromeArgs(args);
267
+ const env = { ...getDefaultEnvironment(), PATH: process.env.PATH || '', ...(cfg.env || {}) };
268
+ if (process.env.NVM_DIR) env.NVM_DIR = process.env.NVM_DIR;
269
+ transport = new StdioClientTransport({
270
+ command: cfg.command,
271
+ args,
272
+ env,
273
+ stderr: 'pipe',
274
+ });
275
+ try {
276
+ transport.stderr?.on?.('data', (buf) => {
277
+ const line = String(buf).trim().split('\n')[0];
278
+ if (line) log?.(`cursor-backend: mcp ${cfg.name} ${line.slice(0, 160)}`);
279
+ });
280
+ } catch { /* */ }
281
+ }
282
+ const ms = cfg.name === 'chrome-devtools' ? 90_000 : 45_000;
283
+ await Promise.race([
284
+ client.connect(transport),
285
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms}ms`)), ms)),
286
+ ]);
287
+ const listed = await client.listTools();
288
+ const tools = Array.isArray(listed?.tools) ? listed.tools : [];
289
+ return { client, tools };
290
+ }
291
+
292
+ function rebuildOpenai() {
293
+ openaiTools = [];
294
+ for (const [name, rec] of registry) {
295
+ openaiTools.push(mcpToOpenAiTool(rec.server, { name: rec.tool, description: rec.description, inputSchema: rec.schema }));
296
+ void name;
297
+ }
298
+ }
299
+
300
+ export async function callHostMcp(name, args) {
301
+ const rec = registry.get(String(name || ''));
302
+ if (!rec) throw new Error(`no mcp tool ${name}`);
303
+ const r = await rec.client.callTool({ name: rec.tool, arguments: args && typeof args === 'object' ? args : {} });
304
+ return flattenMcpResult(r);
305
+ }
306
+
307
+ export function resetHostMcpForTests() {
308
+ registry.clear();
309
+ openaiTools = [];
310
+ serversUp = [];
311
+ started = null;
312
+ }
313
+
314
+ export async function startHostMcps({ log = () => {}, home = os.homedir() } = {}) {
315
+ if (started) return started;
316
+ if (process.env.OZ_GROKBOT_MCP === '0') {
317
+ started = { tools: [], servers: [] };
318
+ return started;
319
+ }
320
+ started = (async () => {
321
+ const configs = loadHostMcpConfigs(home);
322
+ log(`cursor-backend: mcp loading n=${configs.length} ${configs.map((c) => c.name).join(',')}`);
323
+ const results = await Promise.allSettled(configs.map((cfg) => connectOne(cfg, log)));
324
+ for (let i = 0; i < results.length; i++) {
325
+ const cfg = configs[i];
326
+ const r = results[i];
327
+ if (r.status !== 'fulfilled') {
328
+ log(`cursor-backend: mcp ${cfg.name} FAIL ${r.reason?.message || r.reason}`);
329
+ continue;
330
+ }
331
+ serversUp.push(cfg.name);
332
+ for (const tool of r.value.tools) {
333
+ const openaiName = toolOpenaiName(cfg.name, tool.name);
334
+ if (registry.has(openaiName)) continue;
335
+ registry.set(openaiName, {
336
+ client: r.value.client,
337
+ server: cfg.name,
338
+ tool: tool.name,
339
+ description: tool.description || tool.name,
340
+ schema: tool.inputSchema,
341
+ });
342
+ }
343
+ log(`cursor-backend: mcp ${cfg.name} tools=${r.value.tools.length}`);
344
+ }
345
+ rebuildOpenai();
346
+ log(`cursor-backend: mcp ready servers=${serversUp.join(',') || 'none'} tools=${openaiTools.length}`);
347
+ return { tools: openaiTools, servers: serversUp };
348
+ })();
349
+ return started;
350
+ }
package/lib/ship.js ADDED
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Grok Ship on the zoo — factory tools for the hijacked Grok Bot.
3
+ *
4
+ * Shape borrowed from kunchenguid/grok-ship (MIT): one Firstmate bot the
5
+ * human talks to, one crewmate bot per repo, a worker that writes code on a
6
+ * branch, a FRESH adversarial review of that branch, and a pull request only
7
+ * when the review is clean. The human merges.
8
+ *
9
+ * What is different here, and why:
10
+ * - Workers are `claude-zoo -p` (open-claude-code through the :8402 proxy)
11
+ * in a git worktree, not Cursor cloud agents. Cloud agents bill Cursor and
12
+ * need Cursor's GitHub connector; the whole point of the hijack is x402
13
+ * per-call billing, so the coding worker must ride the zoo too.
14
+ * - The backlog is the forge's own issues (gh/glab) plus a small JSON ledger
15
+ * at ~/.openzoo/ship/tasks.json, not a SQLite file. Nothing to migrate.
16
+ * - The review is a one-shot zoo completion with NO chat history — that is
17
+ * what "fresh subagent" means in practice. It only sees the diff.
18
+ *
19
+ * Pure helpers take `run(cmd, cwd)` / `post(messages, model)` so tests can
20
+ * drive them without a shell or a wallet.
21
+ */
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { spawn } from 'node:child_process';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const here = path.dirname(fileURLToPath(import.meta.url));
29
+ export const SHIM_ROOT = path.dirname(here);
30
+
31
+ export function shipDir(home = os.homedir()) {
32
+ return path.join(home, '.openzoo', 'ship');
33
+ }
34
+ export function tasksPath(home = os.homedir()) {
35
+ return path.join(shipDir(home), 'tasks.json');
36
+ }
37
+
38
+ export function loadTasks(home = os.homedir()) {
39
+ try {
40
+ const j = JSON.parse(fs.readFileSync(tasksPath(home), 'utf8'));
41
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ export function saveTasks(home, tasks) {
48
+ fs.mkdirSync(shipDir(home), { recursive: true });
49
+ const tmp = `${tasksPath(home)}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(tasks, null, 2));
51
+ fs.renameSync(tmp, tasksPath(home));
52
+ return tasks;
53
+ }
54
+
55
+ export function newTaskId(now = Date.now(), rand = Math.random()) {
56
+ const t = now.toString(36).slice(-5).toUpperCase();
57
+ const r = Math.floor(rand * 1296).toString(36).padStart(2, '0').toUpperCase();
58
+ return `SH-${t}${r}`;
59
+ }
60
+
61
+ export function slugBranch(taskId, title = '') {
62
+ const slug = String(title || '')
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9]+/g, '-')
65
+ .replace(/^-+|-+$/g, '')
66
+ .slice(0, 40);
67
+ return `ship/${String(taskId).toLowerCase()}${slug ? `-${slug}` : ''}`;
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ forge */
71
+
72
+ /** Pure: which forge a remote points at, and which CLI we can use for it. */
73
+ export function detectForge({ remoteUrl = '', ghAuth = false, glabAuth = false } = {}) {
74
+ const u = String(remoteUrl || '').toLowerCase();
75
+ let forge = 'none';
76
+ if (/github\.com/.test(u)) forge = 'github';
77
+ else if (/gitlab\./.test(u)) forge = 'gitlab';
78
+ else if (/bitbucket\.org/.test(u)) forge = 'bitbucket';
79
+ else if (/cursor\.(com|sh)|origin\./.test(u)) forge = 'origin';
80
+ else if (u) forge = 'other';
81
+ let cli = null;
82
+ if (forge === 'github' && ghAuth) cli = 'gh';
83
+ if (forge === 'gitlab' && glabAuth) cli = 'glab';
84
+ return { forge, cli, remoteUrl: String(remoteUrl || '').trim() };
85
+ }
86
+
87
+ /** Shell-backed: ask git and the forge CLIs. `run` throws on non-zero. */
88
+ export async function probeForge(cwd, run) {
89
+ const tryRun = async (cmd) => {
90
+ try { return { ok: true, out: String(await run(cmd, cwd) || '') }; } catch (e) { return { ok: false, out: String(e?.message || e) }; }
91
+ };
92
+ const remote = await tryRun('git remote get-url origin');
93
+ const remoteUrl = remote.ok ? remote.out.trim().split('\n')[0] : '';
94
+ const gh = await tryRun('gh auth status');
95
+ const glab = await tryRun('glab auth status');
96
+ const ghAuth = gh.ok && !/not logged in/i.test(gh.out);
97
+ const glabAuth = glab.ok && !/not logged in/i.test(glab.out);
98
+ const base = await defaultBase(cwd, run);
99
+ return { ...detectForge({ remoteUrl, ghAuth, glabAuth }), base, cwd };
100
+ }
101
+
102
+ export async function defaultBase(cwd, run) {
103
+ try {
104
+ const ref = String(await run('git symbolic-ref --short refs/remotes/origin/HEAD', cwd) || '').trim();
105
+ const m = ref.match(/^origin\/(.+)$/);
106
+ if (m) return m[1];
107
+ } catch { /* fall through */ }
108
+ for (const b of ['main', 'master']) {
109
+ try {
110
+ await run(`git rev-parse --verify --quiet refs/heads/${b}`, cwd);
111
+ return b;
112
+ } catch { /* next */ }
113
+ }
114
+ return 'main';
115
+ }
116
+
117
+ /* ----------------------------------------------------------------- worker */
118
+
119
+ export function workerPrompt({ taskId, title, prompt, branch, base, forge = 'none' } = {}) {
120
+ return [
121
+ `Task ${taskId}: ${title || '(untitled)'}`,
122
+ '',
123
+ String(prompt || '').trim(),
124
+ '',
125
+ 'Working rules:',
126
+ `- You are in a dedicated git worktree already on branch ${branch} (base ${base}). Do not switch branches.`,
127
+ '- Implement the task. Run the project tests the way the repo runs them. Fix what you break.',
128
+ '- Commit with clear messages. When done: `git push -u origin ' + branch + '`.',
129
+ '- Do NOT open a pull request or merge request. A separate reviewer reads the pushed branch first.',
130
+ `- Forge: ${forge}. Do not assume GitHub.`,
131
+ '- Finish with a short summary: what changed, how it was tested, anything the reviewer should look at.',
132
+ ].join('\n');
133
+ }
134
+
135
+ /** argv for the worker process. Override the binary with OPENZOO_SHIP_WORKER. */
136
+ export function workerCommand({ prompt, shimRoot = SHIM_ROOT, env = process.env } = {}) {
137
+ const custom = env.OPENZOO_SHIP_WORKER;
138
+ const flags = ['-p', prompt, '--permission-mode', 'bypassPermissions', '--output-format', 'text'];
139
+ if (custom) return { file: custom, args: flags };
140
+ return { file: process.execPath, args: [path.join(shimRoot, 'bin', 'claude-zoo.js'), ...flags] };
141
+ }
142
+
143
+ export function isPidAlive(pid) {
144
+ const n = Number(pid);
145
+ if (!Number.isFinite(n) || n <= 0) return false;
146
+ try { process.kill(n, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
147
+ }
148
+
149
+ /**
150
+ * Create the worktree, spawn a detached worker, record the task.
151
+ * `run(cmd, cwd)` runs shell; `spawnFn` defaults to child_process.spawn.
152
+ */
153
+ export async function launchWorker({
154
+ cwd, title, prompt, base, taskId, home = os.homedir(), run, spawnFn = spawn, env = process.env, log = () => {},
155
+ } = {}) {
156
+ if (!cwd) throw new Error('cwd (repo path) is required');
157
+ if (!prompt) throw new Error('prompt is required');
158
+ const tasks = loadTasks(home);
159
+ const existing = taskId ? tasks[taskId] : null;
160
+ if (existing && isPidAlive(existing.pid)) throw new Error(`task ${taskId} still has a live worker (pid ${existing.pid})`);
161
+ const id = existing?.id || taskId || newTaskId();
162
+ const forge = existing
163
+ ? { forge: existing.forge, cli: existing.cli, remoteUrl: existing.remoteUrl, base: existing.base }
164
+ : await probeForge(cwd, run);
165
+ const baseBranch = existing?.base || base || forge.base || 'main';
166
+ const branch = existing?.branch || slugBranch(id, title);
167
+ const worktree = existing?.worktree || path.join(shipDir(home), 'worktrees', id);
168
+ const logPath = existing?.log || path.join(shipDir(home), 'logs', `${id}.log`);
169
+ fs.mkdirSync(path.dirname(worktree), { recursive: true });
170
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
171
+
172
+ if (!existing) {
173
+ try { await run(`git fetch origin ${baseBranch}`, cwd); } catch (e) { log(`ship: fetch ${baseBranch} failed: ${e.message}`); }
174
+ let start = `origin/${baseBranch}`;
175
+ try { await run(`git rev-parse --verify --quiet ${start}`, cwd); } catch { start = baseBranch; }
176
+ await run(`git worktree add -b ${branch} ${JSON.stringify(worktree)} ${start}`, cwd);
177
+ }
178
+
179
+ const text = workerPrompt({
180
+ taskId: id,
181
+ title: title || existing?.title,
182
+ prompt: existing ? `Follow-up on the branch you (or a prior worker) already pushed. Address these review findings, keep behavior otherwise unchanged, then push again:\n${prompt}` : prompt,
183
+ branch,
184
+ base: baseBranch,
185
+ forge: forge.forge,
186
+ });
187
+ const { file, args } = workerCommand({ prompt: text, env });
188
+ const out = fs.openSync(logPath, 'a');
189
+ fs.writeSync(out, `[ship] ${new Date().toISOString()} ${id} start ${file} in ${worktree}\n`);
190
+ const child = spawnFn(file, args, {
191
+ cwd: worktree,
192
+ detached: true,
193
+ stdio: ['ignore', out, out],
194
+ env: { ...env, OPENZOO_SHIP_TASK: id },
195
+ });
196
+ child.unref?.();
197
+ fs.closeSync(out);
198
+
199
+ tasks[id] = {
200
+ ...(existing || {}),
201
+ id,
202
+ title: String(title || existing?.title || ''),
203
+ prompt: existing ? `${existing.prompt}\n\n[follow-up] ${String(prompt)}` : String(prompt),
204
+ repo: existing?.repo || cwd,
205
+ attempts: (existing?.attempts || 0) + 1,
206
+ forge: forge.forge,
207
+ cli: forge.cli,
208
+ remoteUrl: forge.remoteUrl,
209
+ base: baseBranch,
210
+ branch,
211
+ worktree,
212
+ log: logPath,
213
+ pid: child.pid ?? null,
214
+ status: 'underway',
215
+ review: null,
216
+ result: null,
217
+ created_at: Date.now(),
218
+ updated_at: Date.now(),
219
+ };
220
+ saveTasks(home, tasks);
221
+ log(`ship: ${id} worker pid=${child.pid} branch=${branch} wt=${worktree}`);
222
+ return tasks[id];
223
+ }
224
+
225
+ export function tailFile(p, maxChars = 4000) {
226
+ try {
227
+ const s = fs.readFileSync(p, 'utf8');
228
+ return s.length > maxChars ? s.slice(-maxChars) : s;
229
+ } catch {
230
+ return '';
231
+ }
232
+ }
233
+
234
+ export async function taskStatus({ taskId, home = os.homedir(), run } = {}) {
235
+ const tasks = loadTasks(home);
236
+ const task = tasks[taskId];
237
+ if (!task) return { ok: false, error: `no task ${taskId}` };
238
+ const alive = isPidAlive(task.pid);
239
+ let commits = '';
240
+ let pushed = false;
241
+ try { commits = String(await run(`git log --oneline ${task.base}..${task.branch}`, task.worktree) || '').trim(); } catch { /* none yet */ }
242
+ try {
243
+ await run(`git fetch origin ${task.branch}`, task.worktree);
244
+ await run(`git rev-parse --verify --quiet origin/${task.branch}`, task.worktree);
245
+ pushed = true;
246
+ } catch { pushed = false; }
247
+ if (!alive && task.status === 'underway') {
248
+ task.status = pushed ? 'pushed' : 'worker-exited';
249
+ task.updated_at = Date.now();
250
+ saveTasks(home, tasks);
251
+ }
252
+ return {
253
+ ok: true,
254
+ task: { id: task.id, title: task.title, branch: task.branch, base: task.base, status: task.status, result: task.result },
255
+ worker: { pid: task.pid, alive },
256
+ pushed,
257
+ commits: commits.split('\n').filter(Boolean).slice(0, 30),
258
+ logTail: tailFile(task.log, 3000),
259
+ reviewGate: task.review ? reviewGate(task.review) : null,
260
+ };
261
+ }
262
+
263
+ /* ----------------------------------------------------------------- review */
264
+
265
+ export const REVIEW_JSON_SHAPE = `{
266
+ "findings": [
267
+ { "severity": "error|warning|info", "action": "ask-user|auto-fix|no-op", "file": "path", "line": 1, "description": "..." }
268
+ ],
269
+ "risk_level": "low|medium|high",
270
+ "risk_rationale": "one sentence"
271
+ }`;
272
+
273
+ /** Fresh-context review prompt. No chat history rides along on purpose. */
274
+ export function reviewMessages({ repo = '', branch, base, diff, gitlog = '', stat = '' } = {}) {
275
+ const system = [
276
+ 'You are an adversarial code reviewer. You see only a branch diff. You have no tools and cannot run anything.',
277
+ 'Find what would go wrong if this merged: bugs, security, performance regressions, breaking changes, missing error handling, and real simplifications that keep behavior identical.',
278
+ 'Anchor every finding to a file and a 1-indexed line in the changed code. Do not report style, formatting, lint, or type-check noise. No generic advice.',
279
+ 'Severity error must not merge. warning can follow up. info is optional.',
280
+ 'action ask-user = product behavior or the author\'s intent is in question (default when unsure). auto-fix = non-functional, fixable without discussing intent. no-op = informational.',
281
+ 'Do a full pass before answering. If the change is clean, return an empty findings array.',
282
+ `Return ONLY JSON in this shape:\n${REVIEW_JSON_SHAPE}`,
283
+ ].join('\n');
284
+ const user = [
285
+ `repo: ${repo}`,
286
+ `branch: ${branch}`,
287
+ `base: ${base}`,
288
+ gitlog ? `commits:\n${gitlog}` : '',
289
+ stat ? `diffstat:\n${stat}` : '',
290
+ 'diff:',
291
+ '```diff',
292
+ String(diff || '').trim() || '(empty diff)',
293
+ '```',
294
+ ].filter(Boolean).join('\n');
295
+ return [{ role: 'system', content: system }, { role: 'user', content: user }];
296
+ }
297
+
298
+ const SEVERITIES = new Set(['error', 'warning', 'info']);
299
+ const ACTIONS = new Set(['ask-user', 'auto-fix', 'no-op']);
300
+
301
+ /** Tolerant: fences, prose around the JSON, odd casing. Never throws. */
302
+ export function parseReview(text) {
303
+ const s = String(text || '');
304
+ let obj = null;
305
+ const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
306
+ const candidates = [fenced?.[1], s.slice(s.indexOf('{'), s.lastIndexOf('}') + 1), s];
307
+ for (const c of candidates) {
308
+ if (!c) continue;
309
+ try { obj = JSON.parse(c); break; } catch { /* next */ }
310
+ }
311
+ if (!obj || typeof obj !== 'object') {
312
+ return {
313
+ findings: [{ severity: 'error', action: 'no-op', file: '', line: 0, description: 'reviewer returned no JSON' }],
314
+ risk_level: 'high',
315
+ risk_rationale: 'review output was not parseable',
316
+ parse_error: true,
317
+ raw: s.slice(0, 2000),
318
+ };
319
+ }
320
+ const findings = (Array.isArray(obj.findings) ? obj.findings : []).map((f) => {
321
+ const severity = String(f?.severity || 'warning').toLowerCase();
322
+ const action = String(f?.action || 'ask-user').toLowerCase();
323
+ return {
324
+ severity: SEVERITIES.has(severity) ? severity : 'warning',
325
+ action: ACTIONS.has(action) ? action : 'ask-user',
326
+ file: String(f?.file || ''),
327
+ line: Number.isFinite(Number(f?.line)) ? Number(f.line) : 0,
328
+ description: String(f?.description || '').slice(0, 1000),
329
+ };
330
+ });
331
+ const risk = String(obj.risk_level || '').toLowerCase();
332
+ return {
333
+ findings,
334
+ risk_level: ['low', 'medium', 'high'].includes(risk) ? risk : (findings.some((f) => f.severity === 'error') ? 'high' : 'medium'),
335
+ risk_rationale: String(obj.risk_rationale || ''),
336
+ parse_error: false,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Grok Ship's loop, as a decision:
342
+ * error -> blocked, do not raise
343
+ * ask-user -> blocked, one decision card to the human
344
+ * auto-fix -> blocked, send back to the worker, then a FRESH review
345
+ * info / none -> clean, the PR may open
346
+ */
347
+ export function reviewGate(review) {
348
+ const findings = Array.isArray(review?.findings) ? review.findings : [];
349
+ const errors = findings.filter((f) => f.severity === 'error');
350
+ const askUser = findings.filter((f) => f.action === 'ask-user' && f.severity !== 'error');
351
+ const autoFix = findings.filter((f) => f.action === 'auto-fix' && f.severity !== 'error');
352
+ if (review?.parse_error) return { clean: false, reason: 'review did not return JSON; run ship_review again', errors, askUser, autoFix };
353
+ if (errors.length) return { clean: false, reason: `${errors.length} error finding(s) block the PR`, errors, askUser, autoFix };
354
+ if (autoFix.length) return { clean: false, reason: `${autoFix.length} auto-fix finding(s): send them to the worker, then review again fresh`, errors, askUser, autoFix };
355
+ if (askUser.length) return { clean: false, reason: `${askUser.length} ask-user finding(s): one decision card to the human, do not raise`, errors, askUser, autoFix };
356
+ return { clean: true, reason: findings.length ? 'only info findings' : 'no findings', errors, askUser, autoFix };
357
+ }
358
+
359
+ export async function defaultZooPost(messages, model, { fetchFn = fetch, port = 8402, maxTokens = 4096, timeoutMs = 10 * 60_000 } = {}) {
360
+ const r = await fetchFn(`http://127.0.0.1:${port}/v1/chat/completions`, {
361
+ method: 'POST',
362
+ headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
363
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens, temperature: 0 }),
364
+ signal: AbortSignal.timeout(timeoutMs),
365
+ });
366
+ const data = await r.json().catch(() => ({}));
367
+ if (!r.ok) throw new Error(data?.error?.message || `zoo ${r.status}`);
368
+ const c = data?.choices?.[0]?.message?.content;
369
+ return Array.isArray(c) ? c.map((p) => (typeof p === 'string' ? p : p?.text || '')).join('') : String(c || '');
370
+ }
371
+
372
+ /**
373
+ * Review a pushed branch with a fresh one-shot completion. Reads the branch
374
+ * through git in the repo (or the task worktree), never a worker VM.
375
+ */
376
+ export async function reviewBranch({
377
+ taskId, cwd, branch, base, model, home = os.homedir(), run, post = defaultZooPost, maxDiffChars = Number(process.env.OPENZOO_SHIP_DIFF_CHARS || 160_000), log = () => {},
378
+ } = {}) {
379
+ const tasks = loadTasks(home);
380
+ const task = taskId ? tasks[taskId] : null;
381
+ if (taskId && !task) return { ok: false, error: `no task ${taskId}` };
382
+ const dir = task?.worktree || cwd;
383
+ const br = branch || task?.branch;
384
+ const bs = base || task?.base || (await defaultBase(dir, run));
385
+ if (!dir || !br) return { ok: false, error: 'need taskId, or cwd + branch' };
386
+
387
+ try { await run(`git fetch origin ${br}`, dir); } catch (e) { log(`ship: fetch ${br} ${e.message}`); }
388
+ let tip = br;
389
+ try { await run(`git rev-parse --verify --quiet origin/${br}`, dir); tip = `origin/${br}`; } catch { /* local branch */ }
390
+ const stat = String(await run(`git diff --stat ${bs}...${tip}`, dir) || '').trim();
391
+ let diff = String(await run(`git diff ${bs}...${tip}`, dir) || '');
392
+ const gitlog = String(await run(`git log --oneline ${bs}..${tip}`, dir) || '').trim();
393
+ if (!diff.trim()) return { ok: false, error: `no diff between ${bs} and ${tip}; has the worker pushed?` };
394
+ let truncated = false;
395
+ if (diff.length > maxDiffChars) { diff = `${diff.slice(0, maxDiffChars)}\n… (diff truncated at ${maxDiffChars} chars)`; truncated = true; }
396
+
397
+ const messages = reviewMessages({ repo: task?.repo || dir, branch: br, base: bs, diff, gitlog, stat });
398
+ log(`ship: review ${br} vs ${bs} diff=${diff.length}c model=${model}`);
399
+ const text = await post(messages, model);
400
+ const review = parseReview(text);
401
+ const gate = reviewGate(review);
402
+ if (task) {
403
+ task.review = { ...review, reviewed_at: Date.now(), tip, truncated };
404
+ task.status = gate.clean ? 'review-clean' : 'review-blocked';
405
+ task.updated_at = Date.now();
406
+ saveTasks(home, tasks);
407
+ }
408
+ return { ok: true, taskId: task?.id || null, branch: br, base: bs, truncated, review, gate };
409
+ }
410
+
411
+ /* --------------------------------------------------------------------- PR */
412
+
413
+ export function prCommand({ cli, branch, base, title, body = '' } = {}) {
414
+ const t = JSON.stringify(String(title || branch));
415
+ const b = JSON.stringify(String(body || ''));
416
+ if (cli === 'gh') return `gh pr create --head ${branch} --base ${base} --title ${t} --body ${b}`;
417
+ if (cli === 'glab') return `glab mr create --source-branch ${branch} --target-branch ${base} --title ${t} --description ${b} --yes`;
418
+ throw new Error(`no authenticated forge CLI for this repo (cli=${cli || 'none'}); push is visible but the PR must be opened by hand`);
419
+ }
420
+
421
+ export async function openPr({ taskId, title, body, home = os.homedir(), run, log = () => {} } = {}) {
422
+ const tasks = loadTasks(home);
423
+ const task = tasks[taskId];
424
+ if (!task) return { ok: false, error: `no task ${taskId}` };
425
+ if (!task.review) return { ok: false, error: 'no review on record; run ship_review first' };
426
+ const gate = reviewGate(task.review);
427
+ if (!gate.clean) return { ok: false, error: `review not clean: ${gate.reason}`, gate };
428
+ try {
429
+ await run(`git fetch origin ${task.branch}`, task.repo);
430
+ await run(`git rev-parse --verify --quiet origin/${task.branch}`, task.repo);
431
+ } catch {
432
+ return { ok: false, error: `branch ${task.branch} is not on origin yet` };
433
+ }
434
+ const summary = body || [
435
+ task.title,
436
+ '',
437
+ task.prompt,
438
+ '',
439
+ `Adversarial review: ${gate.reason} (risk ${task.review.risk_level}).`,
440
+ `Ship task ${task.id}.`,
441
+ ].join('\n');
442
+ const cmd = prCommand({ cli: task.cli, branch: task.branch, base: task.base, title: title || task.title || task.branch, body: summary });
443
+ log(`ship: ${task.id} ${cmd.slice(0, 80)}`);
444
+ const out = String(await run(cmd, task.repo) || '');
445
+ const url = out.match(/https?:\/\/\S+/)?.[0] || out.trim();
446
+ task.result = url;
447
+ task.status = 'pr-open';
448
+ task.updated_at = Date.now();
449
+ saveTasks(home, tasks);
450
+ return { ok: true, taskId: task.id, url, branch: task.branch, base: task.base };
451
+ }
452
+
453
+ /* ------------------------------------------------------------------ briefs */
454
+
455
+ export function firstmateBrief() {
456
+ return [
457
+ 'You are Firstmate: the one bot the human talks to. They bring you everything; you make sure it gets done.',
458
+ 'Other sidebar bots are crewmates with standing briefs, one per repo. Before creating one, list_agents and reuse a crewmate whose brief already covers the repo.',
459
+ 'Hand work off with message_agent. Tag every hand-off with a short task id and ask for the outcome back against that id. Empty results still get reported.',
460
+ 'Code goes through a crewmate, never through you. You never call ship_launch_worker, ship_review, or ship_open_pr yourself.',
461
+ 'Classify factory work as scout (a report, never a PR) or ship (a branch, a fresh adversarial review, then a PR). Ship only when the human authorized the change.',
462
+ 'Nothing merges without the human\'s explicit word. Relay PR URLs when they land. Bring decisions one at a time: what, why now, real options, your recommendation.',
463
+ 'Detect the forge (GitHub, GitLab, Bitbucket, Origin). Do not assume GitHub. Speak in outcomes, not mechanics. Prefer schedule_wakeup over spawning more bots.',
464
+ ].join(' ');
465
+ }
466
+
467
+ export function crewmateBrief({ repo, forge = 'unknown' } = {}) {
468
+ return [
469
+ `You are the crewmate for the repo at ${repo} (forge: ${forge}). Firstmate hands you tasks with an id; report every outcome back to Firstmate against that id with message_agent.`,
470
+ 'Scout: investigate with read_file / exec (git log, grep, tests). Write the report to ~/.openzoo/ship/reports/<task id>.md and return the path. Never a PR.',
471
+ 'Ship: ship_launch_worker with cwd=the repo, a clear prompt (goal, acceptance criteria, constraints). Poll ship_status until the worker exits and the branch is pushed.',
472
+ 'Then ship_review on the task. If the gate is not clean: for auto-fix findings call ship_launch_worker again with the same task id and the findings as the prompt (same branch, same worktree); ask-user findings go to Firstmate as one decision; error findings block. Review again fresh after any fix.',
473
+ 'Only when ship_review reports gate.clean: ship_open_pr. Relay the URL. Never merge; the human does.',
474
+ 'Use the forge CLI recorded for this repo (gh or glab) via exec for issues and checks. Do not assume GitHub.',
475
+ ].join(' ');
476
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.54",
3
+ "version": "0.50.56",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/lib/xb DELETED
File without changes