symbiot 0.10.3 → 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 +118 -28
  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);
@@ -766,25 +812,41 @@ async function repoReview(path) {
766
812
  const commits = sh(`git -C ${JSON.stringify(path)} log ${authorArgs(auth.emails)} --format='%ad %s' --date=short -50 2>/dev/null`).trim()
767
813
  || sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -50 2>/dev/null`).trim();
768
814
  const footer = reportFooter(path, auth, state, rd);
815
+ // Cheap maturity gauge, so the model can be told to show restraint instead of
816
+ // always inventing new features (over-engineering is a failure, not a win).
817
+ const testFiles = Number(sh(`git -C ${JSON.stringify(path)} ls-files 2>/dev/null | grep -icE '(^|/)(tests?|spec|__tests__)/|\\.(test|spec)\\.'`).trim()) || 0;
818
+ const churn = Number(sh(`git -C ${JSON.stringify(path)} log --since='14 days ago' --oneline 2>/dev/null | wc -l`).trim()) || 0;
819
+ const posture = state.stale ? "the checkout is a stale snapshot — the priority is getting current, not new work"
820
+ : (testFiles === 0 && shape.total > 40) ? "a real codebase with NO test files — favour tests/stabilising over new features"
821
+ : (churn >= 25) ? "very rapid churn lately — likely over-iterating; be conservative, prefer consolidation/finishing over new features"
822
+ : (state.dirty > 20) ? "a lot of unfinished/uncommitted work — finish what's open before starting new things"
823
+ : "no red flags — but still only propose what clearly pays off";
769
824
  const system =
770
- `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[]}. ` +
771
- `"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. ` +
772
- `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. ` +
773
- `Never propose building something that already appears in the structure. Never advise against anything the conventions call intentional. ` +
774
- (state.stale ? `CRITICAL: the working tree is a STALE/old checkout, not new work — never suggest committing it (it would revert history). ` : ``);
825
+ `You are a pragmatic, restraint-minded reviewer of ONE software project. Respond with ONLY a JSON object (no fences): {"review": string, "verdict": string, "ideas": string[]}. ` +
826
+ `"review": 3-6 sentences on what it does and who it's for. ` +
827
+ `"verdict": ONE honest sentence on whether this project needs new work now, and of what KIND. Over-engineering is a failure: if it's already capable, or shows churn / missing tests / unfinished work, say so and steer toward STABILISING (tests, docs, finishing, removing) or simply SHIPPING — not more features. ` +
828
+ `"ideas": AT MOST 5, ranked by value, ONLY items that clearly pay off. Do NOT pad the list — returning 0–2 ideas with a "stabilise, don't add" verdict is a good, correct answer. Prefer fixes / tests / simplification / finishing over new features unless a feature is clearly warranted by the evidence. ` +
829
+ `Ground every claim in the evidence; recent commits beat the README; never propose building something already in the structure; never advise against the stated conventions. ` +
830
+ (state.stale ? `CRITICAL: the working tree is a STALE checkout — never suggest committing it. ` : ``);
775
831
  const prompt =
