openzoo 0.50.55 → 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.
@@ -53,6 +53,7 @@ import {
53
53
  desktopAction, displayBounds, imageSize, noteShotMeta, resolveAppName,
54
54
  } from './grokbotDesktop.js';
55
55
  import { startHostMcps, hostMcpTools, hostMcpHas, callHostMcp, hostMcpServers } from './mcpbridge.js';
56
+ import * as ship from './ship.js';
56
57
 
57
58
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
58
59
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -1456,6 +1457,27 @@ function seedBriefOnCanvas(agent, log) {
1456
1457
  }
1457
1458
  return kickBriefedAgent(agent, log || ((m) => console.error(m)));
1458
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
+ }
1459
1481
  function groupMemberIds(agentId) {
1460
1482
  const a = (cachedAgentList() || []).find((x) => x.id === agentId);
1461
1483
  if (!a?.isGroup) return [];
@@ -2171,6 +2193,80 @@ const LOCAL_TOOLS = [
2171
2193
  },
2172
2194
  },
2173
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
+ },
2174
2270
  ];
2175
2271
 
2176
2272
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
@@ -2367,6 +2463,9 @@ async function runLocalTool(name, args, log, ctx = {}) {
2367
2463
  if (!who) return 'ERROR no such agent';
2368
2464
  return JSON.stringify(cancelAgentWakeup(who.id));
2369
2465
  }
2466
+ if (name.startsWith('ship_')) {
2467
+ return await runShipTool(name, args, log, ctx);
2468
+ }
2370
2469
  if (hostMcpHas(name)) {
2371
2470
  return await callHostMcp(name, args);
2372
2471
  }
@@ -2376,6 +2475,65 @@ async function runLocalTool(name, args, log, ctx = {}) {
2376
2475
  }
2377
2476
  }
2378
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
+
2379
2537
  /** Model parked instead of writing files. Host keeps the tool loop going. */
2380
2538
  export function looksStoppedReply(s) {
2381
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 || ''));
@@ -2466,7 +2624,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2466
2624
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2467
2625
  })(),
2468
2626
  `You HAVE local tools on the user's computer via ${via}.`,
2469
- '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.',
2470
2630
  (() => {
2471
2631
  const extra = hostMcpTools();
2472
2632
  const names = extra.map((t) => t.function.name);
@@ -2994,6 +3154,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2994
3154
  activeAgentId: active,
2995
3155
  });
2996
3156
  }
3157
+ if (seedShipNudge(list, active)) log(`cursor-backend: ship nudge painted on ${active}`);
2997
3158
  log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'} active=${active || 'none'}`);
2998
3159
  return true;
2999
3160
  }
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.55",
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