symbiot 0.29.0 β†’ 0.31.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 (4) hide show
  1. package/README.md +4 -0
  2. package/index.mjs +122 -5
  3. package/package.json +1 -1
  4. package/ui.mjs +45 -9
package/README.md CHANGED
@@ -61,6 +61,10 @@ Tabs:
61
61
  finishes lands in **Awaiting your review** (see
62
62
  [Review and approve](#review-and-approve-the-agents-work)). A task you tick
63
63
  yourself is done and **auto-archives**; the archived view can restore it.
64
+ **πŸ’¬** on any task opens a Q&A thread: ask what it means, how to approach it, or
65
+ (once it's awaiting review) what the agent changed. Answers use your chosen AI,
66
+ grounded in that repo's commits, README, rules and pending diff; the thread is
67
+ kept with the task.
64
68
  - **Agents** β€” every agent run Symbiot has started (and local-model downloads):
65
69
  live status, elapsed time, exit code, the tail of its output, and **what it
66
70
  did** β€” files changed and commits made, read from git, whichever agent it was.
package/index.mjs CHANGED
@@ -164,7 +164,8 @@ function workingDiff(path, cap = 400000) {
164
164
  function pendingReview() {
165
165
  const by = {}; for (const x of loadTasks()) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
166
166
  const map = Object.keys(by).length ? repoPathMap() : {};
167
- return Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
167
+ const am = autoMergeRepos();
168
+ return Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
168
169
  }
169
170
  const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
170
171
  // Sync approved work: off the default branch onto symbiot/<task>, commit the
@@ -202,13 +203,26 @@ function shipChanges(path, texts, opts = {}) {
202
203
  const gh = (args) => spawnSync("gh", args, { cwd: path, encoding: "utf8", timeout: 60000 });
203
204
  const pr = gh(["pr", "create", "--head", branch, "--base", base, "--title", subject, "--body", body + "\n\nApproved in Symbiot."]);
204
205
  const url = (String(pr.stdout || "").match(/https?:\/\/\S+/) || [])[0] || String(gh(["pr", "view", branch, "--json", "url", "-q", ".url"]).stdout || "").trim();
205
- return url ? { ...out, pr: url } : { ...out, note: "Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop() };
206
+ if (!url) return { ...out, note: "Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop() };
207
+ out.pr = url;
208
+ // Opt-in auto-merge: queue GitHub's native auto-merge, which lands the PR only
209
+ // once its required checks (CI) pass β€” never immediately on its own if the repo
210
+ // has branch protection. Needs 'Allow auto-merge' on the repo; if off, we say so.
211
+ if (opts.autoMerge) {
212
+ const am = gh(["pr", "merge", branch, "--auto", "--squash", "--delete-branch"]);
213
+ if (am.status === 0) out.autoMerge = "queued";
214
+ else { out.autoMerge = "unavailable"; out.autoMergeErr = String(am.stderr || "").trim().split("\n").filter(Boolean).pop() || "gh pr merge failed"; }
215
+ }
216
+ return out;
206
217
  }
218
+ // Per-repo opt-in to auto-merge Approve PRs once CI passes (default off).
219
+ function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
220
+ function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
207
221
  function approveRepo(repo, opts = {}) {
208
222
  const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
209
223
  if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
210
224
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
211
- const r = shipChanges(path, items.map((x) => x.text), opts);
225
+ const r = shipChanges(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
212
226
  if (r.error) return r;
213
227
  const now = Date.now();
214
228
  for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
@@ -1144,6 +1158,40 @@ function detectFolder(path) {
1144
1158
  for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
1145
1159
  return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
1146
1160
  }
1161
+ // Projects you've run an AI coding agent on. Claude Code stores one dir per
1162
+ // project under ~/.claude/projects, named by the project path with every
1163
+ // non-alphanumeric char β†’ "-" (so "/" "." "_" "-" all collide). Returns
1164
+ // { "<encoded path>": { agent, last, path } } β€” last = newest session mtime
1165
+ // (epoch secs); path = the real project dir, read from the "cwd" a session
1166
+ // records (empty when no session says, then callers fall back to decoding).
1167
+ // (Gemini's dir is global config, Codex's is empty, so no reliable per-project
1168
+ // signal there yet; add them here when there is.)
1169
+ const claudeEnc = (p) => String(p || "").replace(/[^a-zA-Z0-9]/g, "-");
1170
+ function sessionCwd(file) {
1171
+ const head = sh(`head -c 262144 ${JSON.stringify(file)} 2>/dev/null`);
1172
+ const m = head.match(/"cwd":"((?:[^"\\]|\\.)*)"/);
1173
+ if (!m) return "";
1174
+ try { return JSON.parse(`"${m[1]}"`); } catch { return ""; }
1175
+ }
1176
+ function claudeProjects() {
1177
+ const base = join(homedir(), ".claude", "projects");
1178
+ const out = {};
1179
+ let dirs = [];
1180
+ try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
1181
+ for (const enc of dirs) {
1182
+ let last = 0, path = "";
1183
+ const newest = sh(`find ${JSON.stringify(join(base, enc))} -maxdepth 1 -name '*.jsonl' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1`).trim();
1184
+ if (newest) {
1185
+ const sp = newest.indexOf(" ");
1186
+ last = parseInt(newest.slice(0, sp), 10) || 0;
1187
+ const cwd = sessionCwd(newest.slice(sp + 1));
1188
+ if (cwd && (claudeEnc(cwd) === enc || cwd.replace(/\//g, "-") === enc)) path = cwd; // only trust a cwd that matches this dir
1189
+ }
1190
+ if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
1191
+ out[enc] = { agent: "Claude Code", last, path };
1192
+ }
1193
+ return out;
1194
+ }
1147
1195
  // Async only to YIELD between repos, so the app server can answer /api/scan
1148
1196
  // (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
1149
1197
  // callers (e.g. Rescan while a scan runs) share the one in-flight build.
@@ -1204,6 +1252,30 @@ async function buildMapScan() {
1204
1252
  const prov = resolveProvider();
1205
1253
  if (prov) { const id = "ai:" + prov.provider; add({ id, type: "ai", label: PROVIDERS[prov.provider].label, weight: 14, meta: { model: prov.model, source: prov.source } }); edges.push({ source: "me", target: id }); }
1206
1254
  } catch {}
1255
+ // Agent projects: badge the repo/folder nodes an AI coding agent has worked,
1256
+ // and surface agent-worked projects the scan missed as their own nodes.
1257
+ try {
1258
+ const ap = claudeProjects(); const used = new Set();
1259
+ for (const n of nodes) {
1260
+ if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
1261
+ const e = [claudeEnc(n.meta.path), n.meta.path.replace(/\//g, "-")].find((k) => ap[k]); // older Claude Code mapped only "/"
1262
+ if (e) { n.meta.agents = [{ agent: ap[e].agent, last: ap[e].last }]; used.add(e); }
1263
+ }
1264
+ }
1265
+ let extra = 0;
1266
+ for (const e of Object.keys(ap)) {
1267
+ if (used.has(e) || extra >= 20) continue;
1268
+ // the session's recorded cwd; else best-effort decode (names with - . _ won't resolve and are skipped)
1269
+ const decoded = ap[e].path || e.replace(/-/g, "/");
1270
+ if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
1271
+ if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
1272
+ const isGit = existsSync(join(decoded, ".git"));
1273
+ const id = (isGit ? "repo:" : "folder:") + decoded; if (have.has(id)) continue;
1274
+ const det = isGit ? null : detectFolder(decoded);
1275
+ add({ id, type: isGit ? "repo" : "folder", label: decoded.split("/").pop(), weight: 10, meta: { path: decoded, agents: [{ agent: ap[e].agent, last: ap[e].last }], agentOnly: true, langs: det ? det.langs.slice(0, 3) : [], tools: det ? det.tools : [], files: det ? det.files : 0 } });
1276
+ edges.push({ source: "me", target: id }); extra++;
1277
+ }
1278
+ } catch {}
1207
1279
  const out = { nodes, edges, stats: {
1208
1280
  repos: repos.length,
1209
1281
  folders: nodes.filter((n) => n.type === "folder").length,
@@ -1229,7 +1301,7 @@ async function nodeDetail(id) {
1229
1301
  branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
1230
1302
  dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
1231
1303
  last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
1232
- commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [],
1304
+ commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [], agents: m.agents || [],
1233
1305
  };
1234
1306
  }
1235
1307
  if (id.startsWith("lang:") || id.startsWith("tool:")) {
@@ -1243,7 +1315,7 @@ async function nodeDetail(id) {
1243
1315
  }
1244
1316
  if (id.startsWith("folder:")) {
1245
1317
  const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
1246
- return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0 };
1318
+ return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0, agents: m.agents || [] };
1247
1319
  }
1248
1320
  return { error: "unknown node" };
1249
1321
  }
@@ -1346,6 +1418,48 @@ async function folderSuggest(path) {
1346
1418
  const footer = `symbiot ${VERSION} Β· folder Β· ${det.files} files Β· ${stack} Β· not a git repo`;
1347
1419
  return { text: text || "(couldn't reach the model)", footer };
1348
1420
  }
1421
+ // Ask questions about a task: a short Q&A thread kept on the task itself
1422
+ // (task.chat), grounded in the repo/folder it belongs to β€” and, once an agent
1423
+ // has ticked it, in the changes waiting for review.
1424
+ const CHAT_KEEP = 40; // messages stored per task
1425
+ async function taskChat(id, question) {
1426
+ question = String(question || "").trim().slice(0, 2000);
1427
+ const it = loadTasks().find((x) => x.id === id);
1428
+ if (!it) return { error: "not found" };
1429
+ if (!question) return { error: "empty" };
1430
+ if (!resolveProvider()) return { error: "not-connected" };
1431
+ const path = it.repo ? repoPathMap()[it.repo] : "";
1432
+ const ctx = [];
1433
+ if (path) {
1434
+ const isGit = existsSync(join(path, ".git"));
1435
+ if (isGit) ctx.push(`Recent commits:\n${sh(`git -C ${JSON.stringify(path)} log --format='%ad %s' --date=short -15 2>/dev/null`).trim() || "(none)"}`);
1436
+ else { const det = detectFolder(path); ctx.push(`Project folder (not a git repo) Β· ${det.files} files Β· ${[...det.langs, ...det.tools].join(", ") || "unknown stack"}`); }
1437
+ const rd = readmeInfo(path); if (rd.excerpt) ctx.push(`README excerpt:\n${rd.excerpt.slice(0, 1500)}`);
1438
+ const rules = houseRules(path); if (rules) ctx.push(`Conventions β€” do NOT advise against these:\n${rules}`);
1439
+ if (it.review && isGit) {
1440
+ const ch = workingChanges(path);
1441
+ ctx.push(`The agent has ticked this task; its uncommitted changes await review (${ch.stat || "no changes"}):\n${ch.files.map((f) => `${f.st} ${f.file}`).slice(0, 40).join("\n") || "(none)"}\n\nDiff (truncated):\n${workingDiff(path, 6000)}`);
1442
+ }
1443
+ }
1444
+ const history = (it.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`).join("\n\n");
1445
+ const system =
1446
+ `You are a pragmatic senior engineer helping someone with ONE task on their list β€” before or after they hand it to a coding agent. ` +
1447
+ `Answer their question about the task directly and concisely, in plain text, no preamble. Ground what you say in the project evidence shown; ` +
1448
+ `if the evidence doesn't settle it, say so and what you'd check. If the task is ambiguous, say how you'd read it and what to clarify. ` +
1449
+ `Never invent files, features or history.`;
1450
+ const prompt =
1451
+ `Task: ${it.text}\nKind: ${taskType(it.text)} Β· repo: ${it.repo || "(none)"} Β· status: ${it.archived ? "archived" : it.review ? "done by the agent, awaiting review" : it.done ? "done" : "open"}\n\n` +
1452
+ (ctx.length ? ctx.join("\n\n") + "\n\n" : path ? "" : "(no repo attached β€” answer from the task text alone)\n\n") +
1453
+ (history ? `Conversation so far:\n${history}\n\n` : "") + `Question: ${question}`;
1454
+ const answer = (await write(system, prompt)) || "(couldn't reach the model)";
1455
+ // Re-read: other requests may have changed tasks.json while the model ran.
1456
+ const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
1457
+ if (!cur) return { answer, chat: [] };
1458
+ cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-CHAT_KEEP);
1459
+ saveTasks(t);
1460
+ return { answer, chat: cur.chat };
1461
+ }
1462
+ function clearTaskChat(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" }; delete it.chat; saveTasks(t); return { ok: true }; }
1349
1463
 
1350
1464
  // Build a write-up for a command; returns { text, sub, error? } without printing.
1351
1465
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
@@ -1575,10 +1689,13 @@ async function cmdApp() {
1575
1689
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1576
1690
  if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1577
1691
  if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1692
+ if (u.pathname === "/api/tasks/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await taskChat(String(b.id || ""), b.question)); }
1693
+ if (u.pathname === "/api/tasks/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearTaskChat(String(b.id || ""))); }
1578
1694
  if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
1579
1695
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1580
1696
  if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
1581
1697
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1698
+ if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
1582
1699
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1583
1700
  if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
1584
1701
  if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.29.0",
3
+ "version": "0.31.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": {
package/ui.mjs CHANGED
@@ -74,6 +74,13 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
74
74
  .fchip{font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--ink3);color:var(--text);cursor:pointer;font:inherit}
75
75
  .fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
76
76
  .task .rm.restore{color:var(--green);font-size:15px}
77
+ .task .ask{background:none;border:0;color:var(--faint);cursor:pointer;font-size:14px;line-height:1;padding:0 2px}
78
+ .task .ask:hover,.task .ask.on,.task .ask.has{color:var(--green)}
79
+ .tchat{border:1px solid var(--line);border-top:0;border-radius:0 0 9px 9px;margin-top:-3px;padding:8px 11px 10px;background:var(--ink2)}
80
+ .tchat .msg{margin-top:6px;font-size:13px;line-height:1.55;white-space:pre-wrap}
81
+ .tchat .msg.q{color:var(--bone)}.tchat .msg.q:before{content:'You: ';color:var(--faint)}
82
+ .tchat .msg.a{color:var(--text);background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px}
83
+ .tchat .row{margin:8px 0 0}.tchat .askclear{font-size:12px}
77
84
  .rcard{border:1px solid var(--amber);border-radius:11px;padding:10px 12px;margin-top:8px}
78
85
  .rcard .rhead{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}.rcard .rhead .muted{font-size:12px}
79
86
  .rcard .row{margin-top:8px}.rcard .task{margin-top:6px}
@@ -150,7 +157,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
150
157
  </section>
151
158
  <section id="panel-tasks" class="hidden">
152
159
  <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><select id="newtaskrepo" title="Which repo this task is for (needed to send it to an agent)" style="flex:0 0 auto;max-width:180px"><option value="">repo…</option></select><button class="act" id="addtask">Add</button><button class="ghost" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></div>
153
- <div class="note muted" style="margin-top:2px">To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>&#8630;</b> sends it back. Ticking a task yourself marks it done (it auto-archives).</div>
160
+ <div class="note muted" style="margin-top:2px">To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>&#8630;</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b>&#128172;</b> on a task to ask questions about it.</div>
154
161
  <div id="pushout"></div>
155
162
  <div id="reviewout"></div>
156
163
  <div id="reviewlist"></div>
@@ -260,15 +267,17 @@ function nodeById(id){for(var i=0;i<GRAPH.nodes.length;i++)if(GRAPH.nodes[i].id=
260
267
  function summarize(n){if(!n)return "";if(n.type==='repo'){var m=n.meta||{};var bits=[m.commits+' commits'];if(m.branch)bits.push('branch '+m.branch);if(m.last)bits.push('last '+m.last);if(typeof m.files==='number')bits.push(m.files+' files');if(m.langs&&m.langs.length)bits.push(m.langs.join(', '));if(m.tools&&m.tools.length)bits.push(m.tools.join(', '));return "<b>"+esc(n.label)+"</b> "+esc(m.path||'')+" β€” "+esc(bits.join(' Β· '));}if(n.type==='lang')return "<b>"+esc(n.label)+"</b> β€” language (click to see repos)";if(n.type==='tool')return "<b>"+esc(n.label)+"</b> β€” tool (click to see repos)";if(n.type==='person')return "<b>"+esc(n.label)+"</b> β€” you";return "<b>"+esc(n.label)+"</b>";}
261
268
  function updateBar(id){var bar=document.getElementById('mapbar');if(!bar)return;var n=id?nodeById(id):(sel?nodeById(sel):null);bar.innerHTML=n?summarize(n):"Hover a node for a summary &middot; click for details &middot; drag to move";}
262
269
  function hideDetail(){document.getElementById('detail').classList.add('hidden');}
270
+ function ago(sec){if(!sec)return'';var s=Math.floor(Date.now()/1000)-sec;if(s<3600)return Math.max(1,Math.floor(s/60))+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago';}
271
+ function agentBadge(d){if(!d.agents||!d.agents.length)return'';return d.agents.map(function(a){return "<div style='margin-top:8px;font-size:12px;color:var(--bone);background:var(--green-dim);border:1px solid #2a6b52;border-radius:8px;padding:6px 9px'>&#9889; worked via <b>"+esc(a.agent)+"</b>"+(a.last?" &middot; "+ago(a.last):"")+"</div>";}).join('');}
263
272
  function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
264
273
  if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
265
274
  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>";});
266
- 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>";
275
+ el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div>"+agentBadge(d)+"<button class='act' id='suggest' style='margin-top:12px'>Suggest next steps</button><div id='sugout'></div>";
267
276
  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;}
268
277
  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;}
269
278
  if(d.type==='agent'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+(m.kind==='agent'?'AI coding agent you have installed':'editor you have installed')+"</div>"+(m.cmd?"<div class='out2' style='font-size:11px'>"+esc(m.cmd)+"</div>":"");return;}
270
279
  if(d.type==='ai'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Currently powering Symbiot &middot; model <b>"+esc(m.model||'?')+"</b>"+(m.source?" &middot; via "+esc(m.source):"")+"</div>";return;}
271
- if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder &middot; not a git repo &middot; "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div><button class='act' id='suggest' style='margin-top:12px'>Overview &amp; suggestions</button><div id='sugout'></div><div class='k' style='margin-top:8px'>Tasks can be sent here too &mdash; the agent runs in this folder.</div>";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 an overview - 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;}
280
+ if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder &middot; not a git repo &middot; "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div>"+agentBadge(d)+"<button class='act' id='suggest' style='margin-top:12px'>Overview &amp; suggestions</button><div id='sugout'></div><div class='k' style='margin-top:8px'>Tasks can be sent here too &mdash; the agent runs in this folder.</div>";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 an overview - 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;}
272
281
  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"+(st.agents?" &middot; "+st.agents+" agents/editors":"")+"</div>";return;}}
273
282
  var reviewCache={};var IDEAS=[];var IREPO="";
274
283
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
@@ -292,13 +301,36 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
292
301
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
293
302
  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;}
294
303
  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();});}
295
- var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
304
+ var ALLTASKS=[];var PENDTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
296
305
  var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
297
- function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
306
+ function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+askBtn(t)+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
298
307
  function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
299
308
  var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
309
+ wireAsk(row,id);
300
310
  var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
301
- else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();});});});}
311
+ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){var nx=row.nextElementSibling;if(nx&&nx.classList.contains('tchat'))nx.remove();row.remove();});});});}
312
+ // Per-task Q&A: the button opens a thread under the row; answers come from the
313
+ // connected model, grounded in the task's repo (and its pending changes).
314
+ function askBtn(t){var n=(t.chat||[]).length;return "<button class='ask"+(n?" has":"")+"' title='"+(n?"questions &amp; answers about this task":"ask a question about this task")+"'>&#128172;"+(n?" "+Math.ceil(n/2):"")+"</button>";}
315
+ function taskById(id){var all=PENDTASKS.concat(ALLTASKS);for(var i=0;i<all.length;i++)if(all[i].id===id)return all[i];return null;}
316
+ function chatMsgs(chat){return (chat||[]).map(function(m){return "<div class='msg "+(m.role==='user'?'q':'a')+"'>"+esc(m.text)+"</div>";}).join("");}
317
+ var CHATHINT="<div class='muted' style='font-size:12px'>Ask anything about this task &mdash; what it means, how to approach it, or what the agent changed. Answers draw on the repo&#39;s commits, README and rules.</div>";
318
+ function wireAsk(row,id){var b=row.querySelector('.ask');if(b)b.addEventListener('click',function(){toggleChat(row,id);});}
319
+ function toggleChat(row,id){var btn=row.querySelector('.ask');var nx=row.nextElementSibling;
320
+ if(nx&&nx.classList.contains('tchat')){nx.remove();btn.classList.remove('on');return;}
321
+ var t=taskById(id)||{id:id};btn.classList.add('on');
322
+ row.insertAdjacentHTML('afterend',"<div class='tchat'><div class='msgs'>"+((t.chat&&t.chat.length)?chatMsgs(t.chat):CHATHINT)+"</div><div class='row'><input class='askq' placeholder='Ask about this task...' style='flex:1'><button class='ghost asksend'>Ask</button><a class='askclear' title='forget this conversation'>clear</a></div></div>");
323
+ var box=row.nextElementSibling,inp=box.querySelector('.askq'),send=box.querySelector('.asksend'),msgs=box.querySelector('.msgs');
324
+ function ask(){var q=(inp.value||'').trim();if(!q||send.disabled)return;send.disabled=true;inp.value='';
325
+ if(!(t.chat&&t.chat.length))msgs.innerHTML='';
326
+ msgs.insertAdjacentHTML('beforeend',chatMsgs([{role:'user',text:q}])+"<div class='msg a muted thinking'>Thinking&hellip;</div>");
327
+ api('/api/tasks/chat',{id:id,question:q}).then(function(r){send.disabled=false;var th=msgs.querySelector('.thinking');if(th)th.remove();
328
+ if(r.error==='not-connected'){msgs.insertAdjacentHTML('beforeend',"<div class='msg a'>Connect a model in Settings to ask questions - Ollama is free and runs locally.</div>");return;}
329
+ if(r.error){msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(r.error)+"</div>");return;}
330
+ t.chat=r.chat||[];msgs.innerHTML=chatMsgs(t.chat);btn.className='ask on has';btn.innerHTML="&#128172; "+Math.ceil(t.chat.length/2);}).catch(function(e){send.disabled=false;var th=msgs.querySelector('.thinking');if(th)th.remove();msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(String((e&&e.message)||e))+"</div>");});}
331
+ send.addEventListener('click',ask);inp.addEventListener('keydown',function(e){if(e.key==='Enter')ask();});
332
+ box.querySelector('.askclear').addEventListener('click',function(){api('/api/tasks/chat/clear',{id:id}).then(function(){t.chat=[];msgs.innerHTML=CHATHINT;btn.className='ask on';btn.innerHTML='&#128172;';});});
333
+ inp.focus();}
302
334
  function fchip(dim,val,label){var on=(TFILTER[dim]||'')===val;return "<button class='fchip"+(on?' on':'')+"' data-dim='"+dim+"' data-val='"+esc(val)+"'>"+esc(label)+"</button>";}