776
- `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n\n` +
832
+ `Project: ${name}\nBranch ${state.branch} · ${auth.total} commits total (${auth.mineCount} yours${auth.filterDropped ? ", filter dropped so counting everyone" : ""})\n` +
833
+ `Maturity signal: ${posture}. (${testFiles} test files, ${churn} commits in the last 14 days.)\n\n` +
777
834
  (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") +
778
835
  `Structure (top folders · file counts): ${shape.dirs.join(", ")}\nManifests: ${shape.manifests.join(", ") || "none"}\nDocs present: ${shape.docs.join(", ") || "none"}\n\n` +
779
836
  `Recent commits (newest first):\n${commits || "(none)"}\n\n` +
780
837
  (rules ? `Conventions this team has chosen — do NOT advise against these:\n${rules}\n\n` : "") +
781
838
  (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` : "") +
782
- `Return the JSON.`;
839
+ `Return the JSON. Remember: fewer, higher-value ideas beat a long list; a "don't add — stabilise" verdict with 0–2 ideas is a valid answer.`;
783
840
  const raw = await write(system, prompt);
784
- if (!raw) return { text: "(couldn't reach the model)", ideas: [], footer };
841
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [], verdict: "", footer };
785
842
  const j = extractJson(raw);
786
- 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 };
787
- return { text: raw, ideas: [], footer };
843
+ if (j && (j.review || j.ideas || j.verdict)) return {
844
+ text: String(j.review || "").trim() || raw,
845
+ verdict: String(j.verdict || "").trim(),
846
+ ideas: Array.isArray(j.ideas) ? j.ideas.map((x) => String(x).trim()).filter(Boolean).slice(0, 5) : [],
847
+ footer,
848
+ };
849
+ return { text: raw, ideas: [], verdict: "", footer };
788
850
  }
789
851
  function extractJson(s) {
790
852
  if (!s) return null;
@@ -1015,6 +1077,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1015
1077
  .review .body{color:var(--text);line-height:1.65;font-size:14px;white-space:pre-wrap}
1016
1078
  .review .rname{color:var(--bone);font-weight:600}
1017
1079
  .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}
1080
+ .verdict{margin-top:12px;padding:10px 12px;border-left:3px solid var(--amber);background:var(--ink3);border-radius:0 8px 8px 0;color:var(--bone);font-size:13.5px;line-height:1.5}
1018
1081
  .ideas{margin-top:14px}
1019
1082
  .ideas h4{margin:0 0 6px;color:var(--amber);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700}
1020
1083
  .idea{display:flex;align-items:flex-start;gap:9px;padding:5px 0;font-size:13.5px;color:var(--text)}
@@ -1025,6 +1088,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1025
1088
  .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
1026
1089
  .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
1027
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}
1028
1093
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1029
1094
  .drift .dh{display:flex;gap:8px;align-items:center}
1030
1095
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1105,6 +1170,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1105
1170
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1106
1171
  <span class="note" id="saveMsg"></span></div>
1107
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">
1108
1179
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
1109
1180
  <div id="recout" style="margin-top:12px"></div>
1110
1181
  </div>
@@ -1173,12 +1244,14 @@ if(d.type==='lang'||d.type==='tool'){var lis=(d.repos||[]).map(function(r){retur
1173
1244
  if(d.type==='person'){var st=d.stats||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+st.repos+" repos &middot; "+st.commits+" commits &middot; "+st.languages+" languages &middot; "+st.tools+" tools</div>";return;}}
1174
1245
  var reviewCache={};var IDEAS=[];var IREPO="";
1175
1246
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
1176
- function reviewHtml(name,body,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
1247
+ function reviewHtml(name,body,verdict,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
1177
1248
  var h="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>"+esc(body)+"</div>";
1178
- if(ideas&&ideas.length){h+="<div class='ideas'><h4>Upgrade ideas &middot; tick to add to Tasks</h4>";
1249
+ if(verdict)h+="<div class='verdict'>&#9878;&#65039; "+esc(verdict)+"</div>";
1250
+ if(ideas&&ideas.length){h+="<div class='ideas'><h4>Ideas worth doing &middot; tick to add to Tasks</h4>";
1179
1251
  ideas.forEach(function(idea,i){var t=(tasks||[]).filter(function(x){return x.text===idea&&x.repo===name;})[0];var tid=t?t.id:"";
1180
1252
  h+="<label class='idea'><input type='checkbox' class='ideachk' data-idx='"+i+"' data-tid='"+esc(tid)+"'"+(t?" checked":"")+"><span>"+esc(idea)+"</span></label>";});
1181
1253
  h+="</div>";}
1254
+ else h+="<div class='muted' style='margin-top:10px'>No new features suggested &mdash; the verdict above is the call.</div>";
1182
1255
  if(footer)h+="<div class='rfoot'>"+esc(footer)+"</div>";
1183
1256
  return h;}
1184
1257
  function wireIdeas(){document.querySelectorAll('.ideachk').forEach(function(cb){cb.addEventListener('change',function(){
@@ -1186,17 +1259,21 @@ var idea=IDEAS[+cb.getAttribute('data-idx')];if(idea==null)return;
1186
1259
  if(cb.checked){api('/api/tasks/add',{text:idea,repo:IREPO}).then(function(it){if(it&&it.id)cb.setAttribute('data-tid',it.id);});}
1187
1260
  else{var id=cb.getAttribute('data-tid');if(id){api('/api/tasks/remove',{id:id}).then(function(){cb.setAttribute('data-tid','');});}}});});}
1188
1261
  function loadReview(name,path){var el=document.getElementById('review');el.classList.remove('hidden');
1189
- 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;}
1262
+ if(reviewCache[path]){var c=reviewCache[path];api('/api/tasks').then(function(tasks){el.innerHTML=reviewHtml(name,c.text,c.verdict,c.ideas,tasks,c.footer);wireIdeas();});return;}
1190
1263
  el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project&hellip;</div>";
1191
1264
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1192
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;}
1193
- 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();});}
1194
- 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;}
1195
- 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>";});
1196
- el.innerHTML=h;
1197
- el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
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();});}
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');
1198
1269
  row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1199
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);}
1200
1277
  function loadTasks(){api('/api/tasks').then(renderTasks);}
1201
1278
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1202
1279
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -1218,8 +1295,17 @@ if(r.empty){o.innerHTML="<div class='muted' style='margin-top:10px'>No open task
1218
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>";
1219
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>";}
1220
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>";}
1221
- 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>";
1222
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});}
1223
1309
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1224
1310
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1225
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};}
@@ -1251,8 +1337,9 @@ document.getElementById('recbtn').addEventListener('click',loadRec);
1251
1337
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1252
1338
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1253
1339
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1340
+ document.getElementById('agentcmd').addEventListener('change',saveAgent);
1254
1341
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1255
- initGraphEvents();syncP();refresh();loadMap();
1342
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1256
1343
  </script></body></html>`;
1257
1344
 
1258
1345
  function readBody(req) {
@@ -1311,11 +1398,14 @@ async function cmdApp() {
1311
1398
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1312
1399
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1313
1400
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1314
- 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) })));
1315
1402
  if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1316
1403
  if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1317
1404
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1318
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 || "")) }); }
1319
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)); }
1320
1410
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1321
1411
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -1339,7 +1429,7 @@ ${c.b("Usage")}
1339
1429
  symbiot todo what's still on your plate
1340
1430
  symbiot app open the visual app in your browser
1341
1431
  symbiot drift what's out of sync / at risk across repos
1342
- symbiot push write your tasks into each repo for your agent
1432
+ symbiot push [--open] write tasks into each repo (and open your IDE)
1343
1433
  symbiot models recommend AI models for your hardware
1344
1434
  symbiot login connect it to an AI (once)
1345
1435
  symbiot whoami show how it's connected
@@ -1387,4 +1477,4 @@ const isMain = (() => {
1387
1477
  })();
1388
1478
  if (isMain) main();
1389
1479
 
1390
- 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.10.3",
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": {