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.
- package/README.md +25 -3
- package/index.mjs +370 -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,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
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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();
|
|
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
|
|
463
|
-
`
|
|
464
|
-
`
|
|
465
|
-
`
|
|
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}\
|
|
468
|
-
(
|
|
469
|
-
`
|
|
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: [] };
|
|
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
|
|
489
|
-
const recent = sh(`git -C ${JSON.stringify(path)} log
|
|
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.
|
|
493
|
-
`"In flight" (what's clearly underway), "Next steps" (3-5 concrete
|
|
494
|
-
`
|
|
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}
|
|
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>—</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 · "+st.commits+" commits · "+st.languages+" languages · "+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 · <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 · 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 · <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project…</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 · <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 · <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'>×</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…</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 · <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?" · fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
|
|
1149
|
+
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>";});
|
|
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
|
-
|
|
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.
|
|
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
|
}
|