symbiot 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +76 -19
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -21,7 +21,7 @@ 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, statSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync } from "node:fs";
25
25
  import { createInterface } from "node:readline";
26
26
  import { createServer } from "node:http";
27
27
  import { randomBytes } from "node:crypto";
@@ -493,15 +493,58 @@ function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "
493
493
  function ciState(repo, def) {
494
494
  if (!sh("command -v gh 2>/dev/null").trim()) return null;
495
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();
496
+ const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
497
497
  if (!j) return null;
498
- try { const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
498
+ try {
499
+ const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
499
500
  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` };
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;
503
521
  } catch { return null; }
504
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
+ }
505
548
  // Drift facts for ONE repo. Compares against origin/<default> when a remote
506
549
  // exists, else the local default branch (so it also works on local-only repos).
507
550
  function driftRepo(p, opts = {}) {
@@ -510,19 +553,28 @@ function driftRepo(p, opts = {}) {
510
553
  const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
511
554
  const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
512
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
+
513
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` });
514
564
  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}` });
565
+ if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
516
566
  const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
517
567
  if (originDef) {
518
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"));
519
569
  if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
520
570
  }
521
571
  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(" | ") });
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)` });
524
577
  }
525
- const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
526
578
  if (cmd && defRef) {
527
579
  const sha = sh(cmd).trim().split(/\s+/)[0];
528
580
  if (sha) {
@@ -535,15 +587,15 @@ function driftRepo(p, opts = {}) {
535
587
  }
536
588
  }
537
589
  if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
538
- return { name, path: p, def, flags, fetchAgeDays: fetchAgeDays(p) };
590
+ return { name, path: p, def, flags, fetchAgeDays: fa };
539
591
  }
540
592
  function computeDrift(opts = {}) {
541
593
  const deploys = loadDeploys();
542
- const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci }));
594
+ const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
543
595
  return { repos, ci: !!opts.ci };
544
596
  }
545
597
  function cmdDrift() {
546
- const d = computeDrift({ ci: has("ci") });
598
+ const d = computeDrift({ ci: has("ci"), fetch: has("fetch") });
547
599
  const warn = d.repos.filter((r) => r.flags.some((f) => f.level === "warn"));
548
600
  console.log(`\n${c.g("●")} ${c.b("Symbiot drift")} ${c.d("· " + d.repos.length + " repos · " + warn.length + " with risks")}\n`);
549
601
  for (const r of d.repos) {
@@ -974,7 +1026,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
974
1026
  </section>
975
1027
  <section id="panel-drift" class="hidden">
976
1028
  <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>
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>
978
1031
  <button class="ghost" id="driftrun">Rescan</button></div>
979
1032
  <div id="driftout"></div>
980
1033
  </section>
@@ -1088,8 +1141,8 @@ row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tas
1088
1141
  row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1089
1142
  function loadTasks(){api('/api/tasks').then(renderTasks);}
1090
1143
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1091
- var ci=document.getElementById('driftci').checked?'1':'0';
1092
- api('/api/drift?ci='+ci).then(function(d){driftLoaded=true;var repos=d.repos||[];var risky=repos.filter(function(r){return r.flags.some(function(f){return f.level==='warn';});});
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';});});
1093
1146
  var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos &middot; <b>"+risky.length+"</b> with risks</div>";
1094
1147
  repos.forEach(function(r){if(!r.flags.length)return;var warn=r.flags.some(function(f){return f.level==='warn';});
1095
1148
  h+="<div class='drift'><div class='dh'><span class='"+(warn?'dot-w':'dot-c')+"'></span><span class='dn'>"+esc(r.name)+"</span><span class='dd'>"+esc(r.def)+(r.fetchAgeDays!=null&&r.fetchAgeDays>3?" &middot; fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
@@ -1186,7 +1239,7 @@ async function cmdApp() {
1186
1239
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
1187
1240
  if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
1188
1241
  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" }));
1242
+ if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
1190
1243
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1191
1244
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1192
1245
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
@@ -1255,8 +1308,12 @@ async function main() {
1255
1308
  }
1256
1309
 
1257
1310
  // 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;
1311
+ // expose the pure functions. Compare REAL paths so a global/npx bin symlink
1312
+ // (argv[1] is the symlink, import.meta.url is the real file) still counts.
1313
+ const isMain = (() => {
1314
+ try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
1315
+ catch { return false; }
1316
+ })();
1260
1317
  if (isMain) main();
1261
1318
 
1262
1319
  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.9.0",
3
+ "version": "0.9.3",
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": {