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/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
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}) }); }
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
- function addTask(text, repo) {
20
- text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
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 t = loadTasks();
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, text));
28
- if (dup && !dup.review && taskWords(text).length > taskWords(dup.text).length) { dup.text = text; saveTasks(t); return { ...dup, duplicate: true, reworded: true }; }
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: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
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.length > ENTRY_MAX ? s.slice(0, ENTRY_MAX - 1).replace(/\s+\S*$/, "") + "…" : 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.length > max ? s.slice(0, max - 1).replace(/\s+\S*$/, "") + "…" : s) + more;
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 all = loadTasks(); if (applyRemovals(all)) saveTasks(all); // what an approved Drop/Merge named never goes out again
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 };