symbiot 0.30.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 +90 -14
  3. package/package.json +1 -1
  4. package/ui.mjs +41 -7
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]; }
@@ -1145,20 +1159,36 @@ function detectFolder(path) {
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
  }
1147
1161
  // Projects you've run an AI coding agent on. Claude Code stores one dir per
1148
- // project under ~/.claude/projects, named by the project path with "/"β†’"-"
1149
- // (verified). Returns { "<encoded path>": { agent, last } } β€” last = newest
1150
- // session mtime (epoch secs). (Gemini's dir is global config, Codex's is empty,
1151
- // so no reliable per-project signal there yet; add them here when there is.)
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
+ }
1152
1176
  function claudeProjects() {
1153
1177
  const base = join(homedir(), ".claude", "projects");
1154
1178
  const out = {};
1155
1179
  let dirs = [];
1156
1180
  try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
1157
1181
  for (const enc of dirs) {
1158
- let last = 0;
1159
- try { last = parseInt(sh(`find ${JSON.stringify(join(base, enc))} -maxdepth 1 -name '*.jsonl' -printf '%T@\\n' 2>/dev/null | sort -rn | head -1`).trim(), 10) || 0; } catch {}
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
+ }
1160
1190
  if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
1161
- out[enc] = { agent: "Claude Code", last };
1191
+ out[enc] = { agent: "Claude Code", last, path };
1162
1192
  }
1163
1193
  return out;
1164
1194
  }
@@ -1226,22 +1256,23 @@ async function buildMapScan() {
1226
1256
  // and surface agent-worked projects the scan missed as their own nodes.
1227
1257
  try {
1228
1258
  const ap = claudeProjects(); const used = new Set();
1229
- const encOf = (p) => String(p || "").replace(/\//g, "-");
1230
1259
  for (const n of nodes) {
1231
1260
  if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
1232
- const e = encOf(n.meta.path); if (ap[e]) { n.meta.agents = [ap[e]]; used.add(e); }
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); }
1233
1263
  }
1234
1264
  }
1235
1265
  let extra = 0;
1236
1266
  for (const e of Object.keys(ap)) {
1237
1267
  if (used.has(e) || extra >= 20) continue;
1238
- const decoded = e.replace(/-/g, "/"); // best-effort; hyphenated names won't resolve and are skipped
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, "/");
1239
1270
  if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
1240
1271
  if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
1241
1272
  const isGit = existsSync(join(decoded, ".git"));
1242
1273
  const id = (isGit ? "repo:" : "folder:") + decoded; if (have.has(id)) continue;
1243
1274
  const det = isGit ? null : detectFolder(decoded);
1244
- add({ id, type: isGit ? "repo" : "folder", label: decoded.split("/").pop(), weight: 10, meta: { path: decoded, agents: [ap[e]], agentOnly: true, langs: det ? det.langs.slice(0, 3) : [], tools: det ? det.tools : [], files: det ? det.files : 0 } });
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 } });
1245
1276
  edges.push({ source: "me", target: id }); extra++;
1246
1277
  }
1247
1278
  } catch {}
@@ -1387,6 +1418,48 @@ async function folderSuggest(path) {
1387
1418
  const footer = `symbiot ${VERSION} Β· folder Β· ${det.files} files Β· ${stack} Β· not a git repo`;
1388
1419
  return { text: text || "(couldn't reach the model)", footer };
1389
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 }; }
1390
1463
 
1391
1464
  // Build a write-up for a command; returns { text, sub, error? } without printing.
1392
1465
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
@@ -1616,10 +1689,13 @@ async function cmdApp() {
1616
1689
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1617
1690
  if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1618
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 || ""))); }
1619
1694
  if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
1620
1695
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1621
1696
  if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
1622
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)); }
1623
1699
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1624
1700
  if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
1625
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.30.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>
@@ -294,13 +301,36 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
294
301
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
295
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;}
296
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();});}
297
- var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
304
+ var ALLTASKS=[];var PENDTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
298
305
  var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
299
- 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>";}
300
307
  function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
301
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);
302
310
  var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
303
- 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();}
304
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>";}
305
335
  function renderFilter(){var box=document.getElementById('taskfilter');
306
336
  if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>&#8617; back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
@@ -319,16 +349,18 @@ var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORD
319
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);});});
320
350
  el.innerHTML=h;wireTaskRows(el);}
321
351
  function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});loadPending();});}
322
- 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;}
323
353
  var n=0;list.forEach(function(r){n+=r.tasks.length;});
324
354
  var h="<div class='tgroup' style='color:var(--amber)'>Awaiting your review <span class='tcount'>"+n+"</span></div>";
325
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";
326
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>";
327
- 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>";});
328
- 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>";});
329
359
  el.innerHTML=h;
330
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'));});
331
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});});
332
364
  var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
333
365
  if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
334
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)';});});
@@ -338,6 +370,8 @@ if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="
338
370
  var m="&#10003; <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
339
371
  if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
340
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>";
341
375
  if(r.note)m+="<div class='muted'>"+esc(r.note)+"</div>";
342
376
  o.innerHTML="<div class='note ok'>"+m+"</div>";loadTasks();});});});});}
343
377
  function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');