symbiot 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +85 -14
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -103,6 +103,20 @@ function addTask(text, repo) {
103
103
  }
104
104
  function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
105
105
  function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
106
+ // Deterministic task classification (no model) so tasks batch by kind instead
107
+ // of arriving as a flat, mixed pile. Order matters (most specific first).
108
+ const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
109
+ function taskType(text) {
110
+ const s = String(text || "").toLowerCase();
111
+ if (/\b(test|tests|spec|coverage|smoke|fixture|ci\b)/.test(s)) return "Tests & CI";
112
+ if (/\b(secur|auth|token|secret|vulnerab|permission|sanitiz|escap|injection)/.test(s)) return "Security";
113
+ if (/\b(readme|docs?|document|changelog|comment|guide)/.test(s)) return "Docs";
114
+ if (/\b(perf|performance|slow|optimi|latency|throughput|speed up|memory leak)/.test(s)) return "Performance";
115
+ if (/\b(refactor|simplify|split|extract|clean ?up|consolidat|dedupe|modular|rename)/.test(s)) return "Refactor";
116
+ if (/\b(bug|fix|broken|error|crash|regress|hang|race|leak)/.test(s)) return "Fixes";
117
+ if (/\b(ui|ux|design|layout|style|button|screen|view\b|page\b|responsive)/.test(s)) return "UI/UX";
118
+ return "Features & other";
119
+ }
106
120
  // Push tasks into the repos as an agent-readable brief (.symbiot/TASKS.md):
107
121
  // a checklist plus the deterministic context an agent needs to get oriented.
108
122
  function buildTasksMd(name, ctx, list) {
@@ -115,8 +129,10 @@ function buildTasksMd(name, ctx, list) {
115
129
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
116
130
  if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
117
131
  L.push("", "## Tasks");
118
- for (const t of list) L.push(`- [ ] ${t.text}`);
119
- L.push("", "---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Confirm with me before anything destructive."');
132
+ const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
133
+ const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
134
+ for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
135
+ L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Confirm with me before anything destructive."');
120
136
  return L.join("\n") + "\n";
121
137
  }
122
138
  function pushTasks() {
@@ -147,17 +163,22 @@ function pushTasks() {
147
163
  const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
148
164
  const file = join(dir, "TASKS.md");
149
165
  writeFileSync(file, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
150
- written.push({ name, file, count: list.length });
166
+ written.push({ name, file, path, count: list.length });
151
167
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
152
168
  }
153
- return { empty: false, written, unresolved };
169
+ return { empty: false, written, unresolved, handoff: handoffCmd() };
154
170
  }
155
171
  function cmdPush() {
156
172
  const r = pushTasks();
157
173
  if (r.empty) { console.log(c.y("No open tasks to push.") + c.d(" Add some in `symbiot app` — a repo review's ideas, or the Tasks tab.")); return; }
158
174
  if (r.written.length) { console.log("\n" + c.g("●") + " " + c.b("Pushed tasks into repos:")); for (const w of r.written) console.log(` ${c.g("✓")} ${w.name} ${c.d(w.file + " (" + w.count + " task" + (w.count > 1 ? "s" : "") + ")")}`); }
159
175
  if (r.unresolved.length) { console.log("\n" + c.y(`Not written (repo not found under ${BASE}):`)); for (const u of r.unresolved) console.log(` · ${u.name} (${u.count})`); }
160
- console.log("\n" + c.d("Open each repo in your IDE/agent and point it at .symbiot/TASKS.md."));
176
+ if (has("open")) {
177
+ if (!r.handoff) console.log("\n" + c.y("No agent command set.") + c.d(" Set one in `symbiot app` Settings, or `agentCmd` in ~/.config/symbiot/config.json (use {dir} and {prompt})."));
178
+ else { for (const w of r.written) runHandoff(w.path); console.log("\n" + c.g("→ ") + `Handed ${r.written.length} repo(s) to your agent (${r.handoff}).`); }
179
+ } else {
180
+ console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
181
+ }
161
182
  }
162
183
  function envKey(provider) {
163
184
  for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
@@ -515,6 +536,31 @@ function recommendModels(hw) {
515
536
  const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
516
537
  return { local, paid, best };
517
538
  }
539
+ // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
540
+ // The handoff is a command TEMPLATE the user configures, with {dir} (repo path)
541
+ // and {prompt} (the task instruction). Works for any agent/editor:
542
+ // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
543
+ // gnome-terminal --working-directory={dir} -- claude "{prompt}"
544
+ // Not tied to any one tool — you decide what runs.
545
+ const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
546
+ const AGENT_LIST = [["claude", "Claude Code", 'claude -p "{prompt}"'], ["aider", "Aider", 'aider --message "{prompt}"'], ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"']];
547
+ function detectHandoffs() {
548
+ const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
549
+ const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
550
+ return { agents, editors };
551
+ }
552
+ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
553
+ const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
554
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
555
+ function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
556
+ function runHandoff(repoPath) {
557
+ try {
558
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return false;
559
+ const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
560
+ spawn(cmd, { shell: true, cwd: repoPath, detached: true, stdio: "ignore" }).unref();
561
+ return true;
562
+ } catch { return false; }
563
+ }
518
564
  function cmdModels() {
519
565
  const hw = detectHardware(); const rec = recommendModels(hw);
520
566
  const pad = (s, n) => String(s).padEnd(n);
@@ -1042,6 +1088,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1042
1088
  .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
1043
1089
  .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
1044
1090
  .task .rm:hover{color:var(--amber)}
1091
+ .tgroup{margin:14px 0 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--green);font-weight:700}
1092
+ .tcount{color:var(--faint);font-weight:400}
1045
1093
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1046
1094
  .drift .dh{display:flex;gap:8px;align-items:center}
1047
1095
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1122,6 +1170,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1122
1170
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1123
1171
  <span class="note" id="saveMsg"></span></div>
1124
1172
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1173
+ <label>Hand off to your agent when you "Send to repos"</label>
1174
+ <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
1175
+ <div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor &mdash; it's your command.</div>
1176
+ <div id="agentpresets" style="margin-top:8px"></div>
1177
+ </div>
1178
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1125
1179
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
1126
1180
  <div id="recout" style="margin-top:12px"></div>
1127
1181
  </div>
@@ -1210,12 +1264,16 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
1210
1264
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1211
1265
  if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Connect a model in Settings to get a review - Ollama is free and runs locally.</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");return;}
1212
1266
  var body=r.text||'(no output)';reviewCache[path]={text:body,verdict:r.verdict||"",ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.verdict||"",r.ideas||[],tasks,r.footer);wireIdeas();});}
1213
- 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;}
1214
- var h="";list.forEach(function(t){h+="<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'><input type='checkbox' class='taskchk'"+(t.done?" checked":"")+"><span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+"<button class='rm' title='remove'>&times;</button></div>";});
1215
- el.innerHTML=h;
1216
- el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1267
+ function taskRow(t){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'><input type='checkbox' class='taskchk'"+(t.done?" checked":"")+"><span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+"<button class='rm' title='remove'>&times;</button></div>";}
1268
+ function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1217
1269
  row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1218
1270
  row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1271
+ 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;}
1272
+ var order=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1273
+ var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
1274
+ var keys=Object.keys(groups).sort(function(a,b){var ia=order.indexOf(a),ib=order.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
1275
+ var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t);});});
1276
+ el.innerHTML=h;wireTaskRows(el);}
1219
1277
  function loadTasks(){api('/api/tasks').then(renderTasks);}
