symbiot 0.7.0 → 0.9.2

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 +370 -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,143 @@ 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 databaseId,conclusion,status 2>/dev/null`).trim();
497
+ if (!j) return null;
498
+ try {
499
+ const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
500
+ if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
501
+ if (r.conclusion === "success") return { level: "info", text: `CI on ${def}: passing` };
502
+ if (r.conclusion === "failure") {
503
+ // Deterministic "not running" signal: every job has 0 steps (never started).
504
+ let notRun = false, reason = "";
505
+ try {
506
+ const jobs = (JSON.parse(sh(`cd ${JSON.stringify(repo)} && gh run view ${r.databaseId} --json jobs 2>/dev/null`)).jobs) || [];
507
+ notRun = jobs.length > 0 && jobs.every((x) => ((x.steps || []).length === 0));
508
+ if (notRun && jobs[0] && jobs[0].databaseId) {
509
+ // Only on the not-running path: fetch the one job's annotation to quote
510
+ // GitHub's exact reason (billing / spending limit / runner / disabled).
511
+ const cru = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/actions/jobs/${jobs[0].databaseId} --jq .check_run_url 2>/dev/null`).trim();
512
+ const crid = (cru.match(/check-runs\/(\d+)/) || [])[1];
513
+ if (crid) { const m = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/check-runs/${crid}/annotations --jq '.[0].message' 2>/dev/null`).trim(); if (m) reason = m.slice(0, 240); }
514
+ }
515
+ } catch {}
516
+ if (notRun) return { level: "warn", text: reason ? `CI is NOT running on ${def}: "${reason}"` : `CI is NOT running on ${def} — jobs never started (reason unavailable; usually a billing/spending-limit stop), not failing tests` };
517
+ return { level: "warn", text: `CI's last run on ${def} failed (a real failure — jobs ran)` };
518
+ }
519
+ if (r.conclusion) return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
520
+ return null;
521
+ } catch { return null; }
522
+ }
523
+ // For merges that aren't on the default branch, tell "likely never landed"
524
+ // (added files missing from default) from "merged another way" (all present).
525
+ function mergedOffDefault(p, defRef) {
526
+ const raw = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%H|%s' 2>/dev/null`)
527
+ .split("\n").filter((l) => /merge pull request/i.test(l));
528
+ if (!raw.length) return [];
529
+ const defFiles = new Set(sh(`git -C ${JSON.stringify(p)} ls-tree -r --name-only ${defRef} 2>/dev/null`).split("\n").filter(Boolean));
530
+ const hits = [];
531
+ for (const line of raw.slice(0, 12)) {
532
+ const [sha, subj] = line.split("|");
533
+ const prNum = (subj.match(/#(\d+)/) || [])[1] || "?";
534
+ const parents = sh(`git -C ${JSON.stringify(p)} rev-list --parents -n1 ${sha} 2>/dev/null`).trim().split(/\s+/);
535
+ const head = parents[2]; // 2nd parent = the merged branch head
536
+ let added = 0, missing = 0;
537
+ if (head) {
538
+ const mb = sh(`git -C ${JSON.stringify(p)} merge-base ${head} ${defRef} 2>/dev/null`).trim();
539
+ const files = sh(`git -C ${JSON.stringify(p)} diff --name-status ${mb || defRef} ${head} 2>/dev/null`)
540
+ .split("\n").map((l) => l.trim()).filter((l) => /^A\b|^A\t/.test(l)).map((l) => l.split(/\s+/).pop());
541
+ added = files.length;
542
+ for (const f of files) if (f && !defFiles.has(f)) missing++;
543
+ }
544
+ hits.push({ prNum, sha: sha.slice(0, 9), added, missing });
545
+ }
546
+ return hits;
547
+ }
548
+ // Drift facts for ONE repo. Compares against origin/<default> when a remote
549
+ // exists, else the local default branch (so it also works on local-only repos).
550
+ function driftRepo(p, opts = {}) {
551
+ const name = p.split("/").pop();
552
+ const def = gitDefaultBranch(p), st = repoState(p), flags = [];
553
+ const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
554
+ const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
555
+ const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
556
+ const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
557
+ // Fetch only when asked, or for a deploy-configured repo (comparing a live
558
+ // deployed sha against a stale origin gives a confidently wrong answer).
559
+ if (originDef && (opts.fetch || cmd)) sh(`git -C ${JSON.stringify(p)} fetch -q origin 2>/dev/null`);
560
+ const fa = fetchAgeDays(p);
561
+ const stale = fa != null && fa > 1 ? ` (as of ${fa}d ago)` : "";
562
+
563
+ if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
564
+ else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
565
+ if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
566
+ const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
567
+ if (originDef) {
568
+ 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"));
569
+ if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
570
+ }
571
+ if (defRef) {
572
+ const hits = mergedOffDefault(p, defRef);
573
+ const gone = hits.filter((h) => h.added > 0 && h.missing > 0);
574
+ const other = hits.length - gone.length;
575
+ if (gone.length) flags.push({ level: "warn", text: `${gone.length} PR(s) merged off ${def} with added files MISSING from ${def} — likely never landed`, evidence: gone.slice(0, 5).map((h) => `#${h.prNum} (${h.missing}/${h.added} files missing)`).join(" | ") });
576
+ if (other > 0) flags.push({ level: "info", text: `${other} other PR merge(s) off ${def} (files present — probably re-done/squashed)` });
577
+ }
578
+ if (cmd && defRef) {
579
+ const sha = sh(cmd).trim().split(/\s+/)[0];
580
+ if (sha) {
581
+ const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
582
+ const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
583
+ const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
584
+ if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
585
+ else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
586
+ else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
587
+ }
588
+ }
589
+ if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
590
+ return { name, path: p, def, flags, fetchAgeDays: fa };
591
+ }
592
+ function computeDrift(opts = {}) {
593
+ const deploys = loadDeploys();
594
+ const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
595
+ return { repos, ci: !!opts.ci };
596
+ }
597
+ function cmdDrift() {
598
+ const d = computeDrift({ ci: has("ci"), fetch: has("fetch") });
599
+ const warn = d.repos.filter((r) => r.flags.some((f) => f.level === "warn"));
600
+ console.log(`\n${c.g("●")} ${c.b("Symbiot drift")} ${c.d("· " + d.repos.length + " repos · " + warn.length + " with risks")}\n`);
601
+ for (const r of d.repos) {
602
+ if (!r.flags.length) continue;
603
+ const risky = r.flags.some((f) => f.level === "warn");
604
+ 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" : ""))}`);
605
+ for (const f of r.flags) console.log(` ${f.level === "warn" ? c.y("⚠") : c.d("·")} ${f.text}${f.evidence ? c.d(" [" + f.evidence + "]") : ""}`);
606
+ console.log("");
607
+ }
608
+ const clean = d.repos.filter((r) => !r.flags.length).map((r) => r.name);
609
+ if (clean.length) console.log(c.d(`clean: ${clean.join(", ")}`));
610
+ 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"}`));
611
+ }
612
+
362
613
  // ---- work map: a node graph of your repos, languages, and tools -----------
363
614
  const EXT_LANG = {
364
615
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
@@ -373,20 +624,23 @@ const MANIFEST_TOOL = {
373
624
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
374
625
  };
375
626
  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
627
  const repos = [];
381
- for (const g of out.split("\n").filter(Boolean)) {
628
+ for (const g of findGitDirs(base, 120)) {
382
629
  const repo = g.replace(/\/\.git$/, "");
383
630
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
384
631
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
385
632
  }
386
- return repos.sort((a, b) => b.recency - a.recency).slice(0, 36);
633
+ // Group worktrees by shared git dir; keep only the freshest checkout of each.
634
+ const byCommon = {};
635
+ for (const r of repos) {
636
+ const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
637
+ const key = cd.startsWith("/") ? cd : join(r.path, cd);
638
+ if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
639
+ }
640
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 30);
387
641
  }
388
642
  function detectRepo(r) {
389
- const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -5000`).split("\n").filter(Boolean);
643
+ const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
390
644
  const count = {}; const tools = new Set();
391
645
  for (const f of files) {
392
646
  const base = f.split("/").pop();
@@ -452,26 +706,29 @@ async function repoReview(path) {
452
706
  if (!resolveProvider()) return { error: "not-connected" };
453
707
  if (!path) return { error: "no repo" };
454
708
  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();
709
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), shape = repoShape(path), rules = houseRules(path);
710
+ const commits = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
711
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
712
+ const footer = reportFooter(path, auth, state, rd);
461
713
  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.`;
714
+ `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[]}. ` +
715
+ `"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. ` +
716
+ `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. ` +
717
+ `Never propose building something that already appears in the structure. Never advise against anything the conventions call intentional. ` +
718
+ (state.stale ? `CRITICAL: the working tree is a STALE/old checkout, not new work — never suggest committing it (it would revert history). ` : ``);
466
719
  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.`;
720
+ `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n\n` +
721
+ (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") +
722
+ `Structure (top folders · file counts): ${shape.dirs.join(", ")}\nManifests: ${shape.manifests.join(", ") || "none"}\nDocs present: ${shape.docs.join(", ") || "none"}\n\n` +
723
+ `Recent commits (newest first):\n${commits || "(none)"}\n\n` +
724
+ (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
725
+ (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` : "") +
726
+ `Return the JSON.`;
470
727
  const raw = await write(system, prompt);
471
- if (!raw) return { text: "(couldn't reach the model)", ideas: [] };
728
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [], footer };
472
729
  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
730
+ 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 };
731
+ return { text: raw, ideas: [], footer };
475
732
  }
476
733
  function extractJson(s) {
477
734
  if (!s) return null;
@@ -485,17 +742,23 @@ async function repoSuggest(path) {
485
742
  if (!resolveProvider()) return { error: "not-connected" };
486
743
  if (!path) return { error: "no repo" };
487
744
  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();
745
+ const auth = authorship(path), state = repoState(path), rd = readmeInfo(path), rules = houseRules(path);
746
+ const recent = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
747
+ || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
490
748
  const open = openWork([{ path, name }]);
749
+ const footer = reportFooter(path, auth, state, rd);
491
750
  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.`;
751
+ `You are a pragmatic senior engineer advising on one project. Write exactly three short sections with headings: ` +
752
+ `"In flight" (what's clearly underway), "Next steps" (3-5 concrete actions), "Ideas" (2-3 that fit where this is heading). ` +
753
+ `Ground each point in the evidence; recent commits beat the README. Do not advise against the stated conventions. ` +
754
+ (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it (would revert history). ` : ``) +
755
+ `No filler, no preamble.`;
495
756
  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.`;
757
+ `Project: ${name} (branch ${state.branch}, ${auth.total} commits, ${auth.mineCount} yours${auth.filterDropped ? " — filter dropped" : ""}).\n\n` +
758
+ `Recent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\n` +
759
+ (rules ? `Conventions — do NOT advise against these:\n${rules}\n\n` : "") + `Give the advice.`;
497
760
  const text = await write(system, prompt);
498
- return { text: text || "(couldn't reach the model)" };
761
+ return { text: text || "(couldn't reach the model)", footer };
499
762
  }
500
763
 
501
764
  // Build a write-up for a command; returns { text, sub, error? } without printing.
@@ -511,7 +774,7 @@ async function produce(cmd) {
511
774
  `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
512
775
  `Be concise and concrete. No preamble.`;
513
776
  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` };
777
+ 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
778
  }
516
779
  const label = cmd === "standup" ? "standup" : "week";
517
780
  const days = cmd === "standup" ? 2 : SINCE_WEEK;
@@ -536,7 +799,7 @@ async function produce(cmd) {
536
799
  `Write the ${label === "standup" ? "standup" : "update"}.`;
537
800
 
538
801
  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}` };
802
+ 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
803
  }
541
804
 
542
805
  async function cmdRun(cmd) {
@@ -544,6 +807,7 @@ async function cmdRun(cmd) {
544
807
  const r = await produce(cmd);
545
808
  header(r.sub || cmd);
546
809
  if (r.text) console.log(r.text + "\n");
810
+ if (r.footer) console.log(c.d(r.footer) + "\n");
547
811
  }
548
812
 
549
813
  // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
@@ -694,6 +958,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
694
958
  .review h4{margin:0 0 8px;color:var(--green);font-size:11px;letter-spacing:.15em;text-transform:uppercase;font-weight:700}
695
959
  .review .body{color:var(--text);line-height:1.65;font-size:14px;white-space:pre-wrap}
696
960
  .review .rname{color:var(--bone);font-weight:600}
961
+ .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
962
  .ideas{margin-top:14px}
698
963
  .ideas h4{margin:0 0 6px;color:var(--amber);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700}
699
964
  .idea{display:flex;align-items:flex-start;gap:9px;padding:5px 0;font-size:13.5px;color:var(--text)}
@@ -704,6 +969,16 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
704
969
  .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
705
970
  .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
706
971
  .task .rm:hover{color:var(--amber)}
972
+ .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
973
+ .drift .dh{display:flex;gap:8px;align-items:center}
974
+ .drift .dn{color:var(--bone);font-weight:600}
975
+ .drift .dd{color:var(--faint);font-size:12px}
976
+ .drift ul{margin:8px 0 0;padding:0;list-style:none}
977
+ .drift li{padding:3px 0;font-size:13.5px}
978
+ .drift li.warn{color:var(--amber)}
979
+ .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
980
+ .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
981
+ .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
707
982
  .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
983
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
709
984
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -713,6 +988,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
713
988
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
714
989
  <div class="tabs">
715
990
  <button class="tab active" data-tab="map">Map</button>
991
+ <button class="tab" data-tab="drift">Drift</button>
716
992
  <button class="tab" data-tab="week">Week</button>
717
993
  <button class="tab" data-tab="standup">Standup</button>
718
994
  <button class="tab" data-tab="todo">Todo</button>
@@ -742,11 +1018,19 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
742
1018
  <button class="ghost hidden" id="copy">Copy</button>
743
1019
  <span class="muted">Reads your local git and writes it up.</span></div>
744
1020
  <div class="out muted" id="out">Nothing yet - hit the button.</div>
1021
+ <div class="rfoot" id="outfoot" style="display:none"></div>
745
1022
  </section>
746
1023
  <section id="panel-tasks" class="hidden">
747
1024
  <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button></div>
748
1025
  <div id="tasklist"></div>
749
1026
  </section>
1027
+ <section id="panel-drift" class="hidden">
1028
+ <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
1029
+ <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
1030
+ <label class="muted"><input type="checkbox" id="driftci"> check CI (needs gh)</label>
1031
+ <button class="ghost" id="driftrun">Rescan</button></div>
1032
+ <div id="driftout"></div>
1033
+ </section>
750
1034
  <section id="panel-settings" class="hidden">
751
1035
  <label>Which AI should Symbiot write with?</label>
752
1036
  <select id="provider">
@@ -776,23 +1060,25 @@ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-
776
1060
  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
1061
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
778
1062
  function $(id){return document.getElementById(id);}
779
- var current='map';var mapLoaded=false;
1063
+ var current='map';var mapLoaded=false;var driftLoaded=false;
780
1064
  var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
781
1065
  function tabs(){return document.querySelectorAll('.tab');}
782
1066
  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');
1067
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
784
1068
  $('panel-map').classList.toggle('hidden',!isMap);
785
1069
  $('panel-run').classList.toggle('hidden',!isRun);
786
1070
  $('panel-settings').classList.toggle('hidden',!isSet);
787
1071
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1072
+ $('panel-drift').classList.toggle('hidden',!isDrift);
788
1073
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
789
1074
  if(isMap&&!mapLoaded)loadMap();
790
- if(isTasks)loadTasks();}
1075
+ if(isTasks)loadTasks();
1076
+ if(isDrift&&!driftLoaded)loadDrift();}
791
1077
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
792
1078
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
793
1079
  $('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');});});
1080
+ 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;}
1081
+ $('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
1082
  $('copy').addEventListener('click',function(){navigator.clipboard.writeText($('out').textContent);$('copy').textContent='Copied';setTimeout(function(){$('copy').textContent='Copy';},1400);});
797
1083
  function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
798
1084
  $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
@@ -825,27 +1111,28 @@ function showDetail(d){var el=document.getElementById('detail');el.classList.rem
825
1111
  if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
826
1112
  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
1113
  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;}
1114
+ 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
1115
  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
1116
  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
1117
  var reviewCache={};
832
1118
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
833
- function reviewHtml(name,body,ideas,tasks){
1119
+ function reviewHtml(name,body,ideas,tasks,footer){
834
1120
  var h="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>"+esc(body)+"</div>";
835
1121
  if(ideas&&ideas.length){h+="<div class='ideas'><h4>Upgrade ideas &middot; tick to add to Tasks</h4>";
836
1122
  ideas.forEach(function(idea){var t=(tasks||[]).filter(function(x){return x.text===idea&&x.repo===name;})[0];var tid=t?t.id:"";
837
1123
  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
1124
  h+="</div>";}
1125
+ if(footer)h+="<div class='rfoot'>"+esc(footer)+"</div>";
839
1126
  return h;}
840
1127
  function wireIdeas(){document.querySelectorAll('.ideachk').forEach(function(cb){cb.addEventListener('change',function(){
841
1128
  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
1129
  else{var id=cb.getAttribute('data-tid');if(id){api('/api/tasks/remove',{id:id}).then(function(){cb.setAttribute('data-tid','');});}}});});}
843
1130
  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;}
1131
+ 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
1132
  el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project&hellip;</div>";
846
1133
  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();});}
1134
+ 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;}
1135
+ 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
1136
  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
1137
  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
1138
  el.innerHTML=h;
@@ -853,6 +1140,18 @@ el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data
853
1140
  row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
854
1141
  row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
855
1142
  function loadTasks(){api('/api/tasks').then(renderTasks);}
1143
+ function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1144
+ var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
1145
+ api('/api/drift?ci='+ci+'&fetch='+ft).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';});});
1146
+ var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos &middot; <b>"+risky.length+"</b> with risks</div>";
1147
+ repos.forEach(function(r){if(!r.flags.length)return;var warn=r.flags.some(function(f){return f.level==='warn';});
1148
+ 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>";
1149
+ 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>";});
1150
+ h+="</ul></div>";});
1151
+ var clean=repos.filter(function(r){return !r.flags.length;}).map(function(r){return r.name;});
1152
+ if(clean.length)h+="<div class='muted' style='margin-top:10px'>clean: "+esc(clean.join(", "))+"</div>";
1153
+ if(!repos.length)h+="<div class='muted'>No repos found under your home folder.</div>";
1154
+ out.innerHTML=h;});}
856
1155
  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
1156
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
858
1157
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
@@ -882,6 +1181,7 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
882
1181
  h+="</div>";out.innerHTML=h;});}
883
1182
  document.getElementById('remap').addEventListener('click',loadMap);
884
1183
  document.getElementById('recbtn').addEventListener('click',loadRec);
1184
+ document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
885
1185
  document.getElementById('addtask').addEventListener('click',addTaskUI);
886
1186
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
887
1187
  initGraphEvents();syncP();refresh();loadMap();
@@ -939,6 +1239,7 @@ async function cmdApp() {
939
1239
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
940
1240
  if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
941
1241
  if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
1242
+ if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
942
1243
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
943
1244
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
944
1245
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
@@ -968,6 +1269,7 @@ ${c.b("Usage")}
968
1269
  symbiot standup yesterday + today, for standup
969
1270
  symbiot todo what's still on your plate
970
1271
  symbiot app open the visual app in your browser
1272
+ symbiot drift what's out of sync / at risk across repos
971
1273
  symbiot models recommend AI models for your hardware
972
1274
  symbiot login connect it to an AI (once)
973
1275
  symbiot whoami show how it's connected
@@ -991,15 +1293,23 @@ ${c.d("commit summaries sent to the AI to write your update (nothing leaves at a
991
1293
  ${c.d("with a local Ollama model).")}`;
992
1294
 
993
1295
  // ---- main -----------------------------------------------------------------
994
- (async () => {
1296
+ async function main() {
995
1297
  if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
996
1298
  if (cmd === "login" || cmd === "auth") return cmdLogin();
997
1299
  if (cmd === "logout") return cmdLogout();
998
1300
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
999
1301
  if (cmd === "app" || cmd === "ui") return cmdApp();
1000
1302
  if (cmd === "models" || cmd === "hardware") return cmdModels();
1303
+ if (cmd === "drift") return cmdDrift();
1001
1304
  if (cmd === "week") return cmdRun("week");
1002
1305
  if (cmd === "standup") return cmdRun("standup");
1003
1306
  if (cmd === "todo") return cmdRun("todo");
1004
1307
  console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
1005
- })();
1308
+ }
1309
+
1310
+ // Run the CLI only when invoked directly; when imported (e.g. by tests) just
1311
+ // expose the pure functions.
1312
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || "\0none").href;
1313
+ if (isMain) main();
1314
+
1315
+ 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.2",
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
  }