symbiot 0.50.2 → 0.51.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/CHANGELOG.md +17 -0
- package/README.md +133 -2
- package/adapt.mjs +1 -1
- package/agents.mjs +65 -16
- package/core.mjs +26 -2
- package/handback.mjs +178 -0
- package/handover.mjs +33 -3
- package/headless.mjs +29 -4
- package/home.mjs +15 -4
- package/index.mjs +192 -3
- package/knowledge.mjs +361 -0
- package/lanes.mjs +9 -8
- package/links.mjs +4 -1
- package/mail.mjs +27 -1
- package/mind.mjs +14 -9
- package/package.json +7 -2
- package/post.mjs +317 -0
- package/reports.mjs +136 -0
- package/sandbox.mjs +55 -0
- package/server.mjs +37 -2
- package/tasks.mjs +54 -20
- package/ui.mjs +163 -23
- package/watch.mjs +113 -20
package/server.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
14
|
-
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
14
|
+
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
15
15
|
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } from "./agents.mjs";
|
|
16
16
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
17
17
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
@@ -23,11 +23,15 @@ import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBr
|
|
|
23
23
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
|
|
24
24
|
import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
25
25
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
26
|
+
import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn } from "./post.mjs";
|
|
26
27
|
import { mindState, forget } from "./mind.mjs";
|
|
27
28
|
import { lanesTick, lanesState } from "./lanes.mjs";
|
|
29
|
+
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
28
30
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
29
31
|
import { homeState, homeAsk, workScene, workGo } from "./home.mjs";
|
|
32
|
+
import { listReports, readReport, markAllRead } from "./reports.mjs";
|
|
30
33
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
34
|
+
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
31
35
|
|
|
32
36
|
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
33
37
|
// npm's cache/propagation can resolve stale — that caused an update loop where the
|
|
@@ -103,6 +107,8 @@ function readBody(req) {
|
|
|
103
107
|
function appLink(url) { return url.replace(/^http:\/\//, "symbiot://"); }
|
|
104
108
|
// Symbiot running in Termux (not the Android app's own)
|
|
105
109
|
const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_APP !== "1";
|
|
110
|
+
const SANDBOX = !!process.env.SYMBIOT_SANDBOX; // symbiot app --fresh (sandbox.mjs)
|
|
111
|
+
const FIRST_RUN = !existsSync(CONFIG_PATH); // no config yet when Symbiot started: a brand-new install
|
|
106
112
|
function openApp(url) {
|
|
107
113
|
try {
|
|
108
114
|
// Android (Termux): Symbiot's Android app shows this Symbiot full screen (it
|
|
@@ -225,6 +231,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
225
231
|
if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
|
|
226
232
|
if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
|
|
227
233
|
if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
|
|
234
|
+
// knowledge folders (knowledge.mjs): added or removed, the index catches up at once
|
|
235
|
+
if (u.pathname === "/api/knowledge") return json(res, knowledgeState());
|
|
236
|
+
if (u.pathname === "/api/knowledge/add" && req.method === "POST") { const b = await readBody(req), r = addKnowledgeFolder(String(b.path || ""), b.examples); if (r.error) return json(res, r); indexKnowledge(); return json(res, { ok: true, ...knowledgeState() }); }
|
|
237
|
+
if (u.pathname === "/api/knowledge/remove" && req.method === "POST") { const b = await readBody(req), r = removeKnowledgeFolder(String(b.path || "")); if (r.error) return json(res, r); indexKnowledge(); return json(res, { ok: true, ...knowledgeState() }); }
|
|
238
|
+
if (u.pathname === "/api/knowledge/index" && req.method === "POST") { const r = indexKnowledge(); return json(res, { ...r, ...knowledgeState() }); }
|
|
239
|
+
if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
|
|
228
240
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
|
|
229
241
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
230
242
|
if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
|
|
@@ -240,6 +252,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
240
252
|
if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
|
|
241
253
|
if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
|
|
242
254
|
if (u.pathname === "/api/agents/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipIdea(String(b.path || ""), b.text)); }
|
|
255
|
+
// What a run handed back (handback.mjs): facts for memory, kept only on Remember; replies it waits on.
|
|
256
|
+
if (u.pathname === "/api/agents/remember" && req.method === "POST") { const b = await readBody(req); return json(res, b.skip === true ? skipFacts(String(b.path || "")) : keepFacts(String(b.path || ""), { only: Array.isArray(b.only) ? b.only.map(Number) : undefined })); }
|
|
257
|
+
if (u.pathname === "/api/awaiting") return json(res, awaitingState());
|
|
258
|
+
if (u.pathname === "/api/awaiting/stop" && req.method === "POST") { const b = await readBody(req); return json(res, stopWaiting(String(b.id || ""))); }
|
|
243
259
|
if (u.pathname === "/api/mail") return json(res, mailState());
|
|
244
260
|
if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
|
|
245
261
|
if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || since, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
|
|
@@ -283,6 +299,19 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
283
299
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
284
300
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
285
301
|
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
302
|
+
// Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
|
|
303
|
+
// the page copies the post itself and opens LinkedIn's share box; nothing is posted.
|
|
304
|
+
if (u.pathname === "/api/posts") return json(res, postsState());
|
|
305
|
+
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
306
|
+
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
|
|
307
|
+
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
308
|
+
if (u.pathname === "/api/posts/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipPost(String(b.id || ""))); }
|
|
309
|
+
// reads your LinkedIn only on your click (confirmed), never by itself
|
|
310
|
+
if (u.pathname === "/api/posts/voice" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Reading your LinkedIn posts needs your click." }); return json(res, await voiceFromLinkedIn()); }
|
|
311
|
+
// Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
|
|
312
|
+
if (u.pathname === "/api/reports") return json(res, { reports: listReports() });
|
|
313
|
+
if (u.pathname === "/api/reports/read") return json(res, readReport(u.searchParams.get("id") || ""));
|
|
314
|
+
if (u.pathname === "/api/reports/seen" && req.method === "POST") return json(res, markAllRead());
|
|
286
315
|
// Home (home.mjs): the liquid's live data, and its talk; Adapt (adapt.mjs): its
|
|
287
316
|
// shape from how you use it (commit=1 when it wakes from rest, never mid-gesture).
|
|
288
317
|
if (u.pathname === "/api/home") return json(res, homeState({ fresh: u.searchParams.get("fresh") === "1" }));
|
|
@@ -329,10 +358,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
329
358
|
// the Week tab's week is saved to weeks/ too, as Write it now saves it (no notification: you're looking at it)
|
|
330
359
|
if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, cmd === "week" ? await runWeekly(writeup, { notify: false }) : await writeup(cmd)); }
|
|
331
360
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
332
|
-
|
|
361
|
+
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install
|
|
362
|
+
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
333
363
|
// What's new: after an update, since the version you last saw (until you click Got it);
|
|
334
364
|
// ?latest=1, what the update on offer brings, from its package on npm
|
|
335
365
|
if (u.pathname === "/api/whatsnew") {
|
|
366
|
+
// a brand-new install has nothing to catch up on: this version counts as seen
|
|
367
|
+
if (u.searchParams.get("latest") !== "1" && FIRST_RUN && !loadConfig().seenVersion) { const cfg = loadConfig(); cfg.seenVersion = VERSION; saveConfig(cfg); return json(res, { version: VERSION, changes: [] }); }
|
|
336
368
|
if (u.searchParams.get("latest") !== "1") return json(res, whatsNew());
|
|
337
369
|
const to = LATEST_VERSION; if (!semverGt(to, VERSION)) return json(res, { version: to, changes: [] });
|
|
338
370
|
const md = await registryChangelog(to);
|
|
@@ -345,6 +377,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
345
377
|
// reconnects and reloads. Free the port first and mark the new copy as a
|
|
346
378
|
// relaunch, so it takes over instead of finding us and bowing out.
|
|
347
379
|
// SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
|
|
380
|
+
if (SANDBOX) return json(res, { error: "This is a sandbox (symbiot app --fresh): update your real Symbiot instead." });
|
|
348
381
|
const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
|
|
349
382
|
const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
|
|
350
383
|
const e = track("symbiot update", inst, homedir(), (code) => {
|
|
@@ -381,6 +414,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
381
414
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
382
415
|
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
383
416
|
setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
|
|
417
|
+
setTimeout(() => { try { knowledgeTick(); } catch {} }, 5000).unref(); setInterval(() => { try { knowledgeTick(); } catch {} }, 3 * 60 * 1000).unref(); // knowledge folders: changed files re-read (a stat per file when nothing changed)
|
|
418
|
+
setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
|
|
384
419
|
}
|
|
385
420
|
|
|
386
421
|
export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
|
package/tasks.mjs
CHANGED
|
@@ -4,35 +4,52 @@
|
|
|
4
4
|
// it shows.
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
10
|
-
import { handoverRules, ONLY_YOU } from "./handover.mjs";
|
|
9
|
+
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
10
|
+
import { handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
11
11
|
import { userStyleLine } from "./adapt.mjs";
|
|
12
|
-
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
|
|
12
|
+
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
|
|
13
13
|
import { gitDefaultBranch } from "./drift.mjs";
|
|
14
14
|
import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
|
|
15
15
|
|
|
16
16
|
// ---- tasks: a persistent checklist (stored by core.mjs) -------------------
|
|
17
17
|
// One line (TASKS.md has a task per line, and a tick only matches a whole one),
|
|
18
|
-
// long enough for a pasted list of next steps: 300 cut one off mid-list.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// long enough for a pasted list of next steps: 300 cut one off mid-list. Longer
|
|
19
|
+
// than a task holds (TASK_MAX), nothing is cut: the task keeps its whole text as
|
|
20
|
+
// written (full), and its line is how it starts plus a link to the rest, which
|
|
21
|
+
// goes out with its brief as .symbiot/full/<id>.md (pushTasks). after: what ends
|
|
22
|
+
// its line either way, "(handed over by coral)".
|
|
23
|
+
const LEAD_MAX = 600, FULL_DIR = "full";
|
|
24
|
+
const fullLink = (id) => ` Full text: \`.symbiot/${FULL_DIR}/${id}.md\``;
|
|
25
|
+
// A long task's start: its whole sentences up to LEAD_MAX, or its words.
|
|
26
|
+
function leadOf(text) {
|
|
27
|
+
const m = text.slice(0, LEAD_MAX + 1).match(/^.*[.!?](?=\s)/s);
|
|
28
|
+
return m && m[0].length >= LEAD_MAX / 3 ? m[0] + " …" : clipWords(text, LEAD_MAX);
|
|
29
|
+
}
|
|
30
|
+
function addTask(text, repo, { after = "" } = {}) {
|
|
31
|
+
const raw = String(text || "").trim(), tail = String(after || "").trim();
|
|
32
|
+
text = raw.replace(/\s+/g, " ");
|
|
21
33
|
if (!text) return { error: "empty" };
|
|
22
|
-
const
|
|
34
|
+
const long = text.length + tail.length + 1 > TASK_MAX, full = long ? raw + (tail ? "\n\n" + tail : "") : "";
|
|
35
|
+
const lineFor = (id) => (long ? leadOf(text) + fullLink(id) : text) + (tail ? " " + tail : "");
|
|
36
|
+
const t = loadTasks(), id = randomBytes(6).toString("hex"), line = lineFor(id);
|
|
23
37
|
// The same task already open in the same repo (in review counts), even in
|
|
24
38
|
// other words (sameTask): that task, not a duplicate. When the new wording has
|
|
25
39
|
// a clause more, the task takes it, unless it's in review (its tick is on the
|
|
26
40
|
// old words). Handed out already, a tick on either wording counts (syncTasks).
|
|
27
|
-
const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && sameTask(x.text,
|
|
28
|
-
if (dup && !dup.review && taskWords(
|
|
41
|
+
const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && sameTask(x.text, line));
|
|
42
|
+
if (dup && !dup.review && taskWords(full || line).length > taskWords(dup.full || dup.text).length) {
|
|
43
|
+
dup.text = lineFor(dup.id); if (long) dup.full = full; else delete dup.full;
|
|
44
|
+
saveTasks(t); return { ...dup, duplicate: true, reworded: true };
|
|
45
|
+
}
|
|
29
46
|
if (dup) return { ...dup, duplicate: true };
|
|
30
|
-
const item = { id:
|
|
47
|
+
const item = { id, text: line, ...(long ? { full } : {}), repo: repo || "", done: false, ts: Date.now() };
|
|
31
48
|
t.unshift(item); saveTasks(t); return item;
|
|
32
49
|
}
|
|
33
50
|
function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
|
|
34
51
|
function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
|
|
35
|
-
function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; if (it.removedBy) { it.kept = true; delete it.removedBy; delete it.merged; } saveTasks(t); } return it || { error: "not found" }; }
|
|
52
|
+
function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; if (it.removedBy || it.dropped) { it.kept = true; delete it.removedBy; delete it.merged; delete it.dropped; } saveTasks(t); } return it || { error: "not found" }; }
|
|
36
53
|
// Which task texts the agent checked off in a repo's .symbiot/TASKS.md
|
|
37
54
|
function completedInRepo(repoPath) {
|
|
38
55
|
try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
|
|
@@ -68,6 +85,19 @@ function applyRemovals(t) {
|
|
|
68
85
|
}
|
|
69
86
|
return n;
|
|
70
87
|
}
|
|
88
|
+
// A task the agent deleted from its TASKS.md (droppedTasks: the brief tells it
|
|
89
|
+
// to, only when the user says to drop one) is closed: archived as dropped, so
|
|
90
|
+
// the next brief doesn't bring it back. Only open tasks from before that brief,
|
|
91
|
+
// and never one you restored (kept). map: repo name → path.
|
|
92
|
+
function applyDrops(t, map) {
|
|
93
|
+
let n = 0; const now = Date.now(), seen = {};
|
|
94
|
+
for (const x of t) {
|
|
95
|
+
if (x.done || x.archived || x.review || x.kept || !x.repo || !map[x.repo]) continue;
|
|
96
|
+
const gone = seen[x.repo] || (seen[x.repo] = droppedTasks(map[x.repo]));
|
|
97
|
+
if (gone.some((d) => (x.ts || 0) <= d.at && sameTask(d.text, x.text))) { Object.assign(x, { done: true, archived: true, archivedAt: now, dropped: true }); n++; }
|
|
98
|
+
}
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
71
101
|
// "Check what was handed out, see what's completed, then archive it" — with an
|
|
72
102
|
// approval step in between. An agent ticking an item in TASKS.md means "done,
|
|
73
103
|
// please review", NOT archived: it waits in review until the user approves it
|
|
@@ -77,6 +107,7 @@ function applyRemovals(t) {
|
|
|
77
107
|
// starts on what's still open in them (one `push --open` started can't do that).
|
|
78
108
|
function syncTasks() {
|
|
79
109
|
const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
|
|
110
|
+
const dropped = applyDrops(t, map);
|
|
80
111
|
for (const x of t) {
|
|
81
112
|
if (x.archived || x.done || x.review || !x.repo) continue;
|
|
82
113
|
if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p && startHeldTasks(p)) started++; checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
|
|
@@ -85,7 +116,7 @@ function syncTasks() {
|
|
|
85
116
|
for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
|
|
86
117
|
const removed = applyRemovals(t);
|
|
87
118
|
saveTasks(t);
|
|
88
|
-
return { review, archived, started, removed };
|
|
119
|
+
return { review, archived, started, removed, dropped };
|
|
89
120
|
}
|
|
90
121
|
// git with an argv (task text goes into commit messages — never through a shell)
|
|
91
122
|
function git(repo, args, timeout = 30000) {
|
|
@@ -264,7 +295,7 @@ const NOT_OWN = /(^|\/)(package(-lock)?\.json|npm-shrinkwrap\.json|CHANGELOG\.md
|
|
|
264
295
|
function changelogEntry(text) {
|
|
265
296
|
let s = String(text || "").replace(/\s+/g, " ").trim();
|
|
266
297
|
const m = s.match(/^(.{40,}?(?<!\be\.g|\bi\.e|\betc|\bvs)[.!?])\s+(?=[^a-z])/); if (m && m[1].length <= ENTRY_MAX) s = m[1]; // not at "e.g. `x`" or "e.g. when"
|
|
267
|
-
return s
|
|
298
|
+
return clipWords(s, ENTRY_MAX);
|
|
268
299
|
}
|
|
269
300
|
function changelogSection(version, texts, files = [], day = new Date().toISOString().slice(0, 10), notes = null) {
|
|
270
301
|
const own = files.filter((f) => !NOT_OWN.test(f));
|
|
@@ -386,7 +417,7 @@ function commitSubject(texts) {
|
|
|
386
417
|
const more = texts.length > 1 ? ` (+${texts.length - 1} more)` : "", max = 72 - more.length;
|
|
387
418
|
const t = String(texts[0]).split("\n").find((l) => l.trim()) || "";
|
|
388
419
|
const s = t.replace(/^\s*(#+|[-*+]|\d+[.)])\s+/, "").replace(/\*\*|__/g, "").replace(/\s+/g, " ").trim() || `${texts.length} approved tasks`;
|
|
389
|
-
return (s
|
|
420
|
+
return clipWords(s, max) + more;
|
|
390
421
|
}
|
|
391
422
|
// Sync approved work: off the default branch onto symbiot/<task>, commit the
|
|
392
423
|
// working tree (minus .symbiot/), push, and open a PR with gh. On a branch whose
|
|
@@ -525,8 +556,8 @@ function buildTasksMd(name, ctx, list) {
|
|
|
525
556
|
const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
|
|
526
557
|
const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
|
|
527
558
|
for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
528
|
-
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
|
|
529
|
-
L.push(...handoverRules(ctx.lanes || [], name));
|
|
559
|
+
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
|
|
560
|
+
L.push(...handoverRules(ctx.lanes || [], name), ...HANDBACK);
|
|
530
561
|
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
531
562
|
`- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
|
|
532
563
|
"- Judge the options before you ask. Most people pick the recommended option without weighing the other, and Symbiot works for a whole company (developers, sales, everyone), not one person, so the choice is really yours. Both options must be good routes to the best solution, never filler or one you wouldn't take. Each says in plain words, with no jargon, what it does and what it changes from then on for the project, the people working on it and the company. Recommend the one that's best for, in this order, the company, the people doing the work, then the task's goal. Base that on evidence you can check here (git history, tests, logs, how it's used, the answers so far), not on what's quickest, and give that evidence in the context line in a sentence.",
|
|
@@ -541,12 +572,13 @@ function buildTasksMd(name, ctx, list) {
|
|
|
541
572
|
return L.join("\n") + "\n";
|
|
542
573
|
}
|
|
543
574
|
function pushTasks(filter) {
|
|
544
|
-
const
|
|
575
|
+
const byName = repoPathMap(); // from the already-scanned map when there is one
|
|
576
|
+
// what an approved Drop/Merge named, or an agent deleted from its brief, never goes out again
|
|
577
|
+
const all = loadTasks(); if (applyRemovals(all) + applyDrops(all, byName)) saveTasks(all);
|
|
545
578
|
let tasks = all.filter((t) => !t.done && !t.archived && !t.review);
|
|
546
579
|
if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
|
|
547
580
|
if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
|
|
548
581
|
if (!tasks.length) return { empty: true, written: [], unresolved: [] };
|
|
549
|
-
const byName = repoPathMap(); // from the already-scanned map when there is one
|
|
550
582
|
const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
|
|
551
583
|
const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
|
|
552
584
|
for (const name of Object.keys(groups)) {
|
|
@@ -560,6 +592,8 @@ function pushTasks(filter) {
|
|
|
560
592
|
const open = openWork([{ path, name }]).slice(0, 12);
|
|
561
593
|
const det = detectRepo({ path, name, recency: 0 });
|
|
562
594
|
const stack = [...det.langs.slice(0, 4), ...det.tools].join(", ");
|
|
595
|
+
// a long task's whole text, where its line links to (addTask)
|
|
596
|
+
for (const t of groups[name].filter((x) => x.full)) { mkdirSync(join(path, ".symbiot", FULL_DIR), { recursive: true }); writeFileSync(join(path, ".symbiot", FULL_DIR, t.id + ".md"), t.full.replace(/\s*$/, "\n")); }
|
|
563
597
|
const risk = [];
|
|
564
598
|
if (st.stale) risk.push("stale checkout — working tree is an old snapshot, not new work");
|
|
565
599
|
if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
|
|
@@ -572,4 +606,4 @@ function pushTasks(filter) {
|
|
|
572
606
|
return { empty: false, written, unresolved, handoff: handoffCmd() };
|
|
573
607
|
}
|
|
574
608
|
|
|
575
|
-
export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|
|
609
|
+
export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|