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.
- package/README.md +4 -0
- package/index.mjs +122 -5
- package/package.json +1 -1
- 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
|
-
|
|
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]; }
|
|
@@ -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.
|
|
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>
|
|
@@ -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 · click for details · 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'>⚡ worked via <b>"+esc(a.agent)+"</b>"+(a.last?" · "+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>—</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
|
|
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 · model <b>"+esc(m.model||'?')+"</b>"+(m.source?" · 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 · not a git repo · "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div
|
|
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 · not a git repo · "+(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 & suggestions</button><div id='sugout'></div><div class='k' style='margin-top:8px'>Tasks can be sent here too — 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 · "+st.commits+" commits · "+st.languages+" languages · "+st.tools+" tools"+(st.agents?" · "+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 · <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 · <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'>↶</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>";}
|
|
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 & 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();}
|
|
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'>↩ 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?" · "+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||'?')+" · "+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
|
|
326
|
-
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>";});
|
|
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="✓ <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
|
|
337
371
|
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
338
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>";
|
|
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');
|