symbiot 0.7.0 → 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 +25 -3
  2. package/index.mjs +317 -60
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -52,6 +52,24 @@ where no Chromium-family browser is found it opens your default browser instead.
52
52
  npm install -g symbiot # or run without installing: npx symbiot week
53
53
  ```
54
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
+
55
73
  ## Which model? Ask your machine
56
74
 
57
75
  ```bash
@@ -105,9 +123,13 @@ Point it somewhere specific with `--dir ~/work`.
105
123
 
106
124
  - **Reads:** your local git — commit messages and changed-file names, plus
107
125
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
108
- - **Sends to the AI:** only those commit/TODO summaries, so it can write the
109
- update. Not your code, not file contents. With a **local Ollama model,
110
- 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.
111
133
  - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
112
134
 
113
135
  ## Config
package/index.mjs CHANGED
@@ -21,10 +21,14 @@ import Anthropic from "@anthropic-ai/sdk";
21
21
  import { execSync, spawn } from "node:child_process";
22
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;
@@ -144,19 +148,128 @@ function ask(question, { secret = false } = {}) {
144
148
 
145
149
  // ---- git ------------------------------------------------------------------
146
150
  function sh(cmd) {
147
- try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }); }
148
- 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);
149
162
  }
150
163
  function me() {
151
164
  return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
152
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
+ }
153
270
  function findRepos(base, sinceDays) {
154
- const out = sh(
155
- `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
156
- `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -400`,
157
- );
158
271
  const repos = [];
159
- for (const g of out.split("\n").filter(Boolean)) {
272
+ for (const g of findGitDirs(base, 200)) {
160
273
  const repo = g.replace(/\/\.git$/, "");
161
274
  const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
162
275
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
@@ -168,10 +281,10 @@ function commits(repos, sinceExpr, mineOnly = true) {
168
281
  const all = [];
169
282
  const seen = new Set();
170
283
  for (const r of repos) {
171
- // Filter to the identity you actually commit under IN THIS repo (people use
172
- // different emails per project); no filter with --all.
173
- const email = mineOnly ? sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim() : "";
174
- 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) : "";
175
288
  const raw = sh(
176
289
  `git -C ${JSON.stringify(r.path)} log --since=${JSON.stringify(sinceExpr)} ${authorArg} ` +
177
290
  `--no-merges --date=short --name-only --pretty=format:'@@@%H|%ad|%s' -n 60`,
@@ -202,10 +315,11 @@ function openWork(repos) {
202
315
  const m = l.match(/^([^:]+):(\d+):(.*)$/);
203
316
  if (m) items.push(`${r.name}: ${m[3].replace(/^[\s/*#-]+/, "").trim().slice(0, 120)} (${m[1]}:${m[2]})`);
204
317
  }
205
- const dirty = sh(`git -C ${JSON.stringify(r.path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
206
- if (dirty.length) {
207
- const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
208
- 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 || "?"}`);
209
323
  }
210
324
  }
211
325
  return items;
@@ -359,6 +473,91 @@ function cmdModels() {
359
473
  console.log("");
360
474
  }
361
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
+
362
561
  // ---- work map: a node graph of your repos, languages, and tools -----------
363
562
  const EXT_LANG = {
364
563
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
@@ -373,20 +572,23 @@ const MANIFEST_TOOL = {
373
572
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
374
573
  };
375
574
  function findAllRepos(base) {
376
- const out = sh(
377
- `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
378
- `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -300`,
379
- );
380
575
  const repos = [];
381
- for (const g of out.split("\n").filter(Boolean)) {
576
+ for (const g of findGitDirs(base, 120)) {
382
577
  const repo = g.replace(/\/\.git$/, "");
383
578
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
384
579
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
385
580
  }
386
- 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);
387
589
  }
388
590
  function detectRepo(r) {
389
- 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);
390
592
  const count = {}; const tools = new Set();
391
593
  for (const f of files) {
392
594
  const base = f.split("/").pop();
@@ -452,26 +654,29 @@ async function repoReview(path) {
452
654
  if (!resolveProvider()) return { error: "not-connected" };
453
655
  if (!path) return { error: "no repo" };
454
656
  const name = path.split("/").pop();
455
- let readme = "";
456
- for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
457
- try { const p = join(path, f); if (existsSync(p)) { readme = readFileSync(p, "utf8").slice(0, 2500); break; } } catch {}
458
- }
459
- const files = sh(`git -C ${JSON.stringify(path)} ls-files 2>/dev/null | head -60`).trim();
460
- const recent = sh(`git -C ${JSON.stringify(path)} log --oneline -12 2>/dev/null`).trim();
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);
461
661
  const system =
462
- `You are reviewing one software project for its owner. Respond with ONLY a JSON object, no markdown fences, no prose outside it, shaped exactly: ` +
463
- `{"review": string, "ideas": string[]}. "review" is 3-6 sentences covering what it does and what it's meant for (who'd use it, why). ` +
464
- `"ideas" is 3-6 short, concrete upgrade or next-feature items, each a single actionable phrase (something you could put on a to-do list). ` +
465
- `Be specific and grounded in the README, files, and commits — infer from real evidence, don't invent.`;
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). ` : ``);
466
667
  const prompt =
467
- `Project: ${name}\nPath: ${path}\n\n` +
468
- (readme ? `README (excerpt):\n${readme}\n\n` : "(no README found)\n\n") +
469
- `Files:\n${files || "(none)"}\n\nRecent commits:\n${recent || "(none)"}\n\nReturn the JSON.`;
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.`;
470
675
  const raw = await write(system, prompt);
471
- if (!raw) return { text: "(couldn't reach the model)", ideas: [] };
676
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [], footer };
472
677
  const j = extractJson(raw);
473
- 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) : [] };
474
- return { text: raw, ideas: [] }; // model didn't return clean JSON — show prose, no checkboxes
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 };
475
680
  }
476
681
  function extractJson(s) {
477
682
  if (!s) return null;
@@ -485,17 +690,23 @@ async function repoSuggest(path) {
485
690
  if (!resolveProvider()) return { error: "not-connected" };
486
691
  if (!path) return { error: "no repo" };
487
692
  const name = path.split("/").pop();
488
- const email = sh(`git -C ${JSON.stringify(path)} config user.email`).trim();
489
- 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();
490
696
  const open = openWork([{ path, name }]);
697
+ const footer = reportFooter(path, auth, state, rd);
491
698
  const system =
492
- `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: ` +
493
- `"In flight" (what's clearly underway), "Next steps" (3-5 concrete, specific actions), and "Ideas" (2-3 that fit where this build is heading). ` +
494
- `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.`;
495
704
  const prompt =
496
- `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.`;
497
708
  const text = await write(system, prompt);
498
- return { text: text || "(couldn't reach the model)" };
709
+ return { text: text || "(couldn't reach the model)", footer };
499
710
  }
500
711
 
501
712
  // Build a write-up for a command; returns { text, sub, error? } without printing.
@@ -511,7 +722,7 @@ async function produce(cmd) {
511
722
  `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
512
723
  `Be concise and concrete. No preamble.`;
513
724
  const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
514
- 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` };
515
726
  }
516
727
  const label = cmd === "standup" ? "standup" : "week";
517
728
  const days = cmd === "standup" ? 2 : SINCE_WEEK;
@@ -536,7 +747,7 @@ async function produce(cmd) {
536
747
  `Write the ${label === "standup" ? "standup" : "update"}.`;
537
748
 
538
749
  const text = await write(system, prompt);
539
- 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` };
540
751
  }
541
752
 
542
753
  async function cmdRun(cmd) {
@@ -544,6 +755,7 @@ async function cmdRun(cmd) {
544
755
  const r = await produce(cmd);
545
756
  header(r.sub || cmd);
546
757
  if (r.text) console.log(r.text + "\n");
758
+ if (r.footer) console.log(c.d(r.footer) + "\n");
547
759
  }
548
760
 
549
761
  // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
@@ -694,6 +906,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
694
906
  .review h4{margin:0 0 8px;color:var(--green);font-size:11px;letter-spacing:.15em;text-transform:uppercase;font-weight:700}
695
907
  .review .body{color:var(--text);line-height:1.65;font-size:14px;white-space:pre-wrap}
696
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}
697
910
  .ideas{margin-top:14px}
698
911
  .ideas h4{margin:0 0 6px;color:var(--amber);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700}
699
912
  .idea{display:flex;align-items:flex-start;gap:9px;padding:5px 0;font-size:13.5px;color:var(--text)}
@@ -704,6 +917,16 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
704
917
  .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
705
918
  .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
706
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}
707
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)}
708
931
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
709
932
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -713,6 +936,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
713
936
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
714
937
  <div class="tabs">
715
938
  <button class="tab active" data-tab="map">Map</button>
939
+ <button class="tab" data-tab="drift">Drift</button>
716
940
  <button class="tab" data-tab="week">Week</button>
717
941
  <button class="tab" data-tab="standup">Standup</button>
718
942
  <button class="tab" data-tab="todo">Todo</button>
@@ -742,11 +966,18 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
742
966
  <button class="ghost hidden" id="copy">Copy</button>
743
967
  <span class="muted">Reads your local git and writes it up.</span></div>
744
968
  <div class="out muted" id="out">Nothing yet - hit the button.</div>
969
+ <div class="rfoot" id="outfoot" style="display:none"></div>
745
970
  </section>
746
971
  <section id="panel-tasks" class="hidden">
747
972
  <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button></div>
748
973
  <div id="tasklist"></div>
749
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>
980
+ </section>
750
981
  <section id="panel-settings" class="hidden">
751
982
  <label>Which AI should Symbiot write with?</label>
752
983
  <select id="provider">
@@ -776,23 +1007,25 @@ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-
776
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'};
777
1008
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
778
1009
  function $(id){return document.getElementById(id);}
779
- var current='map';var mapLoaded=false;
1010
+ var current='map';var mapLoaded=false;var driftLoaded=false;
780
1011
  var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
781
1012
  function tabs(){return document.querySelectorAll('.tab');}
782
1013
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
783
- var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1014
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
784
1015
  $('panel-map').classList.toggle('hidden',!isMap);
785
1016
  $('panel-run').classList.toggle('hidden',!isRun);
786
1017
  $('panel-settings').classList.toggle('hidden',!isSet);
787
1018
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1019
+ $('panel-drift').classList.toggle('hidden',!isDrift);
788
1020
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
789
1021
  if(isMap&&!mapLoaded)loadMap();
790
- if(isTasks)loadTasks();}
1022
+ if(isTasks)loadTasks();
1023
+ if(isDrift&&!driftLoaded)loadDrift();}
791
1024
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
792
1025
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
793
1026
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
794
- 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;}
795
- $('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';}});});
796
1029
  $('copy').addEventListener('click',function(){navigator.clipboard.writeText($('out').textContent);$('copy').textContent='Copied';setTimeout(function(){$('copy').textContent='Copy';},1400);});
797
1030
  function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
798
1031
  $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
@@ -825,27 +1058,28 @@ function showDetail(d){var el=document.getElementById('detail');el.classList.rem
825
1058
  if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
826
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>";});
827
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>";
828
- 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;}
829
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;}
830
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;}}
831
1064
  var reviewCache={};
832
1065
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
833
- function reviewHtml(name,body,ideas,tasks){
1066
+ function reviewHtml(name,body,ideas,tasks,footer){
834
1067
  var h="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>"+esc(body)+"</div>";
835
1068
  if(ideas&&ideas.length){h+="<div class='ideas'><h4>Upgrade ideas &middot; tick to add to Tasks</h4>";
836
1069
  ideas.forEach(function(idea){var t=(tasks||[]).filter(function(x){return x.text===idea&&x.repo===name;})[0];var tid=t?t.id:"";
837
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>";});
838
1071
  h+="</div>";}
1072
+ if(footer)h+="<div class='rfoot'>"+esc(footer)+"</div>";
839
1073
  return h;}
840
1074
  function wireIdeas(){document.querySelectorAll('.ideachk').forEach(function(cb){cb.addEventListener('change',function(){
841
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);});}
842
1076
  else{var id=cb.getAttribute('data-tid');if(id){api('/api/tasks/remove',{id:id}).then(function(){cb.setAttribute('data-tid','');});}}});});}
843
1077
  function loadReview(name,path){var el=document.getElementById('review');el.classList.remove('hidden');
844
- if(reviewCache[path]){var c=reviewCache[path];api('/api/tasks').then(function(tasks){el.innerHTML=reviewHtml(name,c.text,c.ideas,tasks);wireIdeas();});return;}
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;}
845
1079
  el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project&hellip;</div>";
846
1080
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
847
- 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>";return;}
848
- var body=r.text||'(no output)';reviewCache[path]={text:body,ideas:r.ideas||[]};el.innerHTML=reviewHtml(name,body,r.ideas||[],tasks);wireIdeas();});}
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();});}
849
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;}
850
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>";});
851
1085
  el.innerHTML=h;
@@ -853,6 +1087,18 @@ el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data
853
1087
  row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
854
1088
  row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
855
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;});}
856
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();});}
857
1103
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
858
1104
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
@@ -882,6 +1128,7 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
882
1128
  h+="</div>";out.innerHTML=h;});}
883
1129
  document.getElementById('remap').addEventListener('click',loadMap);
884
1130
  document.getElementById('recbtn').addEventListener('click',loadRec);
1131
+ document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
885
1132
  document.getElementById('addtask').addEventListener('click',addTaskUI);
886
1133
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
887
1134
  initGraphEvents();syncP();refresh();loadMap();
@@ -939,6 +1186,7 @@ async function cmdApp() {
939
1186
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
940
1187
  if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
941
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" }));
942
1190
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
943
1191
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
944
1192
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
@@ -968,6 +1216,7 @@ ${c.b("Usage")}
968
1216
  symbiot standup yesterday + today, for standup
969
1217
  symbiot todo what's still on your plate
970
1218
  symbiot app open the visual app in your browser
1219
+ symbiot drift what's out of sync / at risk across repos
971
1220
  symbiot models recommend AI models for your hardware
972
1221
  symbiot login connect it to an AI (once)
973
1222
  symbiot whoami show how it's connected
@@ -991,15 +1240,23 @@ ${c.d("commit summaries sent to the AI to write your update (nothing leaves at a
991
1240
  ${c.d("with a local Ollama model).")}`;
992
1241
 
993
1242
  // ---- main -----------------------------------------------------------------
994
- (async () => {
1243
+ async function main() {
995
1244
  if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
996
1245
  if (cmd === "login" || cmd === "auth") return cmdLogin();
997
1246
  if (cmd === "logout") return cmdLogout();
998
1247
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
999
1248
  if (cmd === "app" || cmd === "ui") return cmdApp();
1000
1249
  if (cmd === "models" || cmd === "hardware") return cmdModels();
1250
+ if (cmd === "drift") return cmdDrift();
1001
1251
  if (cmd === "week") return cmdRun("week");
1002
1252
  if (cmd === "standup") return cmdRun("standup");
1003
1253
  if (cmd === "todo") return cmdRun("todo");
1004
1254
  console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
1005
- })();
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.7.0",
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
  }