symbiot 0.40.5 → 0.41.1

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/index.mjs CHANGED
@@ -17,41 +17,29 @@
17
17
  // Flags: --dir <path> where to look (default: your home folder)
18
18
  // --since <n> days back for `week` (default 7)
19
19
  // --plain no colour, no spinner (for piping)
20
-
21
- import Anthropic from "@anthropic-ai/sdk";
22
- import { spawn, spawnSync } from "node:child_process";
23
- import { homedir, totalmem, cpus as oscpus } from "node:os";
24
- import { join } from "node:path";
25
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, realpathSync, readdirSync } from "node:fs";
20
+ //
21
+ // This file is the CLI. The work is in modules: scan.mjs (finding and reading
22
+ // your repos), ai.mjs (the AI it writes with), writeups.mjs (what it writes),
23
+ // tasks.mjs (tasks, review and Approve), agents.mjs (handing work to an agent),
24
+ // drift.mjs, server.mjs (`symbiot app`'s server) and ui.mjs (its page).
25
+
26
+ import { spawn } from "node:child_process";
27
+ import { homedir } from "node:os";
28
+ import { realpathSync } from "node:fs";
26
29
  import { createInterface } from "node:readline";
27
- import { createServer } from "node:http";
28
- import { randomBytes } from "node:crypto";
29
- import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import { fileURLToPath } from "node:url";
30
31
  import { EMBEDDED_UI } from "./ui.mjs";
31
- import { detectMailSources, mailActivity } from "./mail.mjs";
32
- import { CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState } from "./core.mjs";
33
- import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
34
- import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
35
- import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
36
- import { mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
37
- import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
38
- import { watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, checkWatch, startWatches } from "./watch.mjs";
39
-
40
- const HERE = fileURLToPath(new URL(".", import.meta.url));
41
- let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
42
-
43
- const MAX_REPOS = 14;
44
- const MAX_COMMITS = 140;
45
- const MAX_TOKENS = 1600;
46
-
47
- // The providers Symbiot can write with. Models are sensible defaults; override
48
- // per provider at login, or globally with SYMBIOT_MODEL.
49
- const PROVIDERS = {
50
- anthropic: { label: "Claude (Anthropic)", env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], keyUrl: "https://console.anthropic.com/settings/keys", keyName: "Anthropic API key (sk-ant-…)", model: "claude-opus-5-5" },
51
- openai: { label: "OpenAI (GPT)", env: ["OPENAI_API_KEY"], keyUrl: "https://platform.openai.com/api-keys", keyName: "OpenAI API key (sk-…)", model: "gpt-4o-mini" },
52
- gemini: { label: "Gemini (Google)", env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], keyUrl: "https://aistudio.google.com/apikey", keyName: "Google AI API key", model: "gemini-1.5-flash" },
53
- ollama: { label: "Local model (Ollama)", local: true, keyUrl: "https://ollama.com", keyName: null, model: "llama3.1" },
54
- };
32
+ import { VERSION, CONFIG_PATH, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
33
+ import { HANDOFF_PROMPT, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, parseQuestions, agentQuestions } from "./agents.mjs";
34
+ import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
35
+ import { setScanOptions, scanBase, authorship, readmeInfo, repoShape, houseRules, reportFooter, findAllRepos, buildMap } from "./scan.mjs";
36
+ import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.mjs";
37
+ import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
38
+ import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
39
+ import { loadScreens, blueprint } from "./screens.mjs";
40
+ import { mapPage, pressRegion, typeRegion, signIn, isTrusted } from "./headless.mjs";
41
+ import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief } from "./watch.mjs";
42
+ import { startApp, updateCmd } from "./server.mjs";
55
43
 
56
44
  // ---- tiny arg parse --------------------------------------------------------
57
45
  const argv = process.argv.slice(2);
@@ -63,22 +51,7 @@ const flag = (name, def) => {
63
51
  const has = (name) => argv.includes(`--${name}`);
64
52
  const PLAIN = has("plain") || !process.stdout.isTTY;
65
53
  let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
66
- let LAST_MAP = null; // cached graph so node clicks don't rescan
67
- let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
68
- // a.b.c numeric compare: only a HIGHER npm version is an update (a local build
69
- // ahead of npm must not be offered a "newer" older one)
70
- function semverGt(a, b) { const p = (v) => String(v || "").replace(/^v/, "").split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0); const x = p(a), y = p(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }
71
- // SYMBIOT_REGISTRY points the check at another registry (the tests use a fake one)
72
- const REGISTRY = (process.env.SYMBIOT_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
73
- async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
74
- // The in-app update installs the EXACT newest version (not the `latest` tag, which
75
- // npm's cache/propagation can resolve stale — that caused an update loop where the
76
- // install kept re-fetching the same old version). --prefer-online skips a stale
77
- // cached packument.
78
- function updateCmd(latest, current, platform = process.platform) {
79
- const target = latest && semverGt(latest, current) ? latest : "latest";
80
- return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
81
- }
54
+ setScanOptions({ dir: flag("dir", ""), plain: PLAIN });
82
55
 
83
56
  // ---- colour + spinner ------------------------------------------------------
84
57
  const c = PLAIN
@@ -98,430 +71,6 @@ function spinner(label) {
98
71
  return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
99
72
  }
100
73
 
101
- // ---- config + provider resolution (config files: core.mjs) -----------------
102
- function antProfileExists() {
103
- try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
104
- }
105
- // ---- tasks: a persistent checklist (stored by core.mjs) -------------------
106
- function addTask(text, repo) {
107
- text = String(text || "").trim().slice(0, 300);
108
- if (!text) return { error: "empty" };
109
- const t = loadTasks();
110
- // The same text already open in the same repo (in review counts): that task, not a duplicate.
111
- const same = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
112
- const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && same(x.text) === same(text));
113
- if (dup) return { ...dup, duplicate: true };
114
- const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
115
- t.unshift(item); saveTasks(t); return item;
116
- }
117
- function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
118
- function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
119
- function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
120
- // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
121
- function completedInRepo(repoPath) {
122
- try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
123
- catch { return []; }
124
- }
125
- function repoPathMap() {
126
- const byName = {};
127
- const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo" || n.type === "folder").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos();
128
- for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
129
- return byName;
130
- }
131
- // "Check what was handed out, see what's completed, then archive it" — with an
132
- // approval step in between. An agent ticking an item in TASKS.md means "done,
133
- // please review", NOT archived: it waits in review until the user approves it
134
- // (approveRepo: branch + commit + push + PR, then archive) or sends it back
135
- // (sendBack: unticked, open again). Only a task the USER ticks archives directly.
136
- // Tasks held for an agent that has since finished land here too, and an agent
137
- // starts on what's still open in them (one `push --open` started can't do that).
138
- function syncTasks() {
139
- const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
140
- for (const x of t) {
141
- if (x.archived || x.done || x.review || !x.repo) continue;
142
- if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p && startHeldTasks(p)) started++; checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
143
- if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
144
- }
145
- for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
146
- saveTasks(t);
147
- return { review, archived, started };
148
- }
149
- // git with an argv (task text goes into commit messages — never through a shell)
150
- function git(repo, args, timeout = 30000) {
151
- const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
152
- return { ok: r.status === 0, out: String(r.stdout || "").replace(/\s+$/, ""), err: String(r.stderr || (r.error && r.error.message) || "").trim() };
153
- }
154
- const NOT_SYMBIOT = ["--", ".", ":(exclude).symbiot"]; // .symbiot/ is Symbiot's scratch (brief + agent log), never shipped
155
- function workingChanges(path) {
156
- const st = git(path, ["status", "--porcelain", "-uall", ...NOT_SYMBIOT]);
157
- const files = st.out ? st.out.split("\n").map((l) => ({ st: l.slice(0, 2).trim(), file: l.slice(3) })) : [];
158
- const short = git(path, ["diff", "--shortstat", "HEAD", ...NOT_SYMBIOT]).out, n = (re) => +((short.match(re) || [])[1] || 0);
159
- const ins = n(/(\d+) insertion/), del = n(/(\d+) deletion/), fresh = files.filter((f) => f.st === "??").length;
160
- const stat = [ins && `+${ins}`, del && `−${del}`, fresh && `${fresh} new`].filter(Boolean).join(" ");
161
- return { branch: git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).out, files, stat };
162
- }
163
- // The full diff the agent left behind: tracked changes + new files, capped.
164
- function workingDiff(path, cap = 400000) {
165
- let d = git(path, ["diff", "HEAD", ...NOT_SYMBIOT]).out;
166
- for (const f of workingChanges(path).files.filter((x) => x.st === "??")) {
167
- if (d.length > cap) break;
168
- d += (d ? "\n" : "") + git(path, ["diff", "--no-index", "--", "/dev/null", f.file]).out; // exits 1 on a difference — output is what we want
169
- }
170
- return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
171
- }
172
- // A repo that publishes on merge: a GitHub workflow runs `npm publish` on a push
173
- // to a branch, not only on a tag. Its release is the version npm has; a v* tag,
174
- // if any, is a best-effort record that can lag or be missing.
175
- function publishesOnMerge(path) {
176
- const dir = join(path, ".github", "workflows"); let files = []; try { files = readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)); } catch { return false; }
177
- return files.some((f) => { let t = ""; try { t = readFileSync(join(dir, f), "utf8"); } catch {} return /\bnpm publish\b/.test(t) && /^\s*push:\s*\n\s+branches:/m.test(t); });
178
- }
179
- // Merged work that isn't released yet, or null when there's none, or the repo
180
- // has no releases to measure from. bump: the version these uncommitted changes
181
- // set in package.json, if they change it.
182
- // - Released by v* tag: how many commits the default branch (or origin's copy,
183
- // where approved PRs merge) is past its last v* tag.
184
- // - Publishes on merge (npm: true): how many commits it's past the one that set
185
- // the version npm has (since), once learnNpm knows. A branch whose version
186
- // isn't on npm yet is merged and due to publish: { pending: version }.
187
- function unreleased(path) {
188
- const base = gitDefaultBranch(path), pom = publishesOnMerge(path); let best = null;
189
- for (const ref of ["refs/heads/" + base, "refs/remotes/origin/" + base]) {
190
- if (!git(path, ["rev-parse", "-q", "--verify", ref]).ok) continue;
191
- let r = null;
192
- if (pom) {
193
- const k = npmKey(path, ref), v = k.slice(k.lastIndexOf("@") + 1), known = k && NPM_HAS.get(k); if (!known) continue;
194
- if (!known.yes) r = { base, npm: true, pending: v, ahead: 0 };
195
- else {
196
- const set = git(path, ["log", "-1", "--format=%H", "-G", `"version"[[:space:]]*:[[:space:]]*"${v.replace(/\./g, "\\.")}"`, ref, "--", "package.json"]).out;
197
- r = { base, npm: true, since: v, ahead: set ? +git(path, ["rev-list", "--count", set + ".." + ref]).out || 0 : 0 };
198
- }
199
- } else {
200
- const tag = git(path, ["describe", "--tags", "--abbrev=0", "--match", "v*", ref]).out; if (!tag) continue;
201
- r = { base, tag, ahead: +git(path, ["rev-list", "--count", tag + ".." + ref]).out || 0 };
202
- }
203
- // the copy furthest ahead; a merged version still to publish says more than a count
204
- if (!best || (r.pending && !best.pending) || (!best.pending && r.ahead > best.ahead)) best = r;
205
- }
206
- if (!best || (!best.ahead && !best.pending)) return null;
207
- const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
208
- let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
209
- const was = ver(git(path, ["show", "HEAD:package.json"]).out);
210
- return { ...best, ...(now && was && now !== was ? { bump: now } : {}) };
211
- }
212
- // Approve can bump the version in the PR itself: offered when the committed
213
- // version is already released and these changes don't change it. Released: its
214
- // v* tag exists, or npm has it (a repo that publishes on merge may have no tag,
215
- // or one its release couldn't push). { version, patch, minor } or null.
216
- function bumpOffer(path) {
217
- const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
218
- const was = ver(git(path, ["show", "HEAD:package.json"]).out), m = was.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) return null;
219
- let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
220
- if (now !== was || !(git(path, ["rev-parse", "-q", "--verify", "refs/tags/v" + was]).ok || onNpm(path))) return null;
221
- return { version: was, patch: `${m[1]}.${m[2]}.${+m[3] + 1}`, minor: `${m[1]}.${+m[2] + 1}.0` };
222
- }
223
- // Whether npm has the committed version, as learnNpm last found ("name@version" →
224
- // { yes, at }). bumpOffer only reads this; learnNpm asks the registry first.
225
- const NPM_HAS = new Map();
226
- function npmKey(path, ref = "HEAD") {
227
- let p = {}; try { p = JSON.parse(git(path, ["show", ref + ":package.json"]).out) || {}; } catch {}
228
- return typeof p.name === "string" && p.name && !p.private && typeof p.version === "string" && p.version ? p.name + "@" + p.version : "";
229
- }
230
- function onNpm(path) { const k = npmKey(path); return !!(k && NPM_HAS.get(k) && NPM_HAS.get(k).yes); }
231
- // Ask the registry about each repo's committed version, and for a repo that
232
- // publishes on merge, its default branch's (unreleased measures from that). A
233
- // yes is kept; a no is asked again after 5 minutes (it may have just been
234
- // published). True when it learned of a version on npm.
235
- async function learnNpm(paths, registry = REGISTRY) {
236
- let learned = false;
237
- const keys = new Set();
238
- for (const path of new Set(paths.filter(Boolean))) {
239
- keys.add(npmKey(path));
240
- if (publishesOnMerge(path)) { const base = gitDefaultBranch(path); for (const ref of ["refs/heads/" + base, "refs/remotes/origin/" + base]) keys.add(npmKey(path, ref)); }
241
- }
242
- await Promise.all([...keys].map(async (k) => {
243
- const was = NPM_HAS.get(k); if (!k || (was && (was.yes || Date.now() - was.at < 300000))) return;
244
- const at = k.lastIndexOf("@"), name = k.slice(0, at), version = k.slice(at + 1);
245
- try {
246
- const r = await fetch(`${registry}/${encodeURIComponent(name).replace(/^%40/, "@")}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(5000) });
247
- const yes = r.ok && String((await r.json().catch(() => ({}))).version || "") === version;
248
- NPM_HAS.set(k, { yes, at: Date.now() }); learned = learned || yes;
249
- } catch {}
250
- }));
251
- return learned;
252
- }
253
- // An agent's question that needs a release ("Once 0.41.0 is installed: …") names
254
- // a version of the repo's own npm package. The Agents tab shows it next to the
255
- // version installed here and the one on npm, and doesn't offer a "Done" answer
256
- // until the step is possible: a release that isn't out, or isn't installed yet,
257
- // can't have been tried. The version asked about is the highest one the question
258
- // names in the package's major, up to its next minor (not "ydotool 0.1.8").
259
- function releaseNeeded(text, current) {
260
- const p = (v) => v.split(".").map(Number), [maj, min] = p(current);
261
- let best = "";
262
- for (const [v] of String(text || "").matchAll(/\b\d+\.\d+\.\d+\b/g)) { const [a, b] = p(v); if (a === maj && b <= min + 1 && (!best || semverGt(v, best))) best = v; }
263
- return best;
264
- }
265
- // The version npm -g installed, or for Symbiot this app's own; "" if not installed.
266
- function installedVersion(name) {
267
- if (name === "symbiot") return VERSION;
268
- const root = process.platform === "win32" ? join(process.execPath, "..", "node_modules") : join(process.execPath, "..", "..", "lib", "node_modules");
269
- try { return String(JSON.parse(readFileSync(join(root, name, "package.json"), "utf8")).version || ""); } catch { return ""; }
270
- }
271
- // The newest version on npm ("" if unknown), asked again after 5 minutes.
272
- const NPM_LATEST = new Map();
273
- async function npmLatest(name, registry = REGISTRY) {
274
- if (name === "symbiot" && LATEST_VERSION) return LATEST_VERSION;
275
- const was = NPM_LATEST.get(name); if (was && Date.now() - was.at < 300000) return was.v;
276
- let v = was ? was.v : "";
277
- try {
278
- const r = await fetch(`${registry}/${encodeURIComponent(name).replace(/^%40/, "@")}`, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(5000) });
279
- if (r.ok) v = String(((await r.json())["dist-tags"] || {}).latest || "");
280
- } catch {}
281
- NPM_LATEST.set(name, { v, at: Date.now() });
282
- return v;
283
- }
284
- // Mark each open question that needs a release with { name, needs, installed,
285
- // npm, waiting }. waiting: "npm" (not published yet), "install" (published, not
286
- // installed here) or "" (possible now).
287
- async function withReleases(agents, registry = REGISTRY) {
288
- for (const a of agents) {
289
- const qs = (a.ask && a.ask.questions) || []; if (!qs.length) continue;
290
- let pkg = {}; try { pkg = JSON.parse(readFileSync(join(a.path, "package.json"), "utf8")) || {}; } catch {}
291
- if (typeof pkg.name !== "string" || !pkg.name || pkg.private || !/^\d+\.\d+\.\d+/.test(String(pkg.version || ""))) continue;
292
- const asks = qs.map((q) => [q, releaseNeeded(q.q + " " + q.context, pkg.version)]).filter((x) => x[1]); if (!asks.length) continue;
293
- const npm = await npmLatest(pkg.name, registry), installed = installedVersion(pkg.name);
294
- for (const [q, needs] of asks) q.release = { name: pkg.name, needs, installed, npm, waiting: npm && semverGt(needs, npm) ? "npm" : installed && semverGt(needs, installed) ? "install" : "" };
295
- }
296
- return agents;
297
- }
298
- // Set the version in package.json and in package-lock.json / npm-shrinkwrap.json
299
- // (top level and its "" package), keeping each file's indent. Gives back a
300
- // function that restores them, for when the ship fails.
301
- function setVersion(path, to) {
302
- const saved = [];
303
- for (const f of ["package.json", "package-lock.json", "npm-shrinkwrap.json"]) {
304
- const file = join(path, f); let text = "", j = null;
305
- try { text = readFileSync(file, "utf8"); j = JSON.parse(text); } catch { continue; }
306
- if (!j || typeof j !== "object" || (f !== "package.json" && !("version" in j))) continue;
307
- j.version = to; if (f !== "package.json" && j.packages && j.packages[""]) j.packages[""].version = to;
308
- writeFileSync(file, JSON.stringify(j, null, (text.match(/^[ \t]+(?=")/m) || [" "])[0]) + (text.endsWith("\n") ? "\n" : ""));
309
- saved.push([file, text]);
310
- }
311
- return () => { for (const [file, text] of saved) try { writeFileSync(file, text); } catch {} };
312
- }
313
- // Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
314
- // is offered one (bumpOffer). The bump is put back if the ship fails.
315
- function shipWithBump(path, texts, opts) {
316
- const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
317
- if (!offer) return shipChanges(path, texts, opts);
318
- const to = offer[opts.bump], undo = setVersion(path, to);
319
- const r = shipChanges(path, texts, { ...opts, bumped: to });
320
- if (r.error) { undo(); return r; }
321
- return { ...r, bumped: to };
322
- }
323
- // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
324
- // uncommitted changes no ticked task covers (untasked: approve them as-is).
325
- // running: an agent is still editing there, so its changes may be half done.
326
- function pendingReview() {
327
- const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
328
- const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
329
- const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
330
- const am = autoMergeRepos();
331
- const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) } : { branch: "", files: [], stat: "" }) }; });
332
- for (const repo of sent.sort()) {
333
- const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
334
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
335
- }
336
- return out;
337
- }
338
- // Approving while the agent is still editing would commit its half-done work.
339
- const stillWorking = (repo, path) => path && runningHandoff(path) ? { error: `The agent is still working in ${repo}. Approve once it finishes.`, running: true } : null;
340
- const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
341
- // Whether everything committed on this branch is on the default branch already,
342
- // because an earlier PR from it merged. Squash-merged, main has the same changes
343
- // under a different history, so a new PR from the branch carries them twice and
344
- // can't merge (how #80 got stuck behind #79). Seen two ways: the branch's files
345
- // are main's though main doesn't hold its commits, or GitHub says a PR from it
346
- // merged and nothing was committed on it since. Gives the ref to start the next
347
- // branch from, or "".
348
- function mergedAlready(path, branch, base) {
349
- const origin = git(path, ["remote", "get-url", "origin"]);
350
- if (origin.ok) git(path, ["fetch", "-q", "origin", base], 30000); // best effort: main as GitHub has it
351
- const ref = git(path, ["rev-parse", "-q", "--verify", "refs/remotes/origin/" + base]).ok ? "origin/" + base : base;
352
- if (!git(path, ["rev-parse", "-q", "--verify", ref]).ok) return "";
353
- if (!git(path, ["merge-base", "--is-ancestor", "HEAD", ref]).ok && git(path, ["diff", "--quiet", "HEAD", ref, ...NOT_SYMBIOT]).ok) return ref;
354
- if (!origin.ok || !/github/i.test(origin.out) || !hasCmd("gh")) return "";
355
- const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "merged", "--limit", "1", "--json", "headRefOid"], { cwd: path, encoding: "utf8", timeout: 30000 });
356
- let head = ""; try { head = String(JSON.parse(r.stdout)[0].headRefOid || ""); } catch {}
357
- const since = head && git(path, ["rev-list", "--count", head + "..HEAD"]);
358
- return since && since.ok && since.out === "0" ? ref : "";
359
- }
360
- // Sync approved work: off the default branch onto symbiot/<task>, commit the
361
- // working tree (minus .symbiot/), push, and open a PR with gh. On a branch whose
362
- // earlier PR already merged, it starts a fresh symbiot/ branch from the default
363
- // one instead (mergedAlready). Each step that can't happen (no remote, push
364
- // rejected, no gh) stops there and says so — the commit is never lost.
365
- // opts.push=false stops after the commit, opts.pr=false after the push.
366
- function shipChanges(path, texts, opts = {}) {
367
- const ch = workingChanges(path);
368
- if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
369
- if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
370
- const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
371
- const newBranch = () => {
372
- const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
373
- let b = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok; i++) b = `${stem}-${i}`;
374
- return b;
375
- };
376
- if (branch === base) {
377
- branch = newBranch();
378
- const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
379
- } else {
380
- const from = mergedAlready(path, branch, base);
381
- if (from) {
382
- // The changes come along; if main changed a file they touch, git refuses and they stay put.
383
- const next = newBranch(), sw = git(path, ["switch", "-c", next, "--no-track", from]);
384
- fresh = sw.ok ? `${branch}'s earlier PR already merged, so this starts a fresh branch from ${from}.`
385
- : `${branch}'s earlier PR already merged, but these changes couldn't move to a fresh branch from ${from} (${sw.err.split("\n")[0]}), so they're on ${branch}, and its PR may not merge.`;
386
- if (sw.ok) branch = next;
387
- }
388
- }
389
- // Stage everything, then drop .symbiot. A `. :(exclude).symbiot` pathspec
390
- // warns+exits-1 once .symbiot is gitignored ("paths are ignored, use -f"),
391
- // which falsely aborted the ship. `add -A` skips gitignored paths silently;
392
- // the reset also covers repos where .symbiot isn't ignored.
393
- const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
394
- git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
395
- const subject = !texts.length ? "symbiot: changes approved without a task" : texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
396
- const body = (texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.") + (opts.bumped ? `\n\nBumps the version to ${opts.bumped}. ` + (publishesOnMerge(path) ? `It publishes to npm when this merges.` : `After this merges, tag v${opts.bumped} on ${base} to release it.`) : "");
397
- const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
398
- const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject, ...(fresh ? { note: fresh } : {}) };
399
- const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
400
- if (opts.push === false) return out;
401
- if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
402
- const ps = git(path, ["push", "-u", "origin", branch], 120000);
403
- if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
404
- out.pushed = true;
405
- if (opts.pr === false) return out;
406
- if (!hasCmd("gh")) return noted("Pushed. Install the GitHub CLI (gh) to open the PR automatically.");
407
- const gh = (args) => spawnSync("gh", args, { cwd: path, encoding: "utf8", timeout: 60000 });
408
- const pr = gh(["pr", "create", "--head", branch, "--base", base, "--title", subject, "--body", body + "\n\nApproved in Symbiot."]);
409
- const url = (String(pr.stdout || "").match(/https?:\/\/\S+/) || [])[0] || String(gh(["pr", "view", branch, "--json", "url", "-q", ".url"]).stdout || "").trim();
410
- if (!url) return noted("Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop());
411
- out.pr = url;
412
- // Opt-in auto-merge: queue GitHub's native auto-merge, which lands the PR only
413
- // once its required checks (CI) pass — never immediately on its own if the repo
414
- // has branch protection. Needs 'Allow auto-merge' on the repo; if off, we say so.
415
- if (opts.autoMerge) {
416
- const am = gh(["pr", "merge", branch, "--auto", "--squash", "--delete-branch"]);
417
- if (am.status === 0) out.autoMerge = "queued";
418
- else { out.autoMerge = "unavailable"; out.autoMergeErr = String(am.stderr || "").trim().split("\n").filter(Boolean).pop() || "gh pr merge failed"; }
419
- }
420
- return out;
421
- }
422
- // Per-repo opt-in to auto-merge Approve PRs once CI passes (default off).
423
- function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
424
- function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
425
- function approveRepo(repo, opts = {}) {
426
- const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
427
- if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
428
- const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
429
- const busy = stillWorking(repo, path); if (busy) return busy;
430
- const r = shipWithBump(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
431
- if (r.error) return r;
432
- const now = Date.now();
433
- for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
434
- saveTasks(t);
435
- return { ...r, approved: items.length };
436
- }
437
- // "Approve changes without a task": ship the uncommitted changes even though no
438
- // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
439
- // awaiting review in the repo, this is just Approve.
440
- function approveChanges(repo, opts = {}) {
441
- if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
442
- const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
443
- const busy = stillWorking(repo, path); if (busy) return busy;
444
- if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
445
- const r = shipWithBump(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
446
- return r.error ? r : { ...r, approved: 0 };
447
- }
448
- // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
449
- function sendBack(id) {
450
- const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
451
- it.review = false; delete it.reviewAt; saveTasks(t);
452
- const path = it.repo && repoPathMap()[it.repo];
453
- if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { const want = it.text.trim().toLowerCase(); writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase() === want ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
454
- return it;
455
- }
456
- // Deterministic task classification (no model) so tasks batch by kind instead
457
- // of arriving as a flat, mixed pile. Order matters (most specific first).
458
- const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
459
- function taskType(text) {
460
- const s = String(text || "").toLowerCase();
461
- if (/\b(test|tests|spec|coverage|smoke|fixture|ci\b)/.test(s)) return "Tests & CI";
462
- if (/\b(secur|auth|token|secret|vulnerab|permission|sanitiz|escap|injection)/.test(s)) return "Security";
463
- if (/\b(readme|docs?|document|changelog|comment|guide)/.test(s)) return "Docs";
464
- if (/\b(perf|performance|slow|optimi|latency|throughput|speed up|memory leak)/.test(s)) return "Performance";
465
- if (/\b(refactor|simplify|split|extract|clean ?up|consolidat|dedupe|modular|rename)/.test(s)) return "Refactor";
466
- if (/\b(bug|fix|broken|error|crash|regress|hang|race|leak)/.test(s)) return "Fixes";
467
- if (/\b(ui|ux|design|layout|style|button|screen|view\b|page\b|responsive)/.test(s)) return "UI/UX";
468
- return "Features & other";
469
- }
470
- // Push tasks into the repos as an agent-readable brief (.symbiot/TASKS.md):
471
- // a checklist plus the deterministic context an agent needs to get oriented.
472
- function buildTasksMd(name, ctx, list) {
473
- const L = [];
474
- L.push(`# Symbiot tasks — ${name}`);
475
- L.push(`_generated ${new Date().toISOString().slice(0, 10)}${ctx.branch ? ` · branch ${ctx.branch}` : ""} · by symbiot ${VERSION}_`, "");
476
- L.push("## Context for an AI agent", `Tasks for the \`${name}\` repo. Get oriented from the signals below before actioning them.`);
477
- if (ctx.stack) L.push(`- **Stack:** ${ctx.stack}`);
478
- if (ctx.commits && ctx.commits.length) { L.push("- **Recent commits:**"); for (const c of ctx.commits) L.push(` - ${c}`); }
479
- if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
480
- if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
481
- L.push("", "## Tasks");
482
- const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
483
- const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
484
- for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
485
- L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
486
- L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
487
- `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
488
- "- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`.",
489
- "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
490
- "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
491
- "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
492
- L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
493
- return L.join("\n") + "\n";
494
- }
495
- function pushTasks(filter) {
496
- let tasks = loadTasks().filter((t) => !t.done && !t.archived && !t.review);
497
- if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
498
- if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
499
- if (!tasks.length) return { empty: true, written: [], unresolved: [] };
500
- const byName = repoPathMap(); // from the already-scanned map when there is one
501
- const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
502
- const written = [], unresolved = [];
503
- for (const name of Object.keys(groups)) {
504
- const list = groups[name], path = name && byName[name];
505
- if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
506
- try {
507
- // Cheap, per-repo signals only — no full drift scan (that can be very slow
508
- // on big repos and would block the request).
509
- const st = repoState(path);
510
- const commits = sh(`git -C ${JSON.stringify(path)} log --format='%s' -10 2>/dev/null`).split("\n").filter(Boolean);
511
- const open = openWork([{ path, name }]).slice(0, 12);
512
- const det = detectRepo({ path, name, recency: 0 });
513
- const stack = [...det.langs.slice(0, 4), ...det.tools].join(", ");
514
- const risk = [];
515
- if (st.stale) risk.push("stale checkout — working tree is an old snapshot, not new work");
516
- if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
517
- if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
518
- // held: an agent is still running there, so it lands when that one exits
519
- const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
520
- written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
521
- } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
522
- }
523
- return { empty: false, written, unresolved, handoff: handoffCmd() };
524
- }
525
74
  function cmdPush() {
526
75
  const r = pushTasks();
527
76
  if (r.empty) { console.log(c.y("No open tasks to push.") + c.d(" Add some in `symbiot app` — a repo review's ideas, or the Tasks tab.")); return; }
@@ -539,29 +88,6 @@ function cmdPush() {
539
88
  console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
540
89
  }
541
90
  }
542
- function envKey(provider) {
543
- for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
544
- return null;
545
- }
546
- // Returns { provider, key?, baseUrl?, model, source } or null if nothing set up.
547
- // Order: saved choice → legacy saved key → env keys → an `ant` profile.
548
- function resolveProvider() {
549
- const cfg = loadConfig();
550
- const m = process.env.SYMBIOT_MODEL;
551
- if (cfg.provider && PROVIDERS[cfg.provider]) {
552
- const p = cfg.provider, pc = cfg[p] || {};
553
- if (p === "ollama") return { provider: p, baseUrl: pc.baseUrl || "http://localhost:11434", model: m || pc.model || PROVIDERS.ollama.model, source: "saved login" };
554
- const key = pc.apiKey || envKey(p);
555
- if (key || (p === "anthropic" && (process.env.ANTHROPIC_AUTH_TOKEN || antProfileExists())))
556
- return { provider: p, key, model: m || pc.model || PROVIDERS[p].model, source: pc.apiKey ? "saved login" : "environment" };
557
- }
558
- if (cfg.apiKey) return { provider: "anthropic", key: cfg.apiKey, model: m || PROVIDERS.anthropic.model, source: "saved login (~/.config/symbiot)" };
559
- if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return { provider: "anthropic", key: process.env.ANTHROPIC_API_KEY, model: m || PROVIDERS.anthropic.model, source: "ANTHROPIC_* (environment)" };
560
- if (process.env.OPENAI_API_KEY) return { provider: "openai", key: process.env.OPENAI_API_KEY, model: m || PROVIDERS.openai.model, source: "OPENAI_API_KEY (environment)" };
561
- if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return { provider: "gemini", key: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY, model: m || PROVIDERS.gemini.model, source: "GEMINI/GOOGLE_API_KEY (environment)" };
562
- if (antProfileExists()) return { provider: "anthropic", model: m || PROVIDERS.anthropic.model, source: "Anthropic CLI profile (ant auth login)" };
563
- return null;
564
- }
565
91
 
566
92
  // ---- prompt (with masked secret input) ------------------------------------
567
93
  function ask(question, { secret = false } = {}) {
@@ -582,350 +108,17 @@ function ask(question, { secret = false } = {}) {
582
108
  });
583
109
  }
584
110
 
585
- // ---- scan deadline + progress ---------------------------------------------
586
- // One slow disk or giant tree must never hang a scan (the 0.7.1 map hang): each
587
- // scan gets an overall deadline (SYMBIOT_SCAN_TIMEOUT seconds), after which it
588
- // stops and returns what it has, marked partial. Progress is a live stderr line
589
- // in the CLI, and served at /api/scan for the app, which polls it while the Map
590
- // loads. Nested scans (findAllRepos inside buildMap) share the outer deadline.
591
- const SCAN_TIMEOUT_MS = (Number(process.env.SYMBIOT_SCAN_TIMEOUT) || 60) * 1000;
592
- const FIND_TIMEOUT_MS = 20000; // one `find` walk; partial results are kept past it
593
- const SCAN = { active: false, phase: "", done: 0, total: 0, item: "", startedAt: 0, deadline: 0, partial: false };
594
- function scanBegin() {
595
- if (SCAN.active) return false;
596
- Object.assign(SCAN, { active: true, phase: "", done: 0, total: 0, item: "", startedAt: Date.now(), deadline: Date.now() + SCAN_TIMEOUT_MS, partial: false });
597
- return true;
598
- }
599
- function scanPhase(phase, total) { Object.assign(SCAN, { phase, done: 0, total: total || 0, item: "" }); scanDraw(); }
600
- function scanTick(item) { SCAN.done++; SCAN.item = item || ""; scanDraw(); }
601
- function scanExpired() { if (SCAN.active && Date.now() > SCAN.deadline) SCAN.partial = true; return SCAN.partial; }
602
- function scanEnd(owner) {
603
- if (!owner) return;
604
- SCAN.active = false;
605
- if (SERVING) return;
606
- if (!PLAIN && process.stderr.isTTY) process.stderr.write("\r\x1b[K");
607
- if (SCAN.partial) process.stderr.write(c.y(`⚠ scan stopped after ${SCAN_TIMEOUT_MS / 1000}s — results are partial (raise SYMBIOT_SCAN_TIMEOUT, or narrow the scan folders)`) + "\n");
608
- }
609
- // Written synchronously (the scan blocks the event loop, so a timer-driven
610
- // spinner would freeze): one overwritten stderr line, TTY only.
611
- function scanDraw() {
612
- if (SERVING || PLAIN || !process.stderr.isTTY) return;
613
- const n = SCAN.total ? ` ${SCAN.done}/${SCAN.total}` : SCAN.done ? ` ${SCAN.done}` : "";
614
- process.stderr.write(`\r\x1b[K${c.g("⠿")} ${c.d(`${SCAN.phase}${n}${SCAN.item ? " · " + SCAN.item : ""}`.slice(0, (process.stderr.columns || 80) - 3))}`);
615
- }
616
- // Run `find` directly (no shell, no pipe): a timeout then kills find itself
617
- // rather than a shell whose piped children keep running, and whatever it found
618
- // before the timeout is kept instead of discarded.
619
- function findPaths(args, limit) {
620
- const timeout = SCAN.active ? Math.max(1000, Math.min(FIND_TIMEOUT_MS, SCAN.deadline - Date.now())) : FIND_TIMEOUT_MS;
621
- const r = spawnSync("find", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout, killSignal: "SIGKILL" });
622
- if (r.error && r.error.code !== "ETIMEDOUT") return [];
623
- if (r.error && SCAN.active) SCAN.partial = true;
624
- return String(r.stdout || "").split("\n").filter(Boolean).slice(0, limit);
625
- }
626
- const PRUNE = ["node_modules", ".cache", ".local", ".npm", "venv", ".venv", ".gradle", "Pods"];
627
- const anyName = (names) => ["(", ...names.flatMap((n, i) => (i ? ["-o", "-name", n] : ["-name", n])), ")"];
628
- // find .git dirs quickly by PRUNING heavy trees (node_modules etc.) instead of
629
- // crawling into them — this is the big speedup for the map scan.
630
- function findGitDirs(base, limit) {
631
- return findPaths([base, "-maxdepth", "7", ...anyName([...PRUNE, ".git-crypt"]), "-prune", "-o", "-name", ".git", "-print"], limit);
632
- }
633
- function me() {
634
- return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
635
- }
636
-
637
- // ---- accurate repo signals (the field-report fixes) -----------------------
638
- // All author identities in this repo that plausibly belong to the current user,
639
- // with a fallback when the filter would keep almost nothing of an active repo.
640
- function authorship(repoPath) {
641
- const gName = sh("git config --global user.name 2>/dev/null").trim();
642
- const gEmail = sh("git config --global user.email 2>/dev/null").trim();
643
- const rEmail = sh(`git -C ${JSON.stringify(repoPath)} config user.email 2>/dev/null`).trim();
644
- const rName = sh(`git -C ${JSON.stringify(repoPath)} config user.name 2>/dev/null`).trim();
645
- const total = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD 2>/dev/null`).trim()) || 0;
646
- const rows = sh(`git -C ${JSON.stringify(repoPath)} log --format='%ae|%an' 2>/dev/null | sort | uniq -c | sort -rn | head -60`)
647
- .split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
648
- const m = l.match(/^(\d+)\s+(.*)$/); if (!m) return null;
649
- const parts = m[2].split("|"); return { count: Number(m[1]), email: parts[0], name: parts.slice(1).join("|") };
650
- }).filter(Boolean);
651
- const myEmails = new Set([rEmail, gEmail].filter(Boolean));
652
- const myNames = new Set([gName, rName].filter(Boolean).map((n) => n.toLowerCase()));
653
- const nameKeys = new Set([...myNames].map((n) => n.replace(/\s+/g, "")));
654
- const mine = new Set();
655
- for (const r of rows) {
656
- const nore = r.email.match(/^\d+\+(.+)@users\.noreply\.github\.com$/i);
657
- const isMine = (r.email && myEmails.has(r.email))
658
- || (r.name && myNames.has(r.name.toLowerCase()))
659
- || (nore && nameKeys.has(nore[1].toLowerCase().replace(/\s+/g, "")));
660
- if (isMine) mine.add(r.email);
661
- }
662
- if (rEmail) mine.add(rEmail);
663
- let mineCount = rows.filter((r) => mine.has(r.email)).reduce((s, r) => s + r.count, 0);
664
- const filterDropped = total >= 20 && mineCount / Math.max(total, 1) < 0.10;
665
- const emails = filterDropped ? rows.map((r) => r.email) : [...mine];
666
- return { emails, mineCount, total, filterDropped, names: [...new Set(rows.filter((r) => mine.has(r.email)).map((r) => r.email))] };
667
- }
668
- function authorArgs(emails) { return (emails || []).filter(Boolean).map((e) => `--author=${JSON.stringify(e)}`).join(" "); }
669
-
670
- function readmeInfo(repoPath) {
671
- for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
672
- try {
673
- const p = join(repoPath, f);
674
- if (existsSync(p)) {
675
- const hash = sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%H -- ${JSON.stringify(f)} 2>/dev/null`).trim();
676
- return {
677
- file: f, excerpt: readFileSync(p, "utf8").slice(0, 2500),
678
- lastDate: sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%cd --date=short -- ${JSON.stringify(f)} 2>/dev/null`).trim(),
679
- commitsAgo: hash ? (Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count ${hash}..HEAD 2>/dev/null`).trim()) || 0) : 0,
680
- };
681
- }
682
- } catch {}
683
- }
684
- return { file: null, excerpt: "", lastDate: "", commitsAgo: 0 };
685
- }
686
- function repoShape(repoPath) {
687
- const files = sh(`git -C ${JSON.stringify(repoPath)} ls-files 2>/dev/null | head -4000`).split("\n").filter(Boolean);
688
- const top = {}; const docs = []; const manifests = [];
689
- for (const f of files) {
690
- const seg = f.includes("/") ? f.split("/")[0] : "(root)";
691
- top[seg] = (top[seg] || 0) + 1;
692
- const base = f.split("/").pop();
693
- if (MANIFEST_TOOL[base]) manifests.push(f);
694
- if (/^(readme|contributing|claude|agents|changelog|architecture|design)/i.test(base) || /(^|\/)(docs|knowledge)\//i.test(f)) docs.push(f);
695
- }
696
- return {
697
- total: files.length,
698
- dirs: Object.entries(top).sort((a, b) => b[1] - a[1]).slice(0, 14).map(([d, n]) => `${d}/ (${n})`),
699
- manifests: manifests.slice(0, 12), docs: [...new Set(docs)].slice(0, 16),
700
- };
701
- }
702
- // House rules the model must respect (e.g. "we commit .env on purpose").
703
- function houseRules(repoPath) {
704
- const out = [];
705
- for (const [label, f] of [["Project rules", "CLAUDE.md"], ["Agent rules", "AGENTS.md"], ["Contributing", "CONTRIBUTING.md"]]) {
706
- try { const p = join(repoPath, f); if (existsSync(p)) out.push(`## ${label} (${f})\n` + readFileSync(p, "utf8").slice(0, 1500)); } catch {}
707
- }
708
- try { const g = join(homedir(), ".config", "symbiot", "rules.md"); if (existsSync(g)) out.push("## Your global rules\n" + readFileSync(g, "utf8").slice(0, 1500)); } catch {}
709
- return out.join("\n\n");
710
- }
711
- // "Show what was read" footer for every report.
712
- function reportFooter(repoPath, auth, state, rd) {
713
- const b = [`symbiot ${VERSION}`, `path ${repoPath}`];
714
- if (state.branch) b.push(`branch ${state.branch}`);
715
- b.push(`${auth.mineCount} of ${auth.total} commits matched you${auth.filterDropped ? " — filter dropped, counting everyone" : ""}`);
716
- if (rd && rd.lastDate) b.push(`README changed ${rd.lastDate}${rd.commitsAgo ? ` (${rd.commitsAgo} commits ago)` : ""}`);
717
- if (state.stale) b.push(`⚠ STALE checkout: working tree ≈ ${state.staleBy ? "HEAD~" + state.staleBy : "an older commit"} — not new work`);
718
- else if (state.dirty) b.push(`${state.dirty} uncommitted (${state.mod} mod / ${state.del} del / ${state.add} new)`);
719
- if (state.behind) b.push(`${state.behind} behind upstream`);
720
- return b.join(" · ");
721
- }
722
- function expandRoot(p) { p = String(p || "").trim(); return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; }
723
- // The default place to look: your home folder. The Android app's HOME is private
724
- // to it, so it sets SYMBIOT_SCAN_HOME to the phone's shared storage instead.
725
- function scanHome() { return process.env.SYMBIOT_SCAN_HOME || homedir(); }
726
- // Without "All files access", the Android app sees shared storage as empty (it
727
- // always has Download, DCIM...), which would read as "no repos". The map says so
728
- // instead, with a button that asks for access.
729
- function storageBlocked() {
730
- if (process.env.SYMBIOT_ANDROID_APP !== "1") return false;
731
- try { return readdirSync(scanHome()).length === 0; } catch { return true; }
732
- }
733
- // Termux: a Linux run with proot-distro (`proot-distro login debian`) keeps its
734
- // home folders in $PREFIX/var/lib/proot-distro/installed-rootfs/<distro>, outside
735
- // Termux's home, and that's where projects worked on in it live. Symbiot in
736
- // Termux reads them there: /root and /home/<user> of each installed distro.
737
- const subdirs = (p) => { try { return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(p, d.name)); } catch { return []; } };
738
- // The folder each installed distro sees as "/".
739
- function prootDistros() {
740
- if (!process.env.PREFIX) return [];
741
- return subdirs(join(process.env.PREFIX, "var", "lib", "proot-distro", "installed-rootfs"));
742
- }
743
- // Each distro's home folders, with the distro they're in.
744
- function prootHomeDirs() {
745
- const out = [];
746
- for (const rootfs of prootDistros()) {
747
- if (existsSync(join(rootfs, "root"))) out.push({ rootfs, home: join(rootfs, "root") });
748
- for (const home of subdirs(join(rootfs, "home"))) out.push({ rootfs, home });
749
- }
750
- return out;
751
- }
752
- function prootHomes() { return prootHomeDirs().map((h) => h.home); }
753
- // Where to look when you haven't picked folders: your home folder (and in Termux,
754
- // the homes of its proot-distro Linuxes).
755
- function scanDefaults() { return [scanHome(), ...prootHomes()]; }
756
- // Where to look for repos: configured folders, or --dir, else the defaults.
757
- function scanRoots() {
758
- if (flag("dir", null)) return [BASE];
759
- const r = loadConfig().scanRoots;
760
- const roots = (Array.isArray(r) ? r : []).map(expandRoot).filter((x) => { try { return existsSync(x); } catch { return false; } });
761
- return roots.length ? roots : scanDefaults();
762
- }
763
- function addScanRoot(p) {
764
- p = expandRoot(p); if (!p) return { error: "empty" };
765
- try { if (!existsSync(p)) return { error: "folder not found: " + p }; } catch { return { error: "can't read: " + p }; }
766
- const cfg = loadConfig();
767
- let list = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length ? cfg.scanRoots : scanDefaults(); // keep home when adding the first extra folder
768
- if (!list.includes(p)) list.push(p);
769
- cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots };
770
- }
771
- function removeScanRoot(p) {
772
- const cfg = loadConfig(); cfg.scanRoots = (Array.isArray(cfg.scanRoots) ? cfg.scanRoots : []).filter((x) => x !== p);
773
- if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots || [] };
774
- }
775
- function findRepos(base, sinceDays) {
776
- const repos = []; const seen = new Set();
777
- for (const root of scanRoots()) for (const g of findGitDirs(root, 200)) {
778
- const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
779
- const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
780
- const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
781
- if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
782
- }
783
- return repos.sort((a, b) => b.recency - a.recency).slice(0, MAX_REPOS);
784
- }
785
- function commits(repos, sinceExpr, mineOnly = true) {
786
- const all = [];
787
- const seen = new Set();
788
- for (const r of repos) {
789
- // Match ALL of your identities in this repo (per-repo email, global email,
790
- // GitHub noreply login, matching name), with a fallback when almost nothing
791
- // matches an active repo. No filter with --all.
792
- const authorArg = mineOnly ? authorArgs(authorship(r.path).emails) : "";
793
- const raw = sh(
794
- `git -C ${JSON.stringify(r.path)} log --since=${JSON.stringify(sinceExpr)} ${authorArg} ` +
795
- `--no-merges --date=short --name-only --pretty=format:'@@@%H|%ad|%s' -n 60`,
796
- );
797
- let cur = null;
798
- const flush = () => {
799
- if (cur && !seen.has(cur.h)) { seen.add(cur.h); all.push({ ...cur, repo: r.name }); }
800
- };
801
- for (const line of raw.split("\n")) {
802
- if (line.startsWith("@@@")) {
803
- flush();
804
- const [h, date, subject] = line.slice(3).split("|");
805
- cur = { h, date, subject: subject || "", files: [] };
806
- } else if (line.trim() && cur) cur.files.push(line.trim());
807
- }
808
- flush();
809
- }
810
- return all.slice(0, MAX_COMMITS);
811
- }
812
- function openWork(repos) {
813
- const items = [];
814
- for (const r of repos) {
815
- const todos = sh(
816
- `git -C ${JSON.stringify(r.path)} grep -nEI '(TODO|FIXME|HACK)[:( ]' -- ` +
817
- `'*.ts' '*.tsx' '*.js' '*.py' '*.go' '*.rs' '*.java' '*.sql' '*.vue' 2>/dev/null | head -8`,
818
- );
819
- for (const l of todos.split("\n").filter(Boolean)) {
820
- const m = l.match(/^([^:]+):(\d+):(.*)$/);
821
- if (m) items.push(`${r.name}: ${m[3].replace(/^[\s/*#-]+/, "").trim().slice(0, 120)} (${m[1]}:${m[2]})`);
822
- }
823
- const st = repoState(r.path);
824
- if (st.stale) {
825
- items.push(`${r.name}: STALE checkout — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, NOT new work; do not commit (would revert history)`);
826
- } else if (st.dirty) {
827
- items.push(`${r.name}: ${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new) on ${st.branch || "?"}`);
828
- }
829
- }
830
- return items;
831
- }
832
-
833
- // ---- model calls (one per provider, same in/out) --------------------------
834
- async function callAnthropic(r, system, prompt) {
835
- const client = new Anthropic(r.key ? { apiKey: r.key } : {});
836
- const base = { model: r.model, max_tokens: MAX_TOKENS, system, messages: [{ role: "user", content: prompt }] };
837
- let res;
838
- try { res = await client.messages.create({ ...base, output_config: { effort: "low" } }); }
839
- catch (e) {
840
- // effort/output_config isn't accepted on every model (e.g. Haiku) — retry plain
841
- if (/effort|output_config|thinking|budget|400/i.test(e?.message || "")) res = await client.messages.create(base);
842
- else throw e;
843
- }
844
- if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
845
- return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
846
- }
847
- async function callOpenAI(r, system, prompt) {
848
- const messages = [{ role: "system", content: system }, { role: "user", content: prompt }];
849
- // Newer models want max_completion_tokens instead of max_tokens; try both.
850
- for (const tokKey of ["max_tokens", "max_completion_tokens"]) {
851
- const res = await fetch("https://api.openai.com/v1/chat/completions", {
852
- method: "POST",
853
- headers: { Authorization: `Bearer ${r.key}`, "Content-Type": "application/json" },
854
- body: JSON.stringify({ model: r.model, [tokKey]: MAX_TOKENS, messages }),
855
- });
856
- const text = await res.text();
857
- if (res.ok) return (JSON.parse(text).choices?.[0]?.message?.content || "").trim();
858
- if (res.status === 400 && tokKey === "max_tokens" && /max_tokens|max_completion_tokens/i.test(text)) continue;
859
- throw new Error(`OpenAI ${res.status}: ${text.slice(0, 200)}`);
860
- }
861
- return "";
862
- }
863
- async function callGemini(r, system, prompt) {
864
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(r.model)}:generateContent?key=${encodeURIComponent(r.key)}`;
865
- const res = await fetch(url, {
866
- method: "POST", headers: { "Content-Type": "application/json" },
867
- body: JSON.stringify({
868
- systemInstruction: { parts: [{ text: system }] },
869
- contents: [{ role: "user", parts: [{ text: prompt }] }],
870
- generationConfig: { maxOutputTokens: MAX_TOKENS },
871
- }),
872
- });
873
- const text = await res.text();
874
- if (!res.ok) throw new Error(`Gemini ${res.status}: ${text.slice(0, 200)}`);
875
- const parts = JSON.parse(text).candidates?.[0]?.content?.parts || [];
876
- return parts.map((p) => p.text || "").join("").trim();
877
- }
878
- async function callOllama(r, system, prompt) {
879
- const res = await fetch(`${r.baseUrl}/api/chat`, {
880
- method: "POST", headers: { "Content-Type": "application/json" },
881
- body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
882
- });
883
- const text = await res.text();
884
- if (!res.ok) {
885
- if (/not found|no such model|try pulling/i.test(text)) throw new Error(`the model "${r.model}" isn't downloaded yet — run symbiot setup-local --model ${r.model} (or click "Set up a free local model" in Settings)`);
886
- throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
887
- }
888
- return (JSON.parse(text).message?.content || "").trim();
889
- }
890
-
891
- async function write(system, prompt) {
892
- const r = resolveProvider();
893
- if (!r) { console.log(AUTH_HELP); return null; }
894
- const stop = spinner("thinking…");
895
- try {
896
- if (r.provider === "anthropic") return await callAnthropic(r, system, prompt);
897
- if (r.provider === "openai") return await callOpenAI(r, system, prompt);
898
- if (r.provider === "gemini") return await callGemini(r, system, prompt);
899
- if (r.provider === "ollama") return await callOllama(r, system, prompt);
900
- return null;
901
- } catch (err) {
902
- if (/\b401\b|\b403\b|invalid|authentication|api key|unauthor/i.test(err?.message || "")) {
903
- console.log(c.y(`Your ${PROVIDERS[r.provider].label} credentials were rejected. `) + "Reconnect with: " + c.b("symbiot login --force"));
904
- return null;
905
- }
906
- return `Couldn't reach the model: ${err?.message || err}`;
907
- } finally { stop(); }
908
- }
909
-
910
- async function validate(provider, { key, baseUrl } = {}) {
911
- try {
912
- if (provider === "anthropic") { await new Anthropic({ apiKey: key }).models.list(); return true; }
913
- if (provider === "openai") return (await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${key}` } })).ok;
914
- if (provider === "gemini") return (await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`)).ok;
915
- if (provider === "ollama") return (await fetch(`${baseUrl}/api/tags`)).ok;
916
- } catch { return false; }
917
- return false;
918
- }
919
-
920
111
  const AUTH_HELP =
921
112
  c.y("Symbiot needs an AI to write your updates. Connect one:\n") +
922
113
  " " + c.b("symbiot login") + c.d(" pick Claude, OpenAI, Gemini, or a local model (Ollama)") + "\n" +
923
114
  c.d(" Or set a key in your environment: ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.");
115
+ // What writing with the AI (ai.mjs) shows in the terminal.
116
+ Object.assign(AI_UI, {
117
+ spinner,
118
+ notConnected: () => console.log(AUTH_HELP),
119
+ rejected: (label) => console.log(c.y(`Your ${label} credentials were rejected. `) + "Reconnect with: " + c.b("symbiot login --force")),
120
+ });
924
121
 
925
- // ---- render ---------------------------------------------------------------
926
- function renderCommits(list) {
927
- return list.map((x) => `- [${x.repo}] ${x.subject}${x.files.length ? ` (${x.files.length} files)` : ""}`).join("\n");
928
- }
929
122
  function header(sub) {
930
123
  if (PLAIN) { console.log(`Symbiot\n${sub}\n`); return; }
931
124
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} ${c.d("· " + sub)}\n`);
@@ -933,56 +126,8 @@ function header(sub) {
933
126
 
934
127
  // ---- commands -------------------------------------------------------------
935
128
  const SINCE_WEEK = Number(flag("since", "7"));
936
- const BASE = flag("dir", scanHome());
129
+ const BASE = scanBase();
937
130
 
938
- // ---- hardware -> model recommendations ------------------------------------
939
- function detectGpu() {
940
- const nv = sh("nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null").trim();
941
- if (nv) { const p = nv.split("\n")[0].split(","); return { name: (p[0] || "NVIDIA GPU").trim(), vramGB: Math.round(Number(p[1]) / 1024) || null, kind: "nvidia" }; }
942
- if (process.platform === "linux") { const vga = sh("lspci 2>/dev/null | grep -iE 'vga|3d controller|display' | head -1").replace(/^.*?: /, "").trim(); if (vga) return { name: vga, vramGB: null, kind: "other" }; }
943
- if (process.platform === "darwin") { const chip = sh("sysctl -n machdep.cpu.brand_string 2>/dev/null").trim(); if (/apple/i.test(chip)) return { name: chip + " (unified memory)", vramGB: null, kind: "apple" }; }
944
- return null;
945
- }
946
- function detectHardware() {
947
- const cpus = oscpus() || [];
948
- return { platform: process.platform, arch: process.arch, ramGB: Math.round(totalmem() / 1073741824), cpuCount: cpus.length, cpuModel: ((cpus[0] && cpus[0].model) || "CPU").trim(), gpu: detectGpu() };
949
- }
950
- function recommendModels(hw) {
951
- const ram = hw.ramGB || 8;
952
- const local = [
953
- { tier: "min", model: "llama3.2:3b", needGB: 6, note: "fast & light — fine for summaries" },
954
- { tier: "med", model: "llama3.1:8b", needGB: 10, note: "solid all-rounder" },
955
- { tier: "max", model: "qwen2.5:14b", needGB: 18, note: "stronger reasoning" },
956
- ];
957
- if (ram >= 40) local.push({ tier: "max+", model: "llama3.1:70b", needGB: 48, note: "top local quality — big machine/GPU" });
958
- local.forEach((m) => { m.fits = ram >= m.needGB; });
959
- const paid = [
960
- { provider: "anthropic", tier: "cheap", model: "claude-haiku-4-5", note: "cheapest Claude" },
961
- { provider: "anthropic", tier: "top", model: "claude-opus-5-5", note: "best Claude (default)" },
962
- { provider: "openai", tier: "cheap", model: "gpt-4o-mini", note: "cheap OpenAI" },
963
- { provider: "openai", tier: "top", model: "gpt-4o", note: "stronger OpenAI" },
964
- { provider: "gemini", tier: "cheap", model: "gemini-1.5-flash", note: "cheap Google" },
965
- { provider: "gemini", tier: "top", model: "gemini-1.5-pro", note: "stronger Google" },
966
- ];
967
- const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
968
- return { local, paid, best };
969
- }
970
- // ---- local model one-command setup (Ollama), OS-aware ---------------------
971
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
972
- function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
973
- function ollamaInstall() {
974
- if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
975
- if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
976
- return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
977
- }
978
- async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
979
- async function ensureOllama() {
980
- if (await ollamaUp()) return true;
981
- try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
982
- for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
983
- return false;
984
- }
985
- function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
986
131
  async function cmdSetupLocal() {
987
132
  const model = flag("model", null) || recommendModels(detectHardware()).best;
988
133
  if (!hasOllama()) {
@@ -1016,20 +161,6 @@ function cmdModels() {
1016
161
  console.log("");
1017
162
  }
1018
163
 
1019
- // ---- drift across every repo (per-repo facts: drift.mjs) -------------------
1020
- function computeDrift(opts = {}) {
1021
- const own = scanBegin();
1022
- try {
1023
- const deploys = loadDeploys();
1024
- const found = findAllRepos().slice(0, 20), repos = [];
1025
- scanPhase("checking drift", found.length);
1026
- for (const r of found) {
1027
- if (scanExpired()) break;
1028
- repos.push(driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch })); scanTick(r.name);
1029
- }
1030
- return { repos, ci: !!opts.ci, partial: SCAN.partial, base: BASE };
1031
- } finally { scanEnd(own); }
1032
- }
1033
164
  function cmdDrift() {
1034
165
  const d = computeDrift({ ci: has("ci"), fetch: has("fetch") });
1035
166
  const warn = d.repos.filter((r) => r.flags.some((f) => f.level === "warn"));
@@ -1046,515 +177,18 @@ function cmdDrift() {
1046
177
  console.log(c.d(`\nsymbiot ${VERSION} · local git facts only${d.ci ? " + CI" : " (add --ci for CI status)"}${loadDeploys() && Object.keys(loadDeploys()).length ? "" : " · set ~/.config/symbiot/deploys.json for production-sha checks"}`));
1047
178
  }
1048
179
 
1049
- // ---- work map: a node graph of your repos, languages, and tools -----------
1050
- const EXT_LANG = {
1051
- ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
1052
- py: "Python", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", rb: "Ruby", php: "PHP",
1053
- cs: "C#", cpp: "C++", cc: "C++", c: "C", swift: "Swift", vue: "Vue", svelte: "Svelte",
1054
- sql: "SQL", sh: "Shell", css: "CSS", scss: "CSS", html: "HTML",
1055
- };
1056
- const MANIFEST_TOOL = {
1057
- "package.json": "Node", "requirements.txt": "Python", "pyproject.toml": "Python",
1058
- "go.mod": "Go", "Cargo.toml": "Rust", "pom.xml": "Maven", "build.gradle": "Gradle",
1059
- "Gemfile": "Ruby", "composer.json": "PHP", "Dockerfile": "Docker", "docker-compose.yml": "Docker",
1060
- "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
1061
- };
1062
- function findAllRepos(base) {
1063
- const own = scanBegin();
1064
- try {
1065
- const roots = base ? [base] : scanRoots();
1066
- const repos = []; const seen = new Set();
1067
- for (const root of roots) {
1068
- if (scanExpired()) break;
1069
- scanPhase("finding repos in " + root.replace(homedir(), "~"));
1070
- const gits = findGitDirs(root, 300);
1071
- scanPhase("reading repos", gits.length);
1072
- for (const g of gits) {
1073
- if (scanExpired()) break;
1074
- const repo = g.replace(/\/\.git$/, ""); scanTick(repo.split("/").pop());
1075
- if (seen.has(repo)) continue; seen.add(repo);
1076
- const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
1077
- if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
1078
- }
1079
- }
1080
- // Group worktrees by shared git dir; keep only the freshest checkout of each.
1081
- // (Not deadline-bound: it's what makes the result correct, and it's cheap.)
1082
- const byCommon = {};
1083
- for (const r of repos) {
1084
- const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
1085
- const key = cd.startsWith("/") ? cd : join(r.path, cd);
1086
- if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
1087
- }
1088
- return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
1089
- } finally { scanEnd(own); }
1090
- }
1091
- function detectRepo(r) {
1092
- const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
1093
- const count = {}; const tools = new Set();
1094
- for (const f of files) {
1095
- const base = f.split("/").pop();
1096
- if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]);
1097
- const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase();
1098
- if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1;
1099
- }
1100
- const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
1101
- const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
1102
- const mine = Number(sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --oneline 2>/dev/null | wc -l`).trim()) || 0;
1103
- const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
1104
- const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
1105
- return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
1106
- }
1107
- // Non-git PROJECT folders inside your scan roots (you added them = consent):
1108
- // a directory with a manifest but no .git — "not everything is a repo".
1109
- function findProjectFolders(root) {
1110
- const manifests = findPaths([root, "-maxdepth", "3", ...anyName([...PRUNE, ".git"]), "-prune", "-o", "-type", "f",
1111
- ...anyName(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "Gemfile", "composer.json", "Dockerfile", "pubspec.yaml", "CMakeLists.txt"]), "-print"], 200);
1112
- const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
1113
- const out = [];
1114
- for (const d of dirs) {
1115
- if (sh(`git -C ${JSON.stringify(d)} rev-parse --is-inside-work-tree 2>/dev/null`).trim() === "true") continue; // inside a repo already
1116
- out.push(d); if (out.length >= 24) break;
1117
- }
1118
- return out;
1119
- }
1120
- function detectFolder(path) {
1121
- const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -2000`).split("\n").filter(Boolean);
1122
- const count = {}; const tools = new Set();
1123
- for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
1124
- return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
1125
- }
1126
- // Projects you've run an AI coding agent on. Claude Code stores one dir per
1127
- // project under ~/.claude/projects, named by the project path with every
1128
- // non-alphanumeric char → "-" (so "/" "." "_" "-" all collide). Returns
1129
- // { "<key>": { agent, last, path, rootfs, enc } } — last = newest session mtime
1130
- // (epoch secs); path = the real project dir, read from the "cwd" a session
1131
- // records (empty when no session says, then callers fall back to decoding enc).
1132
- // In Termux it also reads Claude Code's history inside each proot-distro (in
1133
- // the distro's /root and /home/<user>). Paths there are the distro's own
1134
- // ("/root/work/proj"), so rootfs is the distro's folder ("" outside one), path
1135
- // is prefixed with it, and key = agentKey(rootfs, enc).
1136
- // (Gemini's dir is global config, Codex's is empty, so no reliable per-project
1137
- // signal there yet; add them here when there is.)
1138
- const claudeEnc = (p) => String(p || "").replace(/[^a-zA-Z0-9]/g, "-");
1139
- function sessionCwd(file) {
1140
- const head = sh(`head -c 262144 ${JSON.stringify(file)} 2>/dev/null`);
1141
- const m = head.match(/"cwd":"((?:[^"\\]|\\.)*)"/);
1142
- if (!m) return "";
1143
- try { return JSON.parse(`"${m[1]}"`); } catch { return ""; }
1144
- }
1145
- const agentKey = (rootfs, enc) => (rootfs ? rootfs + "\0" : "") + enc;
1146
- // The keys a project folder's history could be under: its path, and its path as
1147
- // seen from inside the proot-distro it's in (if any), each encoded both ways.
1148
- function agentKeys(path, distros) {
1149
- const enc = (p) => [claudeEnc(p), p.replace(/\//g, "-")]; // older Claude Code mapped only "/"
1150
- const rootfs = distros.find((r) => path.startsWith(r + "/"));
1151
- return [...enc(path), ...(rootfs ? enc(path.slice(rootfs.length)).map((e) => agentKey(rootfs, e)) : [])];
1152
- }
1153
- function claudeProjects() {
1154
- const out = {};
1155
- readClaudeProjects(join(homedir(), ".claude", "projects"), "", out);
1156
- for (const { rootfs, home } of prootHomeDirs()) readClaudeProjects(join(home, ".claude", "projects"), rootfs, out);
1157
- return out;
1158
- }
1159
- function readClaudeProjects(base, rootfs, out) {
1160
- let dirs = [];
1161
- try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
1162
- for (const enc of dirs) {
1163
- let last = 0, path = "";
1164
- const newest = sh(`find ${JSON.stringify(join(base, enc))} -maxdepth 1 -name '*.jsonl' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1`).trim();
1165
- if (newest) {
1166
- const sp = newest.indexOf(" ");
1167
- last = parseInt(newest.slice(0, sp), 10) || 0;
1168
- const cwd = sessionCwd(newest.slice(sp + 1));
1169
- if (cwd && (claudeEnc(cwd) === enc || cwd.replace(/\//g, "-") === enc)) path = cwd; // only trust a cwd that matches this dir
1170
- }
1171
- if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
1172
- const key = agentKey(rootfs, enc);
1173
- if (out[key] && out[key].last >= last) continue; // one project worked on from two of the distro's homes: keep the newest
1174
- out[key] = { agent: "Claude Code", last, path: path && rootfs + path, rootfs, enc };
1175
- }
1176
- }
1177
- // Async only to YIELD between repos, so the app server can answer /api/scan
1178
- // (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
1179
- // callers (e.g. Rescan while a scan runs) share the one in-flight build.
1180
- let MAP_BUILD = null;
1181
- function buildMap() {
1182
- if (!MAP_BUILD) MAP_BUILD = buildMapNow().finally(() => { MAP_BUILD = null; });
1183
- return MAP_BUILD;
1184
- }
1185
- const yieldTick = () => new Promise((r) => setImmediate(r));
1186
- async function buildMapNow() {
1187
- const own = scanBegin();
1188
- try { return await buildMapScan(); } finally { scanEnd(own); }
1189
- }
1190
- async function buildMapScan() {
1191
- const who = me();
1192
- const found = findAllRepos();
1193
- const repos = [];
1194
- scanPhase("reading repo details", found.length);
1195
- for (const r of found) {
1196
- if (scanExpired()) break;
1197
- await yieldTick();
1198
- repos.push(detectRepo(r)); scanTick(r.name);
1199
- }
1200
- const nodes = []; const edges = []; const have = new Set();
1201
- const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
1202
- add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
1203
- for (const r of repos) {
1204
- const rid = "repo:" + r.path;
1205
- add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
1206
- meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools, files: r.files, branch: r.branch, last: r.last, path: r.path } });
1207
- edges.push({ source: "me", target: rid });
1208
- for (const L of r.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: rid, target: id }); }
1209
- for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
1210
- }
1211
- // Non-git project folders in the scan roots (capped; deduped against repos).
1212
- try {
1213
- const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
1214
- for (const root of scanRoots()) { if (scanExpired()) break; scanPhase("finding project folders"); for (const d of findProjectFolders(root)) {
1215
- if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
1216
- if (scanExpired()) break;
1217
- await yieldTick();
1218
- const det = detectFolder(d); folders++; scanTick(det.name);
1219
- add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files } });
1220
- edges.push({ source: "me", target: "folder:" + d });
1221
- for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
1222
- } }
1223
- } catch {}
1224
- // What you build WITH (person-level, not per-repo): detected agents/editors
1225
- // and the AI currently powering Symbiot — so the initial scan shows the whole
1226
- // setup, not just code. All local detection, nothing invasive.
1227
- try {
1228
- const hands = detectHandoffs();
1229
- for (const a of [...hands.agents, ...hands.editors]) {
1230
- const id = "agent:" + a.label;
1231
- add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
1232
- edges.push({ source: "me", target: id });
1233
- }
1234
- const prov = resolveProvider();
1235
- if (prov) { const id = "ai:" + prov.provider; add({ id, type: "ai", label: PROVIDERS[prov.provider].label, weight: 14, meta: { model: prov.model, source: prov.source } }); edges.push({ source: "me", target: id }); }
1236
- } catch {}
1237
- // Agent projects: badge the repo/folder nodes an AI coding agent has worked,
1238
- // and surface agent-worked projects the scan missed as their own nodes.
1239
- try {
1240
- const ap = claudeProjects(), distros = prootDistros(); const used = new Set();
1241
- for (const n of nodes) {
1242
- if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
1243
- const e = agentKeys(n.meta.path, distros).find((k) => ap[k]);
1244
- if (e) { n.meta.agents = [{ agent: ap[e].agent, last: ap[e].last }]; used.add(e); }
1245
- }
1246
- }
1247
- let extra = 0;
1248
- for (const e of Object.keys(ap)) {
1249
- if (used.has(e) || extra >= 20) continue;
1250
- // the session's recorded cwd; else best-effort decode (names with - . _ won't resolve and are skipped)
1251
- const decoded = ap[e].path || ap[e].rootfs + ap[e].enc.replace(/-/g, "/");
1252
- if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
1253
- if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
1254
- const isGit = existsSync(join(decoded, ".git"));
1255
- const id = (isGit ? "repo:" : "folder:") + decoded; if (have.has(id)) continue;
1256
- const det = isGit ? null : detectFolder(decoded);
1257
- add({ id, type: isGit ? "repo" : "folder", label: decoded.split("/").pop(), weight: 10, meta: { path: decoded, agents: [{ agent: ap[e].agent, last: ap[e].last }], agentOnly: true, langs: det ? det.langs.slice(0, 3) : [], tools: det ? det.tools : [], files: det ? det.files : 0 } });
1258
- edges.push({ source: "me", target: id }); extra++;
1259
- }
1260
- } catch {}
1261
- const out = { nodes, edges, stats: {
1262
- repos: repos.length,
1263
- folders: nodes.filter((n) => n.type === "folder").length,
1264
- languages: nodes.filter((n) => n.type === "lang").length,
1265
- tools: nodes.filter((n) => n.type === "tool").length,
1266
- agents: nodes.filter((n) => n.type === "agent").length,
1267
- commits: repos.reduce((s, r) => s + r.mine, 0),
1268
- files: repos.reduce((s, r) => s + (r.files || 0), 0),
1269
- base: BASE,
1270
- roots: scanRoots(), // every folder scanned (base is the first, unless you picked others)
1271
- partial: SCAN.partial, // the scan hit its deadline — this is what it found so far
1272
- android: process.env.SYMBIOT_ANDROID_APP === "1",
1273
- noStorage: storageBlocked(),
1274
- } };
1275
- LAST_MAP = out;
1276
- return out;
1277
- }
1278
- // Local detail for a clicked node (no AI).
1279
- async function nodeDetail(id) {
1280
- const map = LAST_MAP || await buildMap();
1281
- if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
1282
- if (id.startsWith("repo:")) {
1283
- const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
1284
- return {
1285
- type: "repo", label: n ? n.label : path.split("/").pop(), path,
1286
- branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
1287
- dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
1288
- last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
1289
- commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [], agents: m.agents || [],
1290
- };
1291
- }
1292
- if (id.startsWith("lang:") || id.startsWith("tool:")) {
1293
- const n = map.nodes.find((x) => x.id === id);
1294
- const repos = map.edges.filter((e) => e.target === id).map((e) => { const r = map.nodes.find((x) => x.id === e.source); return r ? r.label : null; }).filter(Boolean);
1295
- return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
1296
- }
1297
- if (id.startsWith("agent:") || id.startsWith("ai:")) {
1298
- const n = map.nodes.find((x) => x.id === id);
1299
- return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
1300
- }
1301
- if (id.startsWith("folder:")) {
1302
- const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
1303
- return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0, agents: m.agents || [] };
1304
- }
1305
- return { error: "unknown node" };
1306
- }
1307
- // AI review of one repo: what it does, what it's for, possible upgrades.
1308
- async function repoReview(path) {
1309
- if (!resolveProvider()) return { error: "not-connected" };
1310
- if (!path) return { error: "no repo" };
1311
- const name = path.split("/").pop();
1312
- const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), shape = repoShape(path), rules = houseRules(path);
1313
- const commits = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
1314
- || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
1315
- const footer = reportFooter(path, auth, state, rd);
1316
- // Cheap maturity gauge, so the model can be told to show restraint instead of
1317
- // always inventing new features (over-engineering is a failure, not a win).
1318
- const testFiles = Number(sh(`git -C ${JSON.stringify(path)} ls-files 2>/dev/null | grep -icE '(^|/)(tests?|spec|__tests__)/|\\.(test|spec)\\.'`).trim()) || 0;
1319
- const churn = Number(sh(`git -C ${JSON.stringify(path)} log --since='14 days ago' --oneline 2>/dev/null | wc -l`).trim()) || 0;
1320
- const posture = state.stale ? "the checkout is a stale snapshot — the priority is getting current, not new work"
1321
- : (testFiles === 0 && shape.total > 40) ? "a real codebase with NO test files — favour tests/stabilising over new features"
1322
- : (churn >= 25) ? "very rapid churn lately — likely over-iterating; be conservative, prefer consolidation/finishing over new features"
1323
- : (state.dirty > 20) ? "a lot of unfinished/uncommitted work — finish what's open before starting new things"
1324
- : "no red flags — but still only propose what clearly pays off";
1325
- const system =
1326
- `You are a pragmatic, restraint-minded reviewer of ONE software project. Respond with ONLY a JSON object (no fences): {"review": string, "verdict": string, "ideas": string[]}. ` +
1327
- `"review": 3-6 sentences on what it does and who it's for. ` +
1328
- `"verdict": ONE honest sentence on whether this project needs new work now, and of what KIND. Over-engineering is a failure: if it's already capable, or shows churn / missing tests / unfinished work, say so and steer toward STABILISING (tests, docs, finishing, removing) or simply SHIPPING — not more features. ` +
1329
- `"ideas": AT MOST 5, ranked by value, ONLY items that clearly pay off. Do NOT pad the list — returning 0–2 ideas with a "stabilise, don't add" verdict is a good, correct answer. Prefer fixes / tests / simplification / finishing over new features unless a feature is clearly warranted by the evidence. ` +
1330
- `Ground every claim in the evidence; recent commits beat the README; never propose building something already in the structure; never advise against the stated conventions. ` +
1331
- (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it. ` : ``);
1332
- const prompt =
1333
- `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n` +
1334
- `Maturity signal: ${posture}. (${testFiles} test files, ${churn} commits in the last 14 days.)\n\n` +
1335
- (rd.file ? `README (last changed ${rd.lastDate || "?"}, ${rd.commitsAgo} commits ago — may be out of date):\n${rd.excerpt}\n\n` : "(no README)\n\n") +
1336
- `Structure (top folders · file counts): ${shape.dirs.join(", ")}\nManifests: ${shape.manifests.join(", ") || "none"}\nDocs present: ${shape.docs.join(", ") || "none"}\n\n` +
1337
- `Recent commits (newest first):\n${commits || "(none)"}\n\n` +
1338
- (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
1339
- (state.stale ? `Working tree: STALE (≈ ${state.staleBy ? "HEAD~" + state.staleBy : "older"}); its "changes" are the gap to an old snapshot, not new work.\n\n` : "") +
1340
- `Return the JSON. Remember: fewer, higher-value ideas beat a long list; a "don't add — stabilise" verdict with 0–2 ideas is a valid answer.`;
1341
- const raw = await write(system, prompt);
1342
- if (!raw) return { text: "(couldn't reach the model)", ideas: [], verdict: "", footer };
1343
- const j = extractJson(raw);
1344
- if (j && (j.review || j.ideas || j.verdict)) return {
1345
- text: String(j.review || "").trim() || raw,
1346
- verdict: String(j.verdict || "").trim(),
1347
- ideas: Array.isArray(j.ideas) ? j.ideas.map((x) => String(x).trim()).filter(Boolean).slice(0, 5) : [],
1348
- footer,
1349
- };
1350
- return { text: raw, ideas: [], verdict: "", footer };
1351
- }
1352
- function extractJson(s) {
1353
- if (!s) return null;
1354
- let t = String(s).trim().replace(/^```(?:json)?/i, "").replace(/```$/,"").trim();
1355
- const a = t.indexOf("{"), b = t.lastIndexOf("}");
1356
- if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
1357
- return null;
1358
- }
1359
- // AI "value" suggestions for one repo, from its recent commits + open work.
1360
- async function repoSuggest(path) {
1361
- if (!resolveProvider()) return { error: "not-connected" };
1362
- if (!path) return { error: "no repo" };
1363
- const name = path.split("/").pop();
1364
- const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), rules = houseRules(path);
1365
- const recent = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
1366
- || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
1367
- const open = openWork([{ path, name }]);
1368
- const footer = reportFooter(path, auth, state, rd);
1369
- const system =
1370
- `You are a pragmatic senior engineer advising on one project. Write exactly three short sections with headings: ` +
1371
- `"In flight" (what's clearly underway), "Next steps" (3-5 concrete actions), "Ideas" (2-3 that fit where this is heading). ` +
1372
- `Ground each point in the evidence; recent commits beat the README. Do not advise against the stated conventions. ` +
1373
- (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it (would revert history). ` : ``) +
1374
- `No filler, no preamble.`;
1375
- const prompt =
1376
- `Project: ${name} (branch ${state.branch}, ${auth.total} commits, ${auth.mineCount} yours${auth.filterDropped ? " — filter dropped" : ""}).\n\n` +
1377
- `Recent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\n` +
1378
- (rules ? `Conventions — do NOT advise against these:\n${rules}\n\n` : "") + `Give the advice.`;
1379
- const text = await write(system, prompt);
1380
- return { text: text || "(couldn't reach the model)", footer };
1381
- }
1382
- // Overview + suggestions for a NON-git project folder (no commits to read), from
1383
- // its file tree, manifest and README. The folder-node equivalent of repoSuggest.
1384
- async function folderSuggest(path) {
1385
- if (!resolveProvider()) return { error: "not-connected" };
1386
- if (!path) return { error: "no path" };
1387
- const det = detectFolder(path);
1388
- const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -200`).split("\n").filter(Boolean);
1389
- const rels = files.map((f) => f.slice(path.length + 1));
1390
- const pick = (names) => { for (const n of names) { const hit = files.find((f) => f.split("/").pop().toLowerCase() === n); if (hit) { try { return readFileSync(hit, "utf8").slice(0, 2000); } catch {} } } return ""; };
1391
- const manifest = pick(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "cargo.toml", "pom.xml", "composer.json", "gemfile", "pubspec.yaml"]);
1392
- const readme = pick(["readme.md", "readme.txt", "readme"]);
1393
- const stack = [...det.langs, ...det.tools].join(", ") || "unknown";
1394
- const system =
1395
- `You are a pragmatic senior engineer looking at a project FOLDER that is NOT under version control. ` +
1396
- `From its files, write exactly three short sections with headings: "Overview" (what this project is, 1-2 sentences), ` +
1397
- `"Next steps" (3-5 concrete actions — a strong first one is often "git init" if this looks like real work), ` +
1398
- `"Ideas" (2-3 that fit where it's heading). Ground every point in the actual files/manifest/README shown. No preamble.`;
1399
- const prompt =
1400
- `Folder: ${det.name}\nStack: ${stack}\nFiles (${det.files}):\n${rels.slice(0, 120).join("\n")}\n\n` +
1401
- (manifest ? `Manifest:\n${manifest}\n\n` : "") + (readme ? `README excerpt:\n${readme}\n\n` : "") + `Write the overview and suggestions.`;
1402
- const text = await write(system, prompt);
1403
- const footer = `symbiot ${VERSION} · folder · ${det.files} files · ${stack} · not a git repo`;
1404
- return { text: text || "(couldn't reach the model)", footer };
1405
- }
1406
- // Ask questions about a task: a short Q&A thread kept on the task itself
1407
- // (task.chat), grounded in the repo/folder it belongs to — and, once an agent
1408
- // has ticked it, in the changes waiting for review.
1409
- const CHAT_KEEP = 40; // messages stored per task
1410
- async function taskChat(id, question) {
1411
- question = String(question || "").trim().slice(0, 2000);
1412
- const it = loadTasks().find((x) => x.id === id);
1413
- if (!it) return { error: "not found" };
1414
- if (!question) return { error: "empty" };
1415
- if (!resolveProvider()) return { error: "not-connected" };
1416
- const path = it.repo ? repoPathMap()[it.repo] : "";
1417
- const ctx = [];
1418
- if (path) {
1419
- const isGit = existsSync(join(path, ".git"));
1420
- if (isGit) ctx.push(`Recent commits:\n${sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -15 2>/dev/null`).trim() || "(none)"}`);
1421
- else { const det = detectFolder(path); ctx.push(`Project folder (not a git repo) · ${det.files} files · ${[...det.langs, ...det.tools].join(", ") || "unknown stack"}`); }
1422
- const rd = readmeInfo(path); if (rd.excerpt) ctx.push(`README excerpt:\n${rd.excerpt.slice(0, 1500)}`);
1423
- const rules = houseRules(path); if (rules) ctx.push(`Conventions — do NOT advise against these:\n${rules}`);
1424
- if (it.review && isGit) {
1425
- const ch = workingChanges(path);
1426
- ctx.push(`The agent has ticked this task; its uncommitted changes await review (${ch.stat || "no changes"}):\n${ch.files.map((f) => `${f.st} ${f.file}`).slice(0, 40).join("\n") || "(none)"}\n\nDiff (truncated):\n${workingDiff(path, 6000)}`);
1427
- }
1428
- }
1429
- const history = (it.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`).join("\n\n");
1430
- const system =
1431
- `You are a pragmatic senior engineer helping someone with ONE task on their list — before or after they hand it to a coding agent. ` +
1432
- `Answer their question about the task directly and concisely, in plain text, no preamble. Ground what you say in the project evidence shown; ` +
1433
- `if the evidence doesn't settle it, say so and what you'd check. If the task is ambiguous, say how you'd read it and what to clarify. ` +
1434
- `Never invent files, features or history.`;
1435
- const prompt =
1436
- `Task: ${it.text}\nKind: ${taskType(it.text)} · repo: ${it.repo || "(none)"} · status: ${it.archived ? "archived" : it.review ? "done by the agent, awaiting review" : it.done ? "done" : "open"}\n\n` +
1437
- (ctx.length ? ctx.join("\n\n") + "\n\n" : path ? "" : "(no repo attached — answer from the task text alone)\n\n") +
1438
- (history ? `Conversation so far:\n${history}\n\n` : "") + `Question: ${question}`;
1439
- const answer = (await write(system, prompt)) || "(couldn't reach the model)";
1440
- // Re-read: other requests may have changed tasks.json while the model ran.
1441
- const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
1442
- if (!cur) return { answer, chat: [] };
1443
- cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-CHAT_KEEP);
1444
- saveTasks(t);
1445
- return { answer, chat: cur.chat };
1446
- }
1447
- function clearTaskChat(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" }; delete it.chat; saveTasks(t); return { ok: true }; }
1448
-
1449
- // Build a write-up for a command; returns { text, sub, error? } without printing.
1450
- // Shared by the CLI (cmdRun) and the web UI (symbiot app).
1451
- // The SAME repo set the Map uses — reuse its cached scan when present, else do
1452
- // the identical discovery. So week/standup/todo/drift all agree with the Map.
1453
- function discoveredRepos() {
1454
- if (LAST_MAP && LAST_MAP.nodes) return LAST_MAP.nodes.filter((n) => n.type === "repo" && n.meta && n.meta.path).map((n) => ({ path: n.meta.path, name: n.label, recency: 0 }));
1455
- return findAllRepos();
1456
- }
1457
- // ---- email: opt-in, local, no API (see mail.mjs) ---------------------------
1458
- // config.mail = { enabled, sources: [extra folders/.mbox files], addresses: [your
1459
- // mail addresses, to pick your mail out of a whole-mailbox export] }.
1460
- function mailCfg() {
1461
- const m = loadConfig().mail || {};
1462
- return { enabled: !!m.enabled, sources: Array.isArray(m.sources) ? m.sources : [], addresses: Array.isArray(m.addresses) ? m.addresses : [] };
1463
- }
1464
- function mailState() { return { ...mailCfg(), detected: detectMailSources() }; }
1465
- function setMail(b = {}) {
1466
- const m = mailCfg();
1467
- if (typeof b.enabled === "boolean") m.enabled = b.enabled;
1468
- if (b.add) {
1469
- const p = expandRoot(b.add);
1470
- // A website typed here (it sits under Trusted sites, easily mixed up): say where it goes.
1471
- const site = p && !existsSync(p) && !/\.(mbox|mbx|eml|msf|sbd)$/i.test(p) && /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?([/?#].*)?$/i.test(p) ? new URL(/^https?:/i.test(p) ? p : "https://" + p).hostname : "";
1472
- if (site) return { ...mailState(), site, error: `${site} is a website, not mail on this computer. To let Screens press and type there without asking, add it under Trusted sites, just above.` };
1473
- if (!p || !existsSync(p)) return { ...mailState(), error: "not found: " + (p || "(empty)") };
1474
- if (!m.sources.includes(p)) m.sources.push(p);
1475
- }
1476
- if (b.remove) m.sources = m.sources.filter((x) => x !== b.remove);
1477
- if (typeof b.addresses === "string") m.addresses = b.addresses.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.includes("@"));
1478
- const cfg = loadConfig(); cfg.mail = m; saveConfig(cfg);
1479
- return mailState();
1480
- }
1481
- // Mail you sent in the window — [] unless it's switched on (preview ignores that).
1482
- function sentMail(days, preview = false) {
1483
- const m = mailCfg(); if (!m.enabled && !preview) return [];
1484
- return mailActivity({ days, sources: m.sources, addresses: [...m.addresses, me().email].filter(Boolean) });
1485
- }
1486
- const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x.to.join(", ") || "?"} · ${x.subject}`).join("\n");
1487
- async function produce(cmd) {
1488
- if (!resolveProvider()) return { error: "not-connected" };
1489
- if (cmd === "todo") {
1490
- const repos = discoveredRepos();
1491
- const open = openWork(repos);
1492
- if (!open.length) return { text: "Nothing outstanding found (no TODOs or uncommitted work).", sub: "0 open items · todo" };
1493
- const system =
1494
- `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
1495
- `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
1496
- `Be concise and concrete. No preamble.`;
1497
- const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
1498
- return { text: text || "(couldn't reach the model)", sub: `${open.length} open items · todo`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${open.length} open items` };
1499
- }
1500
- const label = cmd === "standup" ? "standup" : "week";
1501
- const days = cmd === "standup" ? 2 : SINCE_WEEK;
1502
- const who = me();
1503
- // Discover the Map's repo set, then keep only those with commits in the window.
1504
- const all = discoveredRepos();
1505
- const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
1506
- const mail = sentMail(days); // [] unless email is switched on
1507
- if (!repos.length && !mail.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1508
- let cs = commits(repos, `${days} days ago`, !has("all"));
1509
- if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
1510
- const open = label === "week" ? openWork(repos) : [];
1511
- if (!cs.length && !mail.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
1512
-
1513
- const system =
1514
- `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
1515
- `Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
1516
- `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
1517
- (mail.length ? `Fold the emails into those themes (a decision, a hand-off, who they worked with); skip routine ones (receipts, scheduling, one-line replies). You only have their subjects and recipients — don't guess at what they said. ` : "") +
1518
- `${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
1519
- `No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""} and open items show.`;
1520
- const prompt =
1521
- `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
1522
- `Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
1523
- (mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
1524
- (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
1525
- `Write the ${label === "standup" ? "standup" : "update"}.`;
1526
-
1527
- const text = await write(system, prompt);
1528
- const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
1529
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
1530
- }
1531
-
1532
180
  async function cmdRun(cmd) {
1533
181
  if (!resolveProvider()) { console.log(AUTH_HELP); return; }
1534
- const r = await produce(cmd);
182
+ const r = await produce(cmd, { since: SINCE_WEEK, all: has("all") });
1535
183
  header(r.sub || cmd);
1536
184
  if (r.text) console.log(r.text + "\n");
1537
185
  if (r.footer) console.log(c.d(r.footer) + "\n");
1538
186
  }
1539
187
 
1540
- // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
1541
- async function connectProvider(b) {
1542
- const provider = b && b.provider;
1543
- if (!PROVIDERS[provider]) return { ok: false, message: "Unknown provider." };
1544
- const cfg = loadConfig();
1545
- if (provider === "ollama") {
1546
- const baseUrl = (b.baseUrl || "http://localhost:11434").trim();
1547
- const model = (b.model || "").trim() || PROVIDERS.ollama.model;
1548
- if (!(await validate("ollama", { baseUrl }))) return { ok: false, message: `Couldn't reach Ollama at ${baseUrl}. Is it running?` };
1549
- cfg.provider = "ollama"; cfg.ollama = { baseUrl, model }; delete cfg.apiKey;
1550
- return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS.ollama.label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
1551
- }
1552
- const key = (b.key || "").trim();
1553
- if (!key) return { ok: false, message: "No key entered." };
1554
- const model = (b.model || "").trim() || PROVIDERS[provider].model;
1555
- if (!(await validate(provider, { key }))) return { ok: false, message: `That key didn't work for ${PROVIDERS[provider].label}.` };
1556
- cfg.provider = provider; cfg[provider] = { apiKey: key, model }; delete cfg.apiKey;
1557
- return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
188
+ // `symbiot app` (server.mjs). While it serves: no spinner, no scan progress line.
189
+ function cmdApp() {
190
+ SERVING = true; setScanOptions({ quiet: true });
191
+ return startApp({ bin: realpathSync(fileURLToPath(import.meta.url)), since: SINCE_WEEK, all: has("all"), c });
1558
192
  }
1559
193
 
1560
194
  function saveAndReport(cfg, what) {
@@ -1636,224 +270,6 @@ function cmdWhoami() {
1636
270
  else { console.log(c.y("Not connected yet.\n")); console.log(AUTH_HELP); }
1637
271
  }
1638
272
 
1639
- // ---- `symbiot app` : the same UI in a chrome-less browser window ----------
1640
- // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
1641
- // template literal). Talks to the local API with the per-launch token.
1642
-
1643
- function readBody(req) {
1644
- return new Promise((resolve) => {
1645
- let d = ""; req.on("data", (ch) => (d += ch));
1646
- req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
1647
- });
1648
- }
1649
- // http://127.0.0.1:<port>/?t=<token> -> symbiot://127.0.0.1:<port>/?t=<token>, for the Android app
1650
- function appLink(url) { return url.replace(/^http:\/\//, "symbiot://"); }
1651
- // Symbiot running in Termux (not the Android app's own)
1652
- const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_APP !== "1";
1653
- function openApp(url) {
1654
- try {
1655
- // Android (Termux): Symbiot's Android app shows this Symbiot full screen (it
1656
- // can't read Termux's home, this one can), so the link goes to it first, as a
1657
- // symbiot:// link: only that app takes those, where Android gives an http one
1658
- // to the browser. Without the app, `am` can't resolve it, and the URL goes to
1659
- // the phone's browser: termux-open-url ships with Termux, `am start` is the fallback.
1660
- if (process.platform === "android") {
1661
- if (hasCmd("am")) {
1662
- const r = spawnSync("am", ["start", "-a", "android.intent.action.VIEW", "-d", appLink(url)], { encoding: "utf8", timeout: 8000 });
1663
- if (r.status === 0 && !/error|unable|exception/i.test(`${r.stdout || ""}${r.stderr || ""}`)) return "Symbiot app window";
1664
- }
1665
- if (hasCmd("termux-open-url")) spawn("termux-open-url", [url], { detached: true, stdio: "ignore" }).unref();
1666
- else spawn("am", ["start", "-a", "android.intent.action.VIEW", "-d", url], { detached: true, stdio: "ignore" }).unref();
1667
- return "browser tab";
1668
- }
1669
- const chrome = chromeBinary();
1670
- if (chrome) { spawn(chrome, [`--app=${url}`, "--new-window", "--no-first-run", "--no-default-browser-check"], { detached: true, stdio: "ignore" }).unref(); return "app window"; }
1671
- // fall back to the OS default browser (a normal tab) — still fully functional
1672
- if (process.platform === "win32") { spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); return "browser tab"; }
1673
- spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
1674
- return "browser tab";
1675
- } catch { return null; }
1676
- }
1677
- async function cmdApp() {
1678
- SERVING = true;
1679
- const SERVER_STARTED = Date.now();
1680
- // Stable token + port so the URL survives a restart — the open tab can
1681
- // reconnect and auto-reload itself instead of you closing and reopening it.
1682
- const cfg0 = loadConfig();
1683
- let TOKEN = cfg0.appToken;
1684
- if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
1685
- const PORT = Number(process.env.SYMBIOT_PORT || cfg0.appPort) || 7391;
1686
- // Single instance: if a Symbiot app is already serving this port, don't start
1687
- // a second one (multiple instances race the config and split the open tabs) —
1688
- // just open the one that's running. SYMBIOT_FORCE_NEW overrides (e.g. tests).
1689
- // Not when "Update & restart" relaunched us (SYMBIOT_RELAUNCH): the old app is
1690
- // handing this port over and its window is still open, so finding it here would
1691
- // open a second window and exit, leaving nothing serving either window.
1692
- const RELAUNCH = process.env.SYMBIOT_RELAUNCH === "1"; delete process.env.SYMBIOT_RELAUNCH;
1693
- if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
1694
- try {
1695
- const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 800);
1696
- const r = await fetch(`http://127.0.0.1:${PORT}/api/ping`, { headers: { "x-symbiot-token": TOKEN }, signal: ctrl.signal }).catch(() => null);
1697
- clearTimeout(to);
1698
- if (r && r.ok) {
1699
- const p = await r.json().catch(() => ({}));
1700
- if (p && p.version) {
1701
- const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
1702
- console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
1703
- console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
1704
- console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
1705
- return;
1706
- }
1707
- }
1708
- } catch {}
1709
- }
1710
- // Screens' hidden browser stays open between map, press and type (headless.mjs),
1711
- // so an agent can type into a field and then press a separate button there.
1712
- keepBrowserOpen(BROWSER_KEEP);
1713
- const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1714
- const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
1715
- const server = createServer(async (req, res) => {
1716
- const u = new URL(req.url, "http://127.0.0.1");
1717
- if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
1718
- if (u.pathname.startsWith("/api/")) {
1719
- const tok = req.headers["x-symbiot-token"] || u.searchParams.get("t");
1720
- if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
1721
- }
1722
- try {
1723
- if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
1724
- if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
1725
- if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
1726
- if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
1727
- if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
1728
- if (u.pathname === "/api/node") return json(res, await nodeDetail(u.searchParams.get("id") || "")); // local
1729
- if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); const p = String(b.path || ""); const isRepo = p && existsSync(join(p, ".git")); return json(res, await (isRepo ? repoSuggest(p) : folderSuggest(p))); }
1730
- if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1731
- if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
1732
- if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1733
- if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1734
- if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1735
- if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1736
- if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1737
- if (u.pathname === "/api/tasks/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await taskChat(String(b.id || ""), b.question)); }
1738
- if (u.pathname === "/api/tasks/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearTaskChat(String(b.id || ""))); }
1739
- if (u.pathname === "/api/pending") { // ticked by the agent, awaiting approval
1740
- // what npm has decides the bump offer, and for a repo that publishes on
1741
- // merge, what's unreleased: ask, then recount (answers are cached)
1742
- const list = pendingReview(), ask = list.filter((r) => r.path && (!r.bumpOffer || r.publishesOnMerge)).map((r) => r.path);
1743
- if (!ask.length) return json(res, list);
1744
- await learnNpm(ask); return json(res, pendingReview());
1745
- }
1746
- if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1747
- if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
1748
- if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump })); }
1749
- if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1750
- if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
1751
- if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1752
- if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
1753
- if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
1754
- if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
1755
- if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1756
- if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
1757
- if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
1758
- if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), auto: !!(e && e.auto), id: e ? e.id : "" }); }
1759
- if (u.pathname === "/api/setup-local" && req.method === "POST") {
1760
- const b = await readBody(req);
1761
- if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
1762
- const model = String(b.model || "") || recommendModels(detectHardware()).best;
1763
- await ensureOllama();
1764
- const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1765
- return json(res, { started: true, model, id: e ? e.id : "" });
1766
- }
1767
- if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
1768
- if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
1769
- if (u.pathname === "/api/mail") return json(res, mailState());
1770
- if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
1771
- if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || SINCE_WEEK, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
1772
- // Screens: screenshots + named regions (screens.mjs). The image is an <img>
1773
- // src, so it carries the token in the query (?t=), which the check above accepts.
1774
- if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
1775
- if (u.pathname === "/api/screens/image") { const f = screenImage(u.searchParams.get("id")); if (!f) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": "image/png", "cache-control": "private, max-age=86400" }); res.end(readFileSync(f)); return; } // a screen's image never changes
1776
- if (u.pathname === "/api/screens/capture" && req.method === "POST") { const b = await readBody(req); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, screenOut(captureScreen(b.name, typeof b.which === "string" ? b.which : "all"))); }
1777
- // The displays connected now (for "which display" next to Capture). macOS
1778
- // can't take them all in one image (screencapture takes one display at a time).
1779
- if (u.pathname === "/api/screens/monitors") return json(res, { monitors: listMonitors(), whole: process.platform !== "darwin" });
1780
- if (u.pathname === "/api/screens/split" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(splitScreen(String(b.id || "")))); }
1781
- // Changes a desktop permission, so only on the user's confirmed click.
1782
- if (u.pathname === "/api/screens/allow" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Allowing screenshots needs your confirmation." }); return json(res, allowScreenshots()); }
1783
- if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
1784
- if (u.pathname === "/api/screens/regions" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(setRegions(String(b.id || ""), b.regions))); }
1785
- if (u.pathname === "/api/screens/rename" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(renameScreen(String(b.id || ""), b.name))); }
1786
- if (u.pathname === "/api/screens/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScreen(String(b.id || ""))); }
1787
- // A real click on the real screen: only with the user's confirmation, after
1788
- // the delay they picked (to bring the right window to the front).
1789
- if (u.pathname === "/api/screens/click" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Each click needs your confirmation." }); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, clickRegion(String(b.id || ""), String(b.region || ""))); }
1790
- // A web page in the hidden browser (headless.mjs): mapped by itself, no
1791
- // screen needed. Press acts on the real site, signed in as you, so it's
1792
- // confirmed like a click; Sign in opens a window the user signs in with.
1793
- if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name))); }
1794
- // press / type: refused unless confirmed, or the page's site is one you trust
1795
- if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true }))); }
1796
- if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true }))); }
1797
- if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
1798
- if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
1799
- if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
1800
- if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
1801
- // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
1802
- if (u.pathname === "/api/watch") return json(res, watchState());
1803
- if (u.pathname === "/api/watch/add" && req.method === "POST") { const b = await readBody(req); return json(res, addWatch({ screen: b.screen ? String(b.screen) : "", site: b.site, every: b.every })); }
1804
- if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
1805
- if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
1806
- if (u.pathname === "/api/watch/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkWatch(String(b.id || ""))); }
1807
- if (u.pathname === "/api/watch/clear" && req.method === "POST") return json(res, clearNews());
1808
- // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
1809
- if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
1810
- if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
1811
- if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") return json(res, await runWeekly(produce));
1812
- if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, realpathSync(fileURLToPath(import.meta.url)))); }
1813
- if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
1814
- if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1815
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}) }); }
1816
- if (u.pathname === "/api/update" && req.method === "POST") {
1817
- // Install the exact newest version (see updateCmd), then relaunch this
1818
- // same app (same port+token => same URL) and exit. The page's heartbeat
1819
- // reconnects and reloads. Free the port first and mark the new copy as a
1820
- // relaunch, so it takes over instead of finding us and bowing out.
1821
- // SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
1822
- const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
1823
- const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
1824
- const e = track("symbiot update", inst, homedir(), (code) => {
1825
- if (code !== 0) return;
1826
- server.close(); if (server.closeAllConnections) server.closeAllConnections();
1827
- try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" } }); ch.unref(); } catch {}
1828
- setTimeout(() => process.exit(0), 1200);
1829
- });
1830
- return json(res, { started: true, id: e ? e.id : "", target });
1831
- }
1832
- if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
1833
- } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
1834
- res.writeHead(404); res.end("not found");
1835
- });
1836
- let announced = false, tries = 0, opened = false;
1837
- server.on("listening", () => {
1838
- if (announced) return; announced = true;
1839
- const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
1840
- const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
1841
- console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
1842
- console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
1843
- console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
1844
- });
1845
- server.on("error", (e) => {
1846
- // Stable port busy (an older instance still exiting during an update, or a
1847
- // second app): retry briefly, then fall back to a random port.
1848
- if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
1849
- else { try { server.listen(0, "127.0.0.1"); } catch {} }
1850
- });
1851
- server.listen(PORT, "127.0.0.1");
1852
- checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1853
- startWeekly(produce); // the weekly write-up + notification, when switched on in Settings
1854
- startWatches(); // pages you watch (Screens → Watch), read every few minutes
1855
- }
1856
-
1857
273
  // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
1858
274
  function cmdMail() {
1859
275
  if (has("on") || has("off")) setMail({ enabled: has("on") });
@@ -1880,8 +296,7 @@ function screenJson(s) {
1880
296
  const bp = blueprint(s);
1881
297
  return { id: s.id, ...(s.note ? { note: s.note } : {}), ...(s.pressed ? { pressed: s.pressed, found: s.found } : {}), ...(s.typed ? { typed: s.typed, entered: s.entered, found: s.found } : {}), ...(s.kept ? { kept: true } : {}), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}), ...bp, regions: bp.regions.map((r, i) => ({ id: s.regions[i].id, ...r })) };
1882
298
  }
1883
- // How long the app keeps Screens' hidden browser open after an action.
1884
- const BROWSER_KEEP = 5 * 60 * 1000;
299
+
1885
300
  // map, press, type and signin go through the app when it's running, whose hidden
1886
301
  // browser stays open between them: type into a field, then press a separate
1887
302
  // button. null when the app isn't running (then the command runs here, and its
@@ -1944,7 +359,7 @@ async function cmdWatch() {
1944
359
  const hours = Number(flag("hours", 24)) || 24;
1945
360
  if (sub === "list") {
1946
361
  const st = watchState();
1947
- if (!st.watches.length) console.log(c.d("Not watching anything yet. Map a page (symbiot screens map gmail), then symbiot watch add <screen id>, or click Watch on it in the app."));
362
+ if (!st.watches.length) console.log(c.d("Not watching anything yet. Map a page (symbiot screens map gmail), then symbiot watch add <screen id>, or click Watch on it in the app. For GitHub: symbiot watch add github"));
1948
363
  for (const w of st.watches) console.log(`${w.id} ${w.name} ${c.d(`every ${w.every} min · ${w.checked ? "last read " + new Date(w.checked).toLocaleString() : "not read yet"}${w.error ? " · " + w.error : ""}`)}`);
1949
364
  const news = newsSince(hours);
1950
365
  if (st.watches.length) console.log("\n" + c.b(`${news.length} new in the last ${hours} hours`) + (news.length ? "" : c.d(" (the app reads each page every few minutes while it runs)")));
@@ -1954,6 +369,7 @@ async function cmdWatch() {
1954
369
  if (sub === "new") return out(newsSince(hours));
1955
370
  if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
1956
371
  if (sub === "remove") return out(removeWatch(String(a1 || "")));
372
+ if (sub === "brief") { if (a1 === "on" || a1 === "off") return out(setBrief(a1 === "on")); return out({ brief: watchState().brief, briefs: watchState().briefs }); }
1957
373
  if (sub === "check") {
1958
374
  // through the app when it runs: its hidden browser may be open, and one profile takes one browser
1959
375
  const ids = a1 ? [a1] : watchState().watches.map((w) => w.id), done = [];
@@ -1964,11 +380,15 @@ async function cmdWatch() {
1964
380
  symbiot watch the pages you watch, and what's new
1965
381
  symbiot watch add <screen id | site> [--every 5|15|30|60]
1966
382
  watch a mapped page (or a site) for new rows
383
+ symbiot watch add github your GitHub notifications: review requests,
384
+ failed CI runs (through gh when it's signed in)
1967
385
  symbiot watch new [--hours 24] what's new, newest first (JSON)
1968
386
  symbiot watch check [id] read them now (JSON)
387
+ symbiot watch brief [on|off] your AI says what needs you, and what can wait
1969
388
  symbiot watch remove <id> stop watching it
1970
389
  While the app runs it reads each page every few minutes in its hidden browser
1971
- (only reads: nothing is pressed or typed), and notifies you of what's new.`);
390
+ (only reads: nothing is pressed or typed), and notifies you of what's new.
391
+ Standup counts what's new since yesterday ("Waiting on you: 3 emails").`);
1972
392
  if (sub !== "help") process.exitCode = 1;
1973
393
  }
1974
394
 
@@ -1994,6 +414,7 @@ ${c.b("Experimental")}
1994
414
  symbiot screens map <site> map a web page's buttons in a hidden browser
1995
415
  (symbiot screens help for more)
1996
416
  symbiot watch add <screen id> keep track of a mapped page: what's new on it
417
+ symbiot watch add github ...or your GitHub notifications
1997
418
  (symbiot watch help for more)
1998
419
 
1999
420
  ${c.b("Options")}
@@ -2043,3 +464,4 @@ const isMain = (() => {
2043
464
  if (isMain) main();
2044
465
 
2045
466
  export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
467
+