303
335
  function renderFilter(){var box=document.getElementById('taskfilter');
304
336
  if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>&#8617; back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
@@ -317,16 +349,18 @@ var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORD
317
349
  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,false);});});
318
350
  el.innerHTML=h;wireTaskRows(el);}
319
351
  function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});loadPending();});}
320
- function loadPending(){api('/api/pending').then(function(list){var el=document.getElementById('reviewlist');if(!list||!list.length){el.innerHTML='';return;}
352
+ function loadPending(){api('/api/pending').then(function(list){var el=document.getElementById('reviewlist');PENDTASKS=[];if(!list||!list.length){el.innerHTML='';return;}
321
353
  var n=0;list.forEach(function(r){n+=r.tasks.length;});
322
354
  var h="<div class='tgroup' style='color:var(--amber)'>Awaiting your review <span class='tcount'>"+n+"</span></div>";
323
355
  list.forEach(function(r){var ch=r.files.length?(r.files.length+" file"+(r.files.length>1?"s":"")+" changed"+(r.stat?" &middot; "+esc(r.stat):"")):"no uncommitted changes";
324
356
  h+="<div class='rcard' data-repo='"+esc(r.repo)+"'><div class='rhead'><b>"+esc(r.repo)+"</b><span class='muted'>"+(r.path?"on "+esc(r.branch||'?')+" &middot; "+ch:"repo not found on disk")+"</span></div>";
325
- r.tasks.forEach(function(t){h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span><button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
326
- h+="<div class='row'><button class='act approve'"+(r.path?"":" disabled")+" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive")+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"</div><div class='rdiff hidden'></div></div>";});
357
+ r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
358
+ h+="<div class='row'><button class='act approve'"+(r.path?"":" disabled")+" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive")+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
327
359
  el.innerHTML=h;
328
360
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
361
+ card.querySelectorAll('.task').forEach(function(row){wireAsk(row,row.getAttribute('data-id'));});
329
362
  card.querySelectorAll('.sendback').forEach(function(b){b.addEventListener('click',function(){api('/api/pending/sendback',{id:b.closest('.task').getAttribute('data-id')}).then(loadTasks);});});
363
+ var amt=card.querySelector('.amtoggle');if(amt)amt.addEventListener('change',function(){api('/api/automerge',{repo:repo,on:amt.checked});});
330
364
  var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
331
365
  if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
332
366
  pre.textContent='Loading...';pre.classList.remove('hidden');sd.textContent='Hide diff';api('/api/pending/diff?repo='+encodeURIComponent(repo)).then(function(d){pre.textContent=(d&&d.diff)||'(no changes)';});});
@@ -336,6 +370,8 @@ if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="
336
370
  var m="&#10003; <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
337
371
  if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
338
372
  if(r.pr)m+=" &middot; <a href='"+esc(r.pr)+"' target='_blank' rel='noopener'>open PR</a>";
373
+ if(r.autoMerge==='queued')m+=" &middot; will auto-merge when CI passes";
374
+ else if(r.autoMerge==='unavailable')m+="<div class='muted'>auto-merge not enabled for this repo on GitHub (Settings &rarr; General &rarr; Allow auto-merge)"+(r.autoMergeErr?": "+esc(r.autoMergeErr):"")+"</div>";
339
375
  if(r.note)m+="<div class='muted'>"+esc(r.note)+"</div>";
340
376
  o.innerHTML="<div class='note ok'>"+m+"</div>";loadTasks();});});});});}
341
377
  function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');