1220
1278
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1221
1279
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -1237,8 +1295,17 @@ if(r.empty){o.innerHTML="<div class='muted' style='margin-top:10px'>No open task
1237
1295
  var n=(r.written||[]).length;var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
1238
1296
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span></li>";});h+="</ul>";}
1239
1297
  if(r.unresolved&&r.unresolved.length){h+="<div class='dd' style='margin-top:6px'>&#9888; not written (repo not found under your home folder): "+esc(r.unresolved.map(function(u){return u.name;}).join(", "))+"</div>";}
1240
- h+="<div class='dd' style='margin-top:8px'>Now tell your coding agent, in each repo: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div></div>";
1298
+ if(r.handoff&&r.written&&r.written.length){r.written.forEach(function(w){api('/api/open',{path:w.path});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handing "+n+" repo(s) to your agent (<b>"+esc(r.handoff)+"</b>)&hellip;</div>";}
1299
+ else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send. For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
1300
+ h+="</div>";
1241
1301
  o.innerHTML=h;}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
1302
+ function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
1303
+ var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
1304
+ var box=document.getElementById('agentpresets');
1305
+ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
1306
+ box.innerHTML="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:</span><br>"+chips.map(function(c,i){return "<button class='ghost preset' data-i='"+i+"' style='padding:4px 10px;font-size:12px;margin:5px 5px 0 0'>"+esc(c.label)+"</button>";}).join("");
1307
+ box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1308
+ function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
1242
1309
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1243
1310
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1244
1311
  function screenToGraph(el,ev){var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;return {x:(mx-view.x)/view.k,y:(my-view.y)/view.k};}
@@ -1270,8 +1337,9 @@ document.getElementById('recbtn').addEventListener('click',loadRec);
1270
1337
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1271
1338
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1272
1339
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1340
+ document.getElementById('agentcmd').addEventListener('change',saveAgent);
1273
1341
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1274
- initGraphEvents();syncP();refresh();loadMap();
1342
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1275
1343
  </script></body></html>`;
1276
1344
 
1277
1345
  function readBody(req) {
@@ -1330,11 +1398,14 @@ async function cmdApp() {
1330
1398
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1331
1399
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1332
1400
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1333
- if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks());
1401
+ if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks().map((t) => ({ ...t, type: taskType(t.text) })));
1334
1402
  if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1335
1403
  if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1336
1404
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1337
1405
  if (u.pathname === "/api/tasks/push" && req.method === "POST") return json(res, pushTasks());
1406
+ if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1407
+ if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); const v = String(b.cmd || "").trim(); if (v) cfg.agentCmd = v; else delete cfg.agentCmd; delete cfg.ide; saveConfig(cfg); return json(res, { ok: true, cmd: cfg.agentCmd || "" }); }
1408
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); return json(res, { opened: runHandoff(String(b.path || "")) }); }
1338
1409
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
1339
1410
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1340
1411
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -1358,7 +1429,7 @@ ${c.b("Usage")}
1358
1429
  symbiot todo what's still on your plate
1359
1430
  symbiot app open the visual app in your browser
1360
1431
  symbiot drift what's out of sync / at risk across repos
1361
- symbiot push write your tasks into each repo for your agent
1432
+ symbiot push [--open] write tasks into each repo (and open your IDE)
1362
1433
  symbiot models recommend AI models for your hardware
1363
1434
  symbiot login connect it to an AI (once)
1364
1435
  symbiot whoami show how it's connected
@@ -1406,4 +1477,4 @@ const isMain = (() => {
1406
1477
  })();
1407
1478
  if (isMain) main();
1408
1479
 
1409
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, EMBEDDED_UI };
1480
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {