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.
- package/README.md +4 -0
- package/index.mjs +90 -14
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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> — 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>↶</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> — 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>↶</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b>💬</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 · <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 · <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'>↶</button>":"<button class='rm' title='remove'>×</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'>↶</button>":"<button class='rm' title='remove'>×</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 & answers about this task":"ask a question about this task")+"'>💬"+(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 — what it means, how to approach it, or what the agent changed. Answers draw on the repo'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…</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="💬 "+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='💬';});});
|
|
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'>↩ 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?" · "+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||'?')+" · "+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
|
|
328
|
-
h+="<div class='row'><button class='act approve'"+(r.path?"":" disabled")+" title='commit on a branch, push, open a PR, then archive'>Approve → "+(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)'>↶</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 → "+(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="✓ <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
|
|
339
371
|
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
340
372
|
if(r.pr)m+=" · <a href='"+esc(r.pr)+"' target='_blank' rel='noopener'>open PR</a>";
|
|
373
|
+
if(r.autoMerge==='queued')m+=" · 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 → General → 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');
|