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.
- package/README.md +25 -3
- package/index.mjs +317 -60
- 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:**
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
const authorArg =
|
|
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
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
463
|
-
`
|
|
464
|
-
`
|
|
465
|
-
`
|
|
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}\
|
|
468
|
-
(
|
|
469
|
-
`
|
|
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: [] };
|
|
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
|
|
489
|
-
const recent = sh(`git -C ${JSON.stringify(path)} log
|
|
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.
|
|
493
|
-
`"In flight" (what's clearly underway), "Next steps" (3-5 concrete
|
|
494
|
-
`
|
|
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}
|
|
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>—</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 · "+st.commits+" commits · "+st.languages+" languages · "+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 · <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 · 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 · <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project…</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 · <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 · <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'>×</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…</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 · <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?" · fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
|
|
1096
|
+
r.flags.forEach(function(f){h+="<li class='"+esc(f.level)+"'>"+(f.level==='warn'?'⚠ ':'· ')+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
|
-
|
|
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.
|
|
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
|
}
|