symbiot 0.6.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +37 -3
  2. package/index.mjs +490 -51
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -12,6 +12,7 @@ symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
14
  symbiot app open the visual app in your browser
15
+ symbiot models recommend AI models for your hardware
15
16
  symbiot login connect it to an AI (once)
16
17
  symbiot whoami show how it's connected
17
18
  symbiot help
@@ -51,6 +52,35 @@ where no Chromium-family browser is found it opens your default browser instead.
51
52
  npm install -g symbiot # or run without installing: npx symbiot week
52
53
  ```
53
54
 
55
+ ## What's out of sync? `symbiot drift`
56
+
57
+ ```bash
58
+ symbiot drift # add --ci to also check GitHub Actions state (needs gh)
59
+ ```
60
+
61
+ A **deterministic** report of what's out of sync, stuck, or at risk across your
62
+ repos — computed from git facts, each line citing the fact behind it (no model
63
+ guessing). It flags: **stale checkouts**, **behind upstream**, **multiple
64
+ worktrees**, **branches with work not on the default**, **PR merges that landed
65
+ off the default branch** (the "merged but main didn't move" trap), and — if you
66
+ configure it — **production running code that isn't on your default branch**.
67
+
68
+ For the production check, add a per-repo deploy command to
69
+ `~/.config/symbiot/deploys.json` (read only from your own config, never from a
70
+ repo), e.g. `{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also a
71
+ **Drift** tab in the app.
72
+
73
+ ## Which model? Ask your machine
74
+
75
+ ```bash
76
+ symbiot models
77
+ ```
78
+
79
+ Reads your RAM / CPU / GPU and recommends **local models by tier** (min / med / max,
80
+ marking which fit your RAM) to run free & private via [Ollama](https://ollama.com),
81
+ plus **paid** options (Claude / OpenAI / Gemini, cheap → top). Also available as
82
+ a button in the app's Settings.
83
+
54
84
  ## Connect it (once)
55
85
 
56
86
  Pick the AI you want it to write with:
@@ -93,9 +123,13 @@ Point it somewhere specific with `--dir ~/work`.
93
123
 
94
124
  - **Reads:** your local git — commit messages and changed-file names, plus
95
125
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
96
- - **Sends to the AI:** only those commit/TODO summaries, so it can write the
97
- update. Not your code, not file contents. With a **local Ollama model,
98
- nothing leaves your machine at all.**
126
+ - **Sends to the AI:** commit messages and dates, changed-file **names**, the
127
+ folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
128
+ the README and any `CLAUDE.md`/`AGENTS.md` conventions. It does **not** send
129
+ whole source files. With a **local Ollama model, nothing leaves your machine.**
130
+ - **Shows its work:** every report ends with a footer — path, branch, how many
131
+ commits matched you (e.g. "1090 of 1101"), the README's age, and the
132
+ working-tree state — so you can see exactly what it read.
99
133
  - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
100
134
 
101
135
  ## Config
package/index.mjs CHANGED
@@ -19,12 +19,16 @@
19
19
 
20
20
  import Anthropic from "@anthropic-ai/sdk";
21
21
  import { execSync, spawn } from "node:child_process";
22
- import { homedir } from "node:os";
22
+ import { homedir, totalmem, cpus as oscpus } from "node:os";
23
23
  import { join } from "node:path";
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync } from "node:fs";
25
25
  import { createInterface } from "node:readline";
26
26
  import { createServer } from "node:http";
27
27
  import { randomBytes } from "node:crypto";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ const HERE = fileURLToPath(new URL(".", import.meta.url));
31
+ let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
28
32
 
29
33
  const MAX_REPOS = 14;
30
34
  const MAX_COMMITS = 140;
@@ -86,6 +90,19 @@ function saveConfig(cfg) {
86
90
  function antProfileExists() {
87
91
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
88
92
  }
93
+ // ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
94
+ const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
95
+ function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
96
+ function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
97
+ function addTask(text, repo) {
98
+ text = String(text || "").trim().slice(0, 300);
99
+ if (!text) return { error: "empty" };
100
+ const t = loadTasks();
101
+ const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
102
+ t.unshift(item); saveTasks(t); return item;
103
+ }
104
+ 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" }; }
105
+ function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
89
106
  function envKey(provider) {
90
107
  for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
91
108
  return null;
@@ -131,19 +148,128 @@ function ask(question, { secret = false } = {}) {
131
148
 
132
149
  // ---- git ------------------------------------------------------------------
133
150
  function sh(cmd) {
134
- try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }); }
135
- catch { return ""; }
151
+ try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
152
+ catch { return ""; } // timeout or error -> empty, never hang the scan
153
+ }
154
+ // find .git dirs quickly by PRUNING heavy trees (node_modules etc.) instead of
155
+ // crawling into them — this is the big speedup for the map scan.
156
+ function findGitDirs(base, limit) {
157
+ return sh(
158
+ `find ${JSON.stringify(base)} -maxdepth 6 ` +
159
+ `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .git-crypt \\) -prune ` +
160
+ `-o -name .git -print 2>/dev/null | head -${limit}`,
161
+ ).split("\n").filter(Boolean);
136
162
  }
137
163
  function me() {
138
164
  return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
139
165
  }
166
+
167
+ // ---- accurate repo signals (the field-report fixes) -----------------------
168
+ // All author identities in this repo that plausibly belong to the current user,
169
+ // with a fallback when the filter would keep almost nothing of an active repo.
170
+ function authorship(repoPath) {
171
+ const gName = sh("git config --global user.name 2>/dev/null").trim();
172
+ const gEmail = sh("git config --global user.email 2>/dev/null").trim();
173
+ const rEmail = sh(`git -C ${JSON.stringify(repoPath)} config user.email 2>/dev/null`).trim();
174
+ const rName = sh(`git -C ${JSON.stringify(repoPath)} config user.name 2>/dev/null`).trim();
175
+ const total = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD 2>/dev/null`).trim()) || 0;
176
+ const rows = sh(`git -C ${JSON.stringify(repoPath)} log --format='%ae|%an' 2>/dev/null | sort | uniq -c | sort -rn | head -60`)
177
+ .split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
178
+ const m = l.match(/^(\d+)\s+(.*)$/); if (!m) return null;
179
+ const parts = m[2].split("|"); return { count: Number(m[1]), email: parts[0], name: parts.slice(1).join("|") };
180
+ }).filter(Boolean);
181
+ const myEmails = new Set([rEmail, gEmail].filter(Boolean));
182
+ const myNames = new Set([gName, rName].filter(Boolean).map((n) => n.toLowerCase()));
183
+ const nameKeys = new Set([...myNames].map((n) => n.replace(/\s+/g, "")));
184
+ const mine = new Set();
185
+ for (const r of rows) {
186
+ const nore = r.email.match(/^\d+\+(.+)@users\.noreply\.github\.com$/i);
187
+ const isMine = (r.email && myEmails.has(r.email))
188
+ || (r.name && myNames.has(r.name.toLowerCase()))
189
+ || (nore && nameKeys.has(nore[1].toLowerCase().replace(/\s+/g, "")));
190
+ if (isMine) mine.add(r.email);
191
+ }
192
+ if (rEmail) mine.add(rEmail);
193
+ let mineCount = rows.filter((r) => mine.has(r.email)).reduce((s, r) => s + r.count, 0);
194
+ const filterDropped = total >= 20 && mineCount / Math.max(total, 1) < 0.10;
195
+ const emails = filterDropped ? rows.map((r) => r.email) : [...mine];
196
+ return { emails, mineCount, total, filterDropped, names: [...new Set(rows.filter((r) => mine.has(r.email)).map((r) => r.email))] };
197
+ }
198
+ function authorArgs(emails) { return (emails || []).filter(Boolean).map((e) => `--author=${JSON.stringify(e)}`).join(" "); }
199
+
200
+ // Working-tree state, incl. detecting a stale/old checkout (not new work).
201
+ function repoState(repoPath) {
202
+ const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
203
+ const porcelain = sh(`git -C ${JSON.stringify(repoPath)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
204
+ let del = 0, mod = 0, add = 0;
205
+ for (const l of porcelain) { const x = l.slice(0, 2); if (/\?\?/.test(x)) add++; else if (x.includes("D")) del++; else if (x.includes("A")) add++; else mod++; }
206
+ const dirty = porcelain.length;
207
+ let stale = false, staleBy = 0;
208
+ if (dirty) {
209
+ for (const k of [3, 5, 10, 20, 40, 80, 160, 320]) {
210
+ if (!sh(`git -C ${JSON.stringify(repoPath)} rev-parse HEAD~${k} 2>/dev/null`).trim()) break;
211
+ if (sh(`git -C ${JSON.stringify(repoPath)} diff --quiet HEAD~${k} 2>/dev/null && echo EQ`).trim() === "EQ") { stale = true; staleBy = k; break; }
212
+ }
213
+ if (!stale && del >= 20 && del > mod && add === 0) stale = true; // mostly deletions = an old snapshot
214
+ }
215
+ const behind = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD..@{u} 2>/dev/null`).trim()) || 0;
216
+ return { branch, dirty, del, mod, add, stale, staleBy, behind };
217
+ }
218
+ function readmeInfo(repoPath) {
219
+ for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
220
+ try {
221
+ const p = join(repoPath, f);
222
+ if (existsSync(p)) {
223
+ const hash = sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%H -- ${JSON.stringify(f)} 2>/dev/null`).trim();
224
+ return {
225
+ file: f, excerpt: readFileSync(p, "utf8").slice(0, 2500),
226
+ lastDate: sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%cd --date=short -- ${JSON.stringify(f)} 2>/dev/null`).trim(),
227
+ commitsAgo: hash ? (Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count ${hash}..HEAD 2>/dev/null`).trim()) || 0) : 0,
228
+ };
229
+ }
230
+ } catch {}
231
+ }
232
+ return { file: null, excerpt: "", lastDate: "", commitsAgo: 0 };
233
+ }
234
+ function repoShape(repoPath) {
235
+ const files = sh(`git -C ${JSON.stringify(repoPath)} ls-files 2>/dev/null | head -4000`).split("\n").filter(Boolean);
236
+ const top = {}; const docs = []; const manifests = [];
237
+ for (const f of files) {
238
+ const seg = f.includes("/") ? f.split("/")[0] : "(root)";
239
+ top[seg] = (top[seg] || 0) + 1;
240
+ const base = f.split("/").pop();
241
+ if (MANIFEST_TOOL[base]) manifests.push(f);
242
+ if (/^(readme|contributing|claude|agents|changelog|architecture|design)/i.test(base) || /(^|\/)(docs|knowledge)\//i.test(f)) docs.push(f);
243
+ }
244
+ return {
245
+ total: files.length,
246
+ dirs: Object.entries(top).sort((a, b) => b[1] - a[1]).slice(0, 14).map(([d, n]) => `${d}/ (${n})`),
247
+ manifests: manifests.slice(0, 12), docs: [...new Set(docs)].slice(0, 16),
248
+ };
249
+ }
250
+ // House rules the model must respect (e.g. "we commit .env on purpose").
251
+ function houseRules(repoPath) {
252
+ const out = [];
253
+ for (const [label, f] of [["Project rules", "CLAUDE.md"], ["Agent rules", "AGENTS.md"], ["Contributing", "CONTRIBUTING.md"]]) {
254
+ try { const p = join(repoPath, f); if (existsSync(p)) out.push(`## ${label} (${f})\n` + readFileSync(p, "utf8").slice(0, 1500)); } catch {}
255
+ }
256
+ 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 {}
257
+ return out.join("\n\n");
258
+ }
259
+ // "Show what was read" footer for every report.
260
+ function reportFooter(repoPath, auth, state, rd) {
261
+ const b = [`symbiot ${VERSION}`, `path ${repoPath}`];
262
+ if (state.branch) b.push(`branch ${state.branch}`);
263
+ b.push(`${auth.mineCount} of ${auth.total} commits matched you${auth.filterDropped ? " — filter dropped, counting everyone" : ""}`);
264
+ if (rd && rd.lastDate) b.push(`README changed ${rd.lastDate}${rd.commitsAgo ? ` (${rd.commitsAgo} commits ago)` : ""}`);
265
+ if (state.stale) b.push(`⚠ STALE checkout: working tree ≈ ${state.staleBy ? "HEAD~" + state.staleBy : "an older commit"} — not new work`);
266
+ else if (state.dirty) b.push(`${state.dirty} uncommitted (${state.mod} mod / ${state.del} del / ${state.add} new)`);
267
+ if (state.behind) b.push(`${state.behind} behind upstream`);
268
+ return b.join(" · ");
269
+ }
140
270
  function findRepos(base, sinceDays) {
141
- const out = sh(
142
- `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
143
- `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -400`,
144
- );
145
271
  const repos = [];
146
- for (const g of out.split("\n").filter(Boolean)) {
272
+ for (const g of findGitDirs(base, 200)) {
147
273
  const repo = g.replace(/\/\.git$/, "");
148
274
  const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
149
275
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
@@ -155,10 +281,10 @@ function commits(repos, sinceExpr, mineOnly = true) {
155
281
  const all = [];
156
282
  const seen = new Set();
157
283
  for (const r of repos) {
158
- // Filter to the identity you actually commit under IN THIS repo (people use
159
- // different emails per project); no filter with --all.
160
- const email = mineOnly ? sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim() : "";
161
- const authorArg = email ? `--author=${JSON.stringify(email)}` : "";
284
+ // Match ALL of your identities in this repo (per-repo email, global email,
285
+ // GitHub noreply login, matching name), with a fallback when almost nothing
286
+ // matches an active repo. No filter with --all.
287
+ const authorArg = mineOnly ? authorArgs(authorship(r.path).emails) : "";
162
288
  const raw = sh(
163
289
  `git -C ${JSON.stringify(r.path)} log --since=${JSON.stringify(sinceExpr)} ${authorArg} ` +
164
290
  `--no-merges --date=short --name-only --pretty=format:'@@@%H|%ad|%s' -n 60`,
@@ -189,10 +315,11 @@ function openWork(repos) {
189
315
  const m = l.match(/^([^:]+):(\d+):(.*)$/);
190
316
  if (m) items.push(`${r.name}: ${m[3].replace(/^[\s/*#-]+/, "").trim().slice(0, 120)} (${m[1]}:${m[2]})`);
191
317
  }
192
- const dirty = sh(`git -C ${JSON.stringify(r.path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
193
- if (dirty.length) {
194
- const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
195
- items.push(`${r.name}: ${dirty.length} uncommitted file(s) on ${branch || "?"}`);
318
+ const st = repoState(r.path);
319
+ if (st.stale) {
320
+ 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)`);
321
+ } else if (st.dirty) {
322
+ items.push(`${r.name}: ${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new) on ${st.branch || "?"}`);
196
323
  }
197
324
  }
198
325
  return items;
@@ -300,6 +427,137 @@ function header(sub) {
300
427
  const SINCE_WEEK = Number(flag("since", "7"));
301
428
  const BASE = flag("dir", homedir());
302
429
 
430
+ // ---- hardware -> model recommendations ------------------------------------
431
+ function detectGpu() {
432
+ const nv = sh("nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null").trim();
433
+ 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" }; }
434
+ 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" }; }
435
+ 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" }; }
436
+ return null;
437
+ }
438
+ function detectHardware() {
439
+ const cpus = oscpus() || [];
440
+ 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() };
441
+ }
442
+ function recommendModels(hw) {
443
+ const ram = hw.ramGB || 8;
444
+ const local = [
445
+ { tier: "min", model: "llama3.2:3b", needGB: 6, note: "fast & light — fine for summaries" },
446
+ { tier: "med", model: "llama3.1:8b", needGB: 10, note: "solid all-rounder" },
447
+ { tier: "max", model: "qwen2.5:14b", needGB: 18, note: "stronger reasoning" },
448
+ ];
449
+ if (ram >= 40) local.push({ tier: "max+", model: "llama3.1:70b", needGB: 48, note: "top local quality — big machine/GPU" });
450
+ local.forEach((m) => { m.fits = ram >= m.needGB; });
451
+ const paid = [
452
+ { provider: "anthropic", tier: "cheap", model: "claude-haiku-4-5", note: "cheapest Claude" },
453
+ { provider: "anthropic", tier: "top", model: "claude-opus-5-5", note: "best Claude (default)" },
454
+ { provider: "openai", tier: "cheap", model: "gpt-4o-mini", note: "cheap OpenAI" },
455
+ { provider: "openai", tier: "top", model: "gpt-4o", note: "stronger OpenAI" },
456
+ { provider: "gemini", tier: "cheap", model: "gemini-1.5-flash", note: "cheap Google" },
457
+ { provider: "gemini", tier: "top", model: "gemini-1.5-pro", note: "stronger Google" },
458
+ ];
459
+ const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
460
+ return { local, paid, best };
461
+ }
462
+ function cmdModels() {
463
+ const hw = detectHardware(); const rec = recommendModels(hw);
464
+ const pad = (s, n) => String(s).padEnd(n);
465
+ console.log("\n" + c.b("Your machine"));
466
+ console.log(` ${c.b(hw.ramGB + " GB")} RAM · ${hw.cpuCount}-core ${hw.cpuModel} · ${hw.platform}/${hw.arch}`);
467
+ if (hw.gpu) console.log(` GPU: ${hw.gpu.name}${hw.gpu.vramGB ? ` (${hw.gpu.vramGB} GB VRAM)` : ""}`);
468
+ console.log("\n" + c.b("Local models") + c.d(" (free & private via Ollama — install: ollama pull <model>)"));
469
+ rec.local.forEach((m) => console.log(` ${m.fits ? c.g("✓") : c.d("·")} ${pad(m.tier, 5)} ${pad(m.model, 16)} ${c.d("~" + m.needGB + "GB " + m.note + (m.fits ? "" : " (needs more RAM)"))}`));
470
+ console.log(c.d(` Best fit for you: `) + c.b(rec.best) + c.d(` → ollama pull ${rec.best} then symbiot login (choose Ollama)`));
471
+ console.log("\n" + c.b("Paid models") + c.d(" (bring an API key — symbiot login)"));
472
+ rec.paid.forEach((p) => console.log(` ${pad(p.provider, 10)} ${pad(p.tier, 6)} ${pad(p.model, 20)} ${c.d(p.note)}`));
473
+ console.log("");
474
+ }
475
+
476
+ // ---- drift: what's out of sync / stuck / at risk (deterministic git facts) -
477
+ function gitDefaultBranch(repo) {
478
+ const d = sh(`git -C ${JSON.stringify(repo)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`).trim();
479
+ if (d) return d.replace(/^origin\//, "");
480
+ for (const b of ["main", "master", "develop"]) if (sh(`git -C ${JSON.stringify(repo)} rev-parse --verify -q refs/remotes/origin/${b} 2>/dev/null`).trim()) return b;
481
+ return sh(`git -C ${JSON.stringify(repo)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim() || "main";
482
+ }
483
+ function fetchAgeDays(repo) {
484
+ const gd = sh(`git -C ${JSON.stringify(repo)} rev-parse --git-common-dir 2>/dev/null`).trim();
485
+ if (!gd) return null;
486
+ const p = gd.startsWith("/") ? join(gd, "FETCH_HEAD") : join(repo, gd, "FETCH_HEAD");
487
+ try { return Math.floor((Date.now() - statSync(p).mtimeMs) / 86400000); } catch { return null; }
488
+ }
489
+ function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktree list 2>/dev/null`).split("\n").filter(Boolean).length; }
490
+ // Deployed-sha commands are read ONLY from the user's own config (never from a
491
+ // repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
492
+ function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
493
+ function ciState(repo, def) {
494
+ if (!sh("command -v gh 2>/dev/null").trim()) return null;
495
+ if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
496
+ const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json conclusion,status,createdAt 2>/dev/null`).trim();
497
+ if (!j) return null;
498
+ try { const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
499
+ if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
500
+ if (r.conclusion === "failure") return { level: "warn", text: `CI's last run on ${def} failed — could be a broken build or a stopped runner (check billing/the run)` };
501
+ if (r.conclusion && r.conclusion !== "success") return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
502
+ return { level: "info", text: `CI on ${def}: passing` };
503
+ } catch { return null; }
504
+ }
505
+ // Drift facts for ONE repo. Compares against origin/<default> when a remote
506
+ // exists, else the local default branch (so it also works on local-only repos).
507
+ function driftRepo(p, opts = {}) {
508
+ const name = p.split("/").pop();
509
+ const def = gitDefaultBranch(p), st = repoState(p), flags = [];
510
+ const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
511
+ const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
512
+ const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
513
+ if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
514
+ else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
515
+ if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}` });
516
+ const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
517
+ if (originDef) {
518
+ const unmerged = sh(`git -C ${JSON.stringify(p)} branch -r --no-merged origin/${def} 2>/dev/null`).split("\n").map((s) => s.trim()).filter((b) => b && !b.startsWith("origin/HEAD"));
519
+ if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
520
+ }
521
+ if (defRef) {
522
+ const merged = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%h %s' 2>/dev/null`).split("\n").filter((l) => /merge pull request/i.test(l));
523
+ if (merged.length) flags.push({ level: "warn", text: `${merged.length} PR merge(s) landed off ${def} (in a stacked branch, not the default)`, evidence: merged.slice(0, 3).join(" | ") });
524
+ }
525
+ const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
526
+ if (cmd && defRef) {
527
+ const sha = sh(cmd).trim().split(/\s+/)[0];
528
+ if (sha) {
529
+ const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
530
+ const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
531
+ const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
532
+ if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
533
+ else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
534
+ else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
535
+ }
536
+ }
537
+ if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
538
+ return { name, path: p, def, flags, fetchAgeDays: fetchAgeDays(p) };
539
+ }
540
+ function computeDrift(opts = {}) {
541
+ const deploys = loadDeploys();
542
+ const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci }));
543
+ return { repos, ci: !!opts.ci };
544
+ }
545
+ function cmdDrift() {
546
+ const d = computeDrift({ ci: has("ci") });
547
+ const warn = d.repos.filter((r) => r.flags.some((f) => f.level === "warn"));
548
+ console.log(`\n${c.g("●")} ${c.b("Symbiot drift")} ${c.d("· " + d.repos.length + " repos · " + warn.length + " with risks")}\n`);
549
+ for (const r of d.repos) {
550
+ if (!r.flags.length) continue;
551
+ const risky = r.flags.some((f) => f.level === "warn");
552
+ console.log(`${risky ? c.y("●") : c.d("○")} ${c.b(r.name)} ${c.d(r.def + (r.fetchAgeDays != null && r.fetchAgeDays > 3 ? " · fetch " + r.fetchAgeDays + "d old" : ""))}`);
553
+ for (const f of r.flags) console.log(` ${f.level === "warn" ? c.y("⚠") : c.d("·")} ${f.text}${f.evidence ? c.d(" [" + f.evidence + "]") : ""}`);
554
+ console.log("");
555
+ }
556
+ const clean = d.repos.filter((r) => !r.flags.length).map((r) => r.name);
557
+ if (clean.length) console.log(c.d(`clean: ${clean.join(", ")}`));
558
+ 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"}`));
559
+ }
560
+
303
561
  // ---- work map: a node graph of your repos, languages, and tools -----------
304
562
  const EXT_LANG = {
305
563
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
@@ -314,20 +572,23 @@ const MANIFEST_TOOL = {
314
572
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
315
573
  };
316
574
  function findAllRepos(base) {
317
- const out = sh(
318
- `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
319
- `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -300`,
320
- );
321
575
  const repos = [];
322
- for (const g of out.split("\n").filter(Boolean)) {
576
+ for (const g of findGitDirs(base, 120)) {
323
577
  const repo = g.replace(/\/\.git$/, "");
324
578
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
325
579
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
326
580
  }
327
- return repos.sort((a, b) => b.recency - a.recency).slice(0, 36);
581
+ // Group worktrees by shared git dir; keep only the freshest checkout of each.
582
+ const byCommon = {};
583
+ for (const r of repos) {
584
+ const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
585
+ const key = cd.startsWith("/") ? cd : join(r.path, cd);
586
+ if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
587
+ }
588
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 30);
328
589
  }
329
590
  function detectRepo(r) {
330
- const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -5000`).split("\n").filter(Boolean);
591
+ const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
331
592
  const count = {}; const tools = new Set();
332
593
  for (const f of files) {
333
594
  const base = f.split("/").pop();
@@ -338,7 +599,9 @@ function detectRepo(r) {
338
599
  const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
339
600
  const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
340
601
  const mine = Number(sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --oneline 2>/dev/null | wc -l`).trim()) || 0;
341
- return { ...r, langs, tools: [...tools], mine };
602
+ const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
603
+ const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
604
+ return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
342
605
  }
343
606
  function buildMap() {
344
607
  const who = me();
@@ -349,7 +612,7 @@ function buildMap() {
349
612
  for (const r of repos) {
350
613
  const rid = "repo:" + r.path;
351
614
  add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
352
- meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools } });
615
+ 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 } });
353
616
  edges.push({ source: "me", target: rid });
354
617
  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 }); }
355
618
  for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
@@ -359,6 +622,8 @@ function buildMap() {
359
622
  languages: nodes.filter((n) => n.type === "lang").length,
360
623
  tools: nodes.filter((n) => n.type === "tool").length,
361
624
  commits: repos.reduce((s, r) => s + r.mine, 0),
625
+ files: repos.reduce((s, r) => s + (r.files || 0), 0),
626
+ base: BASE,
362
627
  } };
363
628
  LAST_MAP = out;
364
629
  return out;
@@ -384,22 +649,64 @@ function nodeDetail(id) {
384
649
  }
385
650
  return { error: "unknown node" };
386
651
  }
652
+ // AI review of one repo: what it does, what it's for, possible upgrades.
653
+ async function repoReview(path) {
654
+ if (!resolveProvider()) return { error: "not-connected" };
655
+ if (!path) return { error: "no repo" };
656
+ const name = path.split("/").pop();
657
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), shape = repoShape(path), rules = houseRules(path);
658
+ const commits = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
659
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
660
+ const footer = reportFooter(path, auth, state, rd);
661
+ const system =
662
+ `You are reviewing one software project for its owner. Respond with ONLY a JSON object (no fences, no prose outside it): {"review": string, "ideas": string[]}. ` +
663
+ `"review": 3-6 sentences on what it does and who it's for. "ideas": 3-6 short, concrete upgrade items, each a single actionable phrase. ` +
664
+ `Ground every claim in the evidence below and tie it to real files/commits. Recent commits are MORE current than the README — when they disagree, trust the commits. ` +
665
+ `Never propose building something that already appears in the structure. Never advise against anything the conventions call intentional. ` +
666
+ (state.stale ? `CRITICAL: the working tree is a STALE/old checkout, not new work — never suggest committing it (it would revert history). ` : ``);
667
+ const prompt =
668
+ `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n\n` +
669
+ (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") +
670
+ `Structure (top folders · file counts): ${shape.dirs.join(", ")}\nManifests: ${shape.manifests.join(", ") || "none"}\nDocs present: ${shape.docs.join(", ") || "none"}\n\n` +
671
+ `Recent commits (newest first):\n${commits || "(none)"}\n\n` +
672
+ (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
673
+ (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` : "") +
674
+ `Return the JSON.`;
675
+ const raw = await write(system, prompt);
676
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [], footer };
677
+ const j = extractJson(raw);
678
+ if (j && (j.review || j.ideas)) return { text: String(j.review || "").trim() || raw, ideas: Array.isArray(j.ideas) ? j.ideas.map((x) => String(x).trim()).filter(Boolean).slice(0, 8) : [], footer };
679
+ return { text: raw, ideas: [], footer };
680
+ }
681
+ function extractJson(s) {
682
+ if (!s) return null;
683
+ let t = String(s).trim().replace(/^```(?:json)?/i, "").replace(/```$/,"").trim();
684
+ const a = t.indexOf("{"), b = t.lastIndexOf("}");
685
+ if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
686
+ return null;
687
+ }
387
688
  // AI "value" suggestions for one repo, from its recent commits + open work.
388
689
  async function repoSuggest(path) {
389
690
  if (!resolveProvider()) return { error: "not-connected" };
390
691
  if (!path) return { error: "no repo" };
391
692
  const name = path.split("/").pop();
392
- const email = sh(`git -C ${JSON.stringify(path)} config user.email`).trim();
393
- const recent = sh(`git -C ${JSON.stringify(path)} log --oneline -30 ${email ? `--author=${JSON.stringify(email)}` : ""} 2>/dev/null`).trim();
693
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), rules = houseRules(path);
694
+ const recent = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
695
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
394
696
  const open = openWork([{ path, name }]);
697
+ const footer = reportFooter(path, auth, state, rd);
395
698
  const system =
396
- `You are a pragmatic senior engineer advising on one project. From its recent commits and open items, write exactly three short sections with these headings: ` +
397
- `"In flight" (what's clearly underway), "Next steps" (3-5 concrete, specific actions), and "Ideas" (2-3 that fit where this build is heading). ` +
398
- `Be specific to the actual work shown — no filler, no preamble, no restating the commits verbatim.`;
699
+ `You are a pragmatic senior engineer advising on one project. Write exactly three short sections with headings: ` +
700
+ `"In flight" (what's clearly underway), "Next steps" (3-5 concrete actions), "Ideas" (2-3 that fit where this is heading). ` +
701
+ `Ground each point in the evidence; recent commits beat the README. Do not advise against the stated conventions. ` +
702
+ (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it (would revert history). ` : ``) +
703
+ `No filler, no preamble.`;
399
704
  const prompt =
400
- `Project: ${name}.\n\nRecent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\nGive the advice.`;
705
+ `Project: ${name} (branch ${state.branch}, ${auth.total} commits, ${auth.mineCount} yours${auth.filterDropped ? " — filter dropped" : ""}).\n\n` +
706
+ `Recent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\n` +
707
+ (rules ? `Conventions — do NOT advise against these:\n${rules}\n\n` : "") + `Give the advice.`;
401
708
  const text = await write(system, prompt);
402
- return { text: text || "(couldn't reach the model)" };
709
+ return { text: text || "(couldn't reach the model)", footer };
403
710
  }
404
711
 
405
712
  // Build a write-up for a command; returns { text, sub, error? } without printing.
@@ -415,7 +722,7 @@ async function produce(cmd) {
415
722
  `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
416
723
  `Be concise and concrete. No preamble.`;
417
724
  const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
418
- return { text: text || "(couldn't reach the model)", sub: `${open.length} open items · todo` };
725
+ 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` };
419
726
  }
420
727
  const label = cmd === "standup" ? "standup" : "week";
421
728
  const days = cmd === "standup" ? 2 : SINCE_WEEK;
@@ -440,7 +747,7 @@ async function produce(cmd) {
440
747
  `Write the ${label === "standup" ? "standup" : "update"}.`;
441
748
 
442
749
  const text = await write(system, prompt);
443
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}` };
750
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${cs.length} commits (yours) in last ${days}d` };
444
751
  }
445
752
 
446
753
  async function cmdRun(cmd) {
@@ -448,6 +755,7 @@ async function cmdRun(cmd) {
448
755
  const r = await produce(cmd);
449
756
  header(r.sub || cmd);
450
757
  if (r.text) console.log(r.text + "\n");
758
+ if (r.footer) console.log(c.d(r.footer) + "\n");
451
759
  }
452
760
 
453
761
  // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
@@ -592,6 +900,33 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
592
900
  .detail ul{margin:6px 0 0;padding-left:18px}.detail li{margin:2px 0}
593
901
  .detail .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
594
902
  .tag{display:inline-flex;font-size:11px;padding:4px 9px;border-radius:999px;background:var(--green-dim);border:1px solid #2a6b52;color:var(--green)}
903
+ .mapbar{margin-top:8px;font-size:12.5px;color:var(--text);background:var(--ink3);border:1px solid var(--line);border-radius:9px;padding:9px 12px;min-height:18px;font-family:ui-monospace,Menlo,Consolas,monospace}
904
+ .mapbar b{color:var(--bone)}
905
+ .review{margin-top:12px;background:var(--ink2);border:1px solid var(--line);border-radius:12px;padding:16px;min-height:120px}
906
+ .review h4{margin:0 0 8px;color:var(--green);font-size:11px;letter-spacing:.15em;text-transform:uppercase;font-weight:700}
907
+ .review .body{color:var(--text);line-height:1.65;font-size:14px;white-space:pre-wrap}
908
+ .review .rname{color:var(--bone);font-weight:600}
909
+ .rfoot{margin-top:12px;font-size:11px;color:var(--faint);font-family:ui-monospace,Menlo,Consolas,monospace;border-top:1px solid var(--line);padding-top:8px;line-height:1.5}
910
+ .ideas{margin-top:14px}
911
+ .ideas h4{margin:0 0 6px;color:var(--amber);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700}
912
+ .idea{display:flex;align-items:flex-start;gap:9px;padding:5px 0;font-size:13.5px;color:var(--text)}
913
+ .idea input{margin-top:3px;flex:none;width:15px;height:15px;cursor:pointer}
914
+ .task{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--line);border-radius:9px;margin-top:8px;background:var(--ink2)}
915
+ .task input[type=checkbox]{width:16px;height:16px;flex:none;cursor:pointer}
916
+ .task .t{flex:1}.task.done .t{color:var(--faint);text-decoration:line-through}
917
+ .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
918
+ .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
919
+ .task .rm:hover{color:var(--amber)}
920
+ .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
921
+ .drift .dh{display:flex;gap:8px;align-items:center}
922
+ .drift .dn{color:var(--bone);font-weight:600}
923
+ .drift .dd{color:var(--faint);font-size:12px}
924
+ .drift ul{margin:8px 0 0;padding:0;list-style:none}
925
+ .drift li{padding:3px 0;font-size:13.5px}
926
+ .drift li.warn{color:var(--amber)}
927
+ .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
928
+ .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
929
+ .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
595
930
  .out2{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:9px;padding:10px;margin-top:10px;font-size:13px;line-height:1.55;color:var(--bone)}
596
931
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
597
932
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -601,9 +936,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
601
936
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
602
937
  <div class="tabs">
603
938
  <button class="tab active" data-tab="map">Map</button>
939
+ <button class="tab" data-tab="drift">Drift</button>
604
940
  <button class="tab" data-tab="week">Week</button>
605
941
  <button class="tab" data-tab="standup">Standup</button>
606
942
  <button class="tab" data-tab="todo">Todo</button>
943
+ <button class="tab" data-tab="tasks">Tasks</button>
607
944
  <button class="tab" data-tab="settings">Settings</button>
608
945
  </div>
609
946
  <main>
@@ -613,6 +950,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
613
950
  <svg id="graph" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg>
614
951
  <aside id="detail" class="detail hidden"></aside>
615
952
  </div>
953
+ <div id="mapbar" class="mapbar">Hover a node for a summary &middot; click for details &middot; drag to move</div>
616
954
  <div class="legend">
617
955
  <span class="lg"><i style="background:var(--green)"></i>you</span>
618
956
  <span class="lg"><i style="background:var(--bone)"></i>repos</span>
@@ -621,12 +959,24 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
621
959
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
622
960
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
623
961
  </div>
962
+ <div id="review" class="review hidden"></div>
624
963
  </section>
625
964
  <section id="panel-run" class="hidden">
626
965
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
627
966
  <button class="ghost hidden" id="copy">Copy</button>
628
967
  <span class="muted">Reads your local git and writes it up.</span></div>
629
968
  <div class="out muted" id="out">Nothing yet - hit the button.</div>
969
+ <div class="rfoot" id="outfoot" style="display:none"></div>
970
+ </section>
971
+ <section id="panel-tasks" class="hidden">
972
+ <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button></div>
973
+ <div id="tasklist"></div>
974
+ </section>
975
+ <section id="panel-drift" class="hidden">
976
+ <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
977
+ <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftci"> check CI (needs gh)</label>
978
+ <button class="ghost" id="driftrun">Rescan</button></div>
979
+ <div id="driftout"></div>
630
980
  </section>
631
981
  <section id="panel-settings" class="hidden">
632
982
  <label>Which AI should Symbiot write with?</label>
@@ -644,6 +994,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
644
994
  <input id="model" type="text" placeholder="(blank = default)">
645
995
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
646
996
  <span class="note" id="saveMsg"></span></div>
997
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
998
+ <button class="ghost" id="recbtn">Recommend models for my machine</button>
999
+ <div id="recout" style="margin-top:12px"></div>
1000
+ </div>
647
1001
  </section>
648
1002
  </main>
649
1003
  <footer><button class="ghost" id="quit" style="margin-left:auto">Quit</button></footer>
@@ -653,21 +1007,25 @@ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-
653
1007
  var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
654
1008
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
655
1009
  function $(id){return document.getElementById(id);}
656
- var current='map';var mapLoaded=false;
1010
+ var current='map';var mapLoaded=false;var driftLoaded=false;
657
1011
  var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
658
1012
  function tabs(){return document.querySelectorAll('.tab');}
659
1013
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
660
- var isMap=tab==='map',isSet=tab==='settings',isRun=!isMap&&!isSet;
1014
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
661
1015
  $('panel-map').classList.toggle('hidden',!isMap);
662
1016
  $('panel-run').classList.toggle('hidden',!isRun);
663
1017
  $('panel-settings').classList.toggle('hidden',!isSet);
1018
+ $('panel-tasks').classList.toggle('hidden',!isTasks);
1019
+ $('panel-drift').classList.toggle('hidden',!isDrift);
664
1020
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
665
- if(isMap&&!mapLoaded)loadMap();}
1021
+ if(isMap&&!mapLoaded)loadMap();
1022
+ if(isTasks)loadTasks();
1023
+ if(isDrift&&!driftLoaded)loadDrift();}
666
1024
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
667
1025
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
668
1026
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
669
- api('/api/run',{cmd:current}).then(function(r){if(r.error==='not-connected'){$('out').textContent='Not connected yet - open Settings and pick an AI.';return;}
670
- $('out').textContent=r.text||'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');});});
1027
+ api('/api/run',{cmd:current}).then(function(r){var f=$('outfoot');if(r.error==='not-connected'){$('out').textContent='Not connected yet - open Settings and pick an AI.';f.style.display='none';return;}
1028
+ $('out').textContent=r.text||'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');if(r.footer){f.textContent=r.footer;f.style.display='block';}else{f.style.display='none';}});});
671
1029
  $('copy').addEventListener('click',function(){navigator.clipboard.writeText($('out').textContent);$('copy').textContent='Copied';setTimeout(function(){$('copy').textContent='Copy';},1400);});
672
1030
  function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
673
1031
  $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
@@ -691,25 +1049,88 @@ GRAPH.nodes.forEach(function(n){var r=Math.max(5,Math.sqrt(n.weight)*2);var col=
691
1049
  s+="<g class='node' data-id='"+esc(n.id)+"' opacity='"+op+"'><circle cx='"+n.x.toFixed(1)+"' cy='"+n.y.toFixed(1)+"' r='"+r.toFixed(1)+"' fill='"+col+"'"+st+"><title>"+title+"</title></circle>";
692
1050
  var cls=n.type==="person"?"lbl-me":"";s+="<text x='"+n.x.toFixed(1)+"' y='"+(n.y+r+12).toFixed(1)+"' text-anchor='middle' class='"+cls+"'>"+esc(n.label)+"</text></g>";});
693
1051
  document.getElementById("graph").innerHTML=s+"</g>";}
694
- function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});if(!repos.length)return "No git repositories found under your home folder yet.";var langs=g.nodes.filter(function(n){return n.type==="lang";}).map(function(n){return n.label;});var top=repos.slice().sort(function(a,b){return (b.meta.commits||0)-(a.meta.commits||0);})[0];var s="<b>"+g.stats.repos+"</b> repos &middot; <b>"+g.stats.commits+"</b> of your commits &middot; ";s+=langs.length?("mostly <b>"+esc(langs.slice(0,3).join(", "))+"</b>"):"no languages detected";if(top)s+=" &middot; most active: <b>"+esc(top.label)+"</b>";return s;}
1052
+ function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});var base=g.stats.base||"your home folder";if(!repos.length)return "No git repositories found under "+esc(base)+" yet.";var langs=g.nodes.filter(function(n){return n.type==="lang";}).map(function(n){return n.label;});var top=repos.slice().sort(function(a,b){return (b.meta.commits||0)-(a.meta.commits||0);})[0];var s="Scanned <b>"+esc(base)+"</b> &middot; <b>"+g.stats.repos+"</b> repos &middot; <b>"+g.stats.commits+"</b> of your commits &middot; <b>"+(g.stats.files||0)+"</b> files &middot; ";s+=langs.length?("mostly <b>"+esc(langs.slice(0,3).join(", "))+"</b>"):"no languages detected";if(top)s+=" &middot; most active: <b>"+esc(top.label)+"</b>";return s;}
1053
+ function nodeById(id){for(var i=0;i<GRAPH.nodes.length;i++)if(GRAPH.nodes[i].id===id)return GRAPH.nodes[i];return null;}
1054
+ function summarize(n){if(!n)return "";if(n.type==='repo'){var m=n.meta||{};var bits=[m.commits+' commits'];if(m.branch)bits.push('branch '+m.branch);if(m.last)bits.push('last '+m.last);if(typeof m.files==='number')bits.push(m.files+' files');if(m.langs&&m.langs.length)bits.push(m.langs.join(', '));if(m.tools&&m.tools.length)bits.push(m.tools.join(', '));return "<b>"+esc(n.label)+"</b> "+esc(m.path||'')+" — "+esc(bits.join(' · '));}if(n.type==='lang')return "<b>"+esc(n.label)+"</b> — language (click to see repos)";if(n.type==='tool')return "<b>"+esc(n.label)+"</b> — tool (click to see repos)";if(n.type==='person')return "<b>"+esc(n.label)+"</b> — you";return "<b>"+esc(n.label)+"</b>";}
1055
+ function updateBar(id){var bar=document.getElementById('mapbar');if(!bar)return;var n=id?nodeById(id):(sel?nodeById(sel):null);bar.innerHTML=n?summarize(n):"Hover a node for a summary &middot; click for details &middot; drag to move";}
695
1056
  function hideDetail(){document.getElementById('detail').classList.add('hidden');}
696
1057
  function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
697
1058
  if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
698
1059
  if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits',(d.dirty?d.dirty+' uncommitted':'clean'),(d.last?'last '+d.last:'')].concat(d.langs||[]).concat(d.tools||[]).forEach(function(x){if(x)chips+="<span class='tag'>"+esc(x)+"</span>";});
699
1060
  el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div><button class='act' id='suggest' style='margin-top:12px'>Suggest next steps</button><div id='sugout'></div>";
700
- document.getElementById('suggest').addEventListener('click',function(){var o=document.getElementById('sugout');o.innerHTML="<div class='out2'>Thinking...</div>";api('/api/suggest',{path:d.path}).then(function(r){if(r.error==='not-connected'){o.innerHTML="<div class='out2'>Connect a model in Settings to get suggestions - Ollama is free and runs locally.</div>";return;}o.innerHTML="<div class='out2'>"+esc(r.text||'(no output)')+"</div>";});});return;}
1061
+ document.getElementById('suggest').addEventListener('click',function(){var o=document.getElementById('sugout');o.innerHTML="<div class='out2'>Thinking...</div>";api('/api/suggest',{path:d.path}).then(function(r){if(r.error==='not-connected'){o.innerHTML="<div class='out2'>Connect a model in Settings to get suggestions - Ollama is free and runs locally.</div>";return;}o.innerHTML="<div class='out2'>"+esc(r.text||'(no output)')+"</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");});});return;}
701
1062
  if(d.type==='lang'||d.type==='tool'){var lis=(d.repos||[]).map(function(r){return "<li>"+esc(r)+"</li>";}).join("");el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Used in "+((d.repos||[]).length)+" repos</div><ul>"+lis+"</ul>";return;}
702
1063
  if(d.type==='person'){var st=d.stats||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+st.repos+" repos &middot; "+st.commits+" commits &middot; "+st.languages+" languages &middot; "+st.tools+" tools</div>";return;}}
703
- function selectNode(id){sel=id;render();api('/api/node?id='+encodeURIComponent(id)).then(showDetail);}
704
- function initGraphEvents(){var el=document.getElementById('graph');var drag=false,moved=0,sx=0,sy=0,ox=0,oy=0,downId=null;
1064
+ var reviewCache={};
1065
+ function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
1066
+ function reviewHtml(name,body,ideas,tasks,footer){
1067
+ var h="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>"+esc(body)+"</div>";
1068
+ if(ideas&&ideas.length){h+="<div class='ideas'><h4>Upgrade ideas &middot; tick to add to Tasks</h4>";
1069
+ ideas.forEach(function(idea){var t=(tasks||[]).filter(function(x){return x.text===idea&&x.repo===name;})[0];var tid=t?t.id:"";
1070
+ h+="<label class='idea'><input type='checkbox' class='ideachk' data-text=\""+esc(idea)+"\" data-repo=\""+esc(name)+"\" data-tid='"+tid+"'"+(t?" checked":"")+"><span>"+esc(idea)+"</span></label>";});
1071
+ h+="</div>";}
1072
+ if(footer)h+="<div class='rfoot'>"+esc(footer)+"</div>";
1073
+ return h;}
1074
+ function wireIdeas(){document.querySelectorAll('.ideachk').forEach(function(cb){cb.addEventListener('change',function(){
1075
+ if(cb.checked){api('/api/tasks/add',{text:cb.getAttribute('data-text'),repo:cb.getAttribute('data-repo')}).then(function(it){if(it&&it.id)cb.setAttribute('data-tid',it.id);});}
1076
+ else{var id=cb.getAttribute('data-tid');if(id){api('/api/tasks/remove',{id:id}).then(function(){cb.setAttribute('data-tid','');});}}});});}
1077
+ function loadReview(name,path){var el=document.getElementById('review');el.classList.remove('hidden');
1078
+ if(reviewCache[path]){var c=reviewCache[path];api('/api/tasks').then(function(tasks){el.innerHTML=reviewHtml(name,c.text,c.ideas,tasks,c.footer);wireIdeas();});return;}
1079
+ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project&hellip;</div>";
1080
+ Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1081
+ if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Connect a model in Settings to get a review - Ollama is free and runs locally.</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");return;}
1082
+ var body=r.text||'(no output)';reviewCache[path]={text:body,ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.ideas||[],tasks,r.footer);wireIdeas();});}
1083
+ function renderTasks(list){var el=document.getElementById('tasklist');if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No tasks yet. Tick an idea in a repo's review, or add one above.</div>";return;}
1084
+ var h="";list.forEach(function(t){h+="<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'><input type='checkbox' class='taskchk'"+(t.done?" checked":"")+"><span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+"<button class='rm' title='remove'>&times;</button></div>";});
1085
+ el.innerHTML=h;
1086
+ el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1087
+ row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1088
+ row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1089
+ function loadTasks(){api('/api/tasks').then(renderTasks);}
1090
+ function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1091
+ var ci=document.getElementById('driftci').checked?'1':'0';
1092
+ api('/api/drift?ci='+ci).then(function(d){driftLoaded=true;var repos=d.repos||[];var risky=repos.filter(function(r){return r.flags.some(function(f){return f.level==='warn';});});
1093
+ var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos &middot; <b>"+risky.length+"</b> with risks</div>";
1094
+ repos.forEach(function(r){if(!r.flags.length)return;var warn=r.flags.some(function(f){return f.level==='warn';});
1095
+ h+="<div class='drift'><div class='dh'><span class='"+(warn?'dot-w':'dot-c')+"'></span><span class='dn'>"+esc(r.name)+"</span><span class='dd'>"+esc(r.def)+(r.fetchAgeDays!=null&&r.fetchAgeDays>3?" &middot; fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
1096
+ r.flags.forEach(function(f){h+="<li class='"+esc(f.level)+"'>"+(f.level==='warn'?'&#9888; ':'&middot; ')+esc(f.text)+(f.evidence?" <span class='ev'>["+esc(f.evidence)+"]</span>":"")+"</li>";});
1097
+ h+="</ul></div>";});
1098
+ var clean=repos.filter(function(r){return !r.flags.length;}).map(function(r){return r.name;});
1099
+ if(clean.length)h+="<div class='muted' style='margin-top:10px'>clean: "+esc(clean.join(", "))+"</div>";
1100
+ if(!repos.length)h+="<div class='muted'>No repos found under your home folder.</div>";
1101
+ out.innerHTML=h;});}
1102
+ function addTaskUI(){var i=document.getElementById('newtask');var v=(i.value||'').trim();if(!v)return;api('/api/tasks/add',{text:v,repo:''}).then(function(){i.value='';loadTasks();});}
1103
+ function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1104
+ if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1105
+ function screenToGraph(el,ev){var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;return {x:(mx-view.x)/view.k,y:(my-view.y)/view.k};}
1106
+ function initGraphEvents(){var el=document.getElementById('graph');var mode=null,moved=0,sx=0,sy=0,ox=0,oy=0,downId=null,dnode=null;
705
1107
  function nodeAt(ev){var t=ev.target;var g=t&&t.closest?t.closest('.node'):null;return g?g.getAttribute('data-id'):null;}
706
1108
  el.addEventListener('wheel',function(ev){ev.preventDefault();if(!GRAPH)return;var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;var nk=Math.max(0.3,Math.min(4,view.k*(ev.deltaY<0?1.12:0.89)));view.x=mx-(mx-view.x)*(nk/view.k);view.y=my-(my-view.y)*(nk/view.k);view.k=nk;render();},{passive:false});
707
- el.addEventListener('pointerdown',function(ev){drag=true;moved=0;sx=ev.clientX;sy=ev.clientY;ox=view.x;oy=view.y;downId=nodeAt(ev);try{el.setPointerCapture(ev.pointerId);}catch(e){}});
708
- el.addEventListener('pointermove',function(ev){if(!drag)return;var dx=ev.clientX-sx,dy=ev.clientY-sy;moved+=Math.abs(dx)+Math.abs(dy);if(moved<4)return;var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW;view.y=oy+dy/rc.height*GH;render();});
709
- el.addEventListener('pointerup',function(ev){drag=false;if(moved<6){if(downId){selectNode(downId);}else{sel=null;hideDetail();render();}}downId=null;});
710
- el.addEventListener('click',function(ev){var id=nodeAt(ev);if(id)selectNode(id);}); }
711
- function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();api("/api/map").then(function(g){mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g);render();});}
1109
+ el.addEventListener('pointerdown',function(ev){if(!GRAPH)return;moved=0;sx=ev.clientX;sy=ev.clientY;ox=view.x;oy=view.y;downId=nodeAt(ev);if(downId){mode='node';dnode=nodeById(downId);}else{mode='pan';}try{el.setPointerCapture(ev.pointerId);}catch(e){}});
1110
+ el.addEventListener('pointermove',function(ev){
1111
+ if(!mode){updateBar(nodeAt(ev));return;}
1112
+ var dx=ev.clientX-sx,dy=ev.clientY-sy;moved+=Math.abs(dx)+Math.abs(dy);if(moved<4)return;
1113
+ if(mode==='node'&&dnode){var p=screenToGraph(el,ev);dnode.x=p.x;dnode.y=p.y;render();}
1114
+ else if(mode==='pan'){var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW;view.y=oy+dy/rc.height*GH;render();}});
1115
+ el.addEventListener('pointerup',function(ev){var id=downId,wasClick=moved<6;mode=null;dnode=null;downId=null;
1116
+ if(id){selectNode(id);}else if(wasClick){sel=null;hideDetail();hideReview();render();updateBar(null);}});
1117
+ el.addEventListener('mouseleave',function(){if(!mode)updateBar(null);}); }
1118
+ function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();api("/api/map").then(function(g){mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g);render();});}
1119
+ function fitBadge(m){return m.fits?"<span class='tag'>fits your RAM</span>":"<span class='tag' style='background:#3a2a12;border-color:#6b4a1f;color:#F2A541'>needs more RAM</span>";}
1120
+ function loadRec(){var out=document.getElementById('recout');out.innerHTML="<div class='muted'>Reading your hardware...</div>";
1121
+ api('/api/models').then(function(d){var hw=d.hardware,rec=d.rec;
1122
+ var h="<div class='k'><b>"+hw.ramGB+" GB</b> RAM &middot; "+hw.cpuCount+"-core &middot; "+esc(hw.platform)+"/"+esc(hw.arch)+(hw.gpu?" &middot; GPU: "+esc(hw.gpu.name)+(hw.gpu.vramGB?" ("+hw.gpu.vramGB+"GB)":""):"")+"</div>";
1123
+ h+="<div class='ideas'><h4>Local models &middot; free via Ollama</h4>";
1124
+ rec.local.forEach(function(m){h+="<div class='idea' style='justify-content:space-between'><span><b>"+esc(m.tier)+"</b> &middot; <code>ollama pull "+esc(m.model)+"</code> <span class='muted'>~"+m.needGB+"GB &middot; "+esc(m.note)+"</span></span> "+fitBadge(m)+"</div>";});
1125
+ h+="<div class='muted' style='margin-top:6px'>Best fit: <b>"+esc(rec.best)+"</b> &mdash; pull it, then pick <b>Local model (Ollama)</b> above.</div></div>";
1126
+ h+="<div class='ideas'><h4>Paid models &middot; bring an API key</h4>";
1127
+ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"</b> &middot; "+esc(p.tier)+" &middot; <code>"+esc(p.model)+"</code> <span class='muted'>&middot; "+esc(p.note)+"</span></span></div>";});
1128
+ h+="</div>";out.innerHTML=h;});}
712
1129
  document.getElementById('remap').addEventListener('click',loadMap);
1130
+ document.getElementById('recbtn').addEventListener('click',loadRec);
1131
+ document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1132
+ document.getElementById('addtask').addEventListener('click',addTaskUI);
1133
+ document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
713
1134
  initGraphEvents();syncP();refresh();loadMap();
714
1135
  </script></body></html>`;
715
1136
 
@@ -764,8 +1185,15 @@ async function cmdApp() {
764
1185
  try {
765
1186
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
766
1187
  if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
1188
+ if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
1189
+ if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1" }));
767
1190
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
768
1191
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1192
+ if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1193
+ if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks());
1194
+ if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1195
+ if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1196
+ if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
769
1197
  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)); }
770
1198
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
771
1199
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -788,6 +1216,8 @@ ${c.b("Usage")}
788
1216
  symbiot standup yesterday + today, for standup
789
1217
  symbiot todo what's still on your plate
790
1218
  symbiot app open the visual app in your browser
1219
+ symbiot drift what's out of sync / at risk across repos
1220
+ symbiot models recommend AI models for your hardware
791
1221
  symbiot login connect it to an AI (once)
792
1222
  symbiot whoami show how it's connected
793
1223
  symbiot logout forget saved credentials
@@ -810,14 +1240,23 @@ ${c.d("commit summaries sent to the AI to write your update (nothing leaves at a
810
1240
  ${c.d("with a local Ollama model).")}`;
811
1241
 
812
1242
  // ---- main -----------------------------------------------------------------
813
- (async () => {
1243
+ async function main() {
814
1244
  if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
815
1245
  if (cmd === "login" || cmd === "auth") return cmdLogin();
816
1246
  if (cmd === "logout") return cmdLogout();
817
1247
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
818
1248
  if (cmd === "app" || cmd === "ui") return cmdApp();
1249
+ if (cmd === "models" || cmd === "hardware") return cmdModels();
1250
+ if (cmd === "drift") return cmdDrift();
819
1251
  if (cmd === "week") return cmdRun("week");
820
1252
  if (cmd === "standup") return cmdRun("standup");
821
1253
  if (cmd === "todo") return cmdRun("todo");
822
1254
  console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
823
- })();
1255
+ }
1256
+
1257
+ // Run the CLI only when invoked directly; when imported (e.g. by tests) just
1258
+ // expose the pure functions.
1259
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || "\0none").href;
1260
+ if (isMain) main();
1261
+
1262
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.6.1",
3
+ "version": "0.9.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,5 +40,8 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@anthropic-ai/sdk": "^0.128.0"
43
+ },
44
+ "scripts": {
45
+ "test": "node test/fixtures.mjs"
43
46
  }
44
47
  }