symbiot 0.44.1 → 0.46.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.44.1",
3
+ "version": "0.46.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": {
@@ -26,9 +26,13 @@
26
26
  "mind.mjs",
27
27
  "handover.mjs",
28
28
  "lanes.mjs",
29
+ "adapt.mjs",
30
+ "home.mjs",
31
+ "work.mjs",
29
32
  "phone.mjs",
30
33
  "desktop.mjs",
31
34
  "README.md",
35
+ "CHANGELOG.md",
32
36
  "LICENSE"
33
37
  ],
34
38
  "engines": {
@@ -62,7 +66,7 @@
62
66
  "@anthropic-ai/sdk": "^0.128.0"
63
67
  },
64
68
  "scripts": {
65
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
69
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/adapt.mjs && node test/work.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
66
70
  "lint": "eslint ."
67
71
  },
68
72
  "devDependencies": {
package/server.mjs CHANGED
@@ -5,17 +5,19 @@
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { gunzipSync } from "node:zlib";
8
10
  import { readFileSync, existsSync } from "node:fs";
9
11
  import { createServer } from "node:http";
10
12
  import { randomBytes } from "node:crypto";
11
13
  import { EMBEDDED_UI } from "./ui.mjs";
12
- import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
14
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
13
15
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } from "./agents.mjs";
14
16
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
15
17
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
16
18
  import { computeDrift } from "./drift.mjs";
17
- import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
18
- import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce } from "./writeups.mjs";
19
+ import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
20
+ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
19
21
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
22
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
21
23
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
@@ -23,6 +25,8 @@ import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, chec
23
25
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
24
26
  import { mindState, forget } from "./mind.mjs";
25
27
  import { lanesTick, lanesState } from "./lanes.mjs";
28
+ import { adaptState, noteUse } from "./adapt.mjs";
29
+ import { homeState, homeAsk } from "./home.mjs";
26
30
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
27
31
 
28
32
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -34,6 +38,57 @@ function updateCmd(latest, current, platform = process.platform) {
34
38
  return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
35
39
  }
36
40
 
41
+ // ---- what's new: the changelog's releases between two versions -------------------
42
+ // CHANGELOG.md's "## 0.45.0 — 2026-10-06" sections newer than `from` and up to
43
+ // `to`, newest first: [{ version, date, items }]. It ships with Symbiot, so after an
44
+ // update the app shows once what came with it (config.seenVersion is the version
45
+ // you last saw it for). Before an update, the new version's own CHANGELOG.md is
46
+ // read from its package on npm (registryChangelog).
47
+ const CHANGELOG = fileURLToPath(new URL("./CHANGELOG.md", import.meta.url));
48
+ function changesSince(md, from, to) {
49
+ const out = [];
50
+ for (const part of String(md || "").split(/^## /m).slice(1)) {
51
+ const m = part.match(/^\[?v?(\d+\.\d+\.\d+)\]?(?:\s*[—–-]\s*(\d{4}-\d{2}-\d{2}))?/); if (!m) continue;
52
+ if ((from && !semverGt(m[1], from)) || (to && semverGt(m[1], to))) continue;
53
+ out.push({ version: m[1], date: m[2] || "", items: part.split("\n").filter((l) => /^[-*]\s+/.test(l)).map((l) => l.replace(/^[-*]\s+/, "").trim()).slice(0, 30) });
54
+ }
55
+ return out.slice(0, 15);
56
+ }
57
+ const localChangelog = () => { try { return readFileSync(CHANGELOG, "utf8"); } catch { return ""; } };
58
+ // After an update: what's new since the version you last saw it for. The first
59
+ // time (no seenVersion yet) that's just this version's own release.
60
+ function whatsNew(cfg = loadConfig(), md = localChangelog()) {
61
+ const seen = typeof cfg.seenVersion === "string" ? cfg.seenVersion : "";
62
+ if (seen && !semverGt(VERSION, seen)) return { version: VERSION, changes: [] };
63
+ const all = changesSince(md, "", VERSION);
64
+ return { version: VERSION, changes: seen ? changesSince(md, seen, VERSION) : all.slice(0, 1) };
65
+ }
66
+ // A file in a package tarball (.tgz: gzip, then tar's 512-byte headers, each
67
+ // followed by its file's bytes), "" if it isn't there.
68
+ function tarFile(tgz, name) {
69
+ let buf; try { buf = gunzipSync(tgz); } catch { return ""; }
70
+ const str = (a, b, at) => buf.subarray(at + a, at + b).toString("utf8").replace(/\0[\s\S]*$/, "");
71
+ for (let at = 0; at + 512 <= buf.length;) {
72
+ if (!buf[at]) break;
73
+ const size = parseInt(str(124, 136, at).trim() || "0", 8) || 0, pre = str(345, 500, at), path = (pre ? pre + "/" : "") + str(0, 100, at);
74
+ if (path === name) return buf.subarray(at + 512, at + 512 + size).toString("utf8");
75
+ at += 512 + Math.ceil(size / 512) * 512;
76
+ }
77
+ return "";
78
+ }
79
+ // The new version's CHANGELOG.md, from its package on npm (kept once found).
80
+ const REGISTRY_LOGS = new Map();
81
+ async function registryChangelog(version, registry = REGISTRY) {
82
+ if (REGISTRY_LOGS.has(version)) return REGISTRY_LOGS.get(version);
83
+ let md = "";
84
+ try {
85
+ const r = await fetch(`${registry}/symbiot/-/symbiot-${encodeURIComponent(version)}.tgz`, { signal: AbortSignal.timeout(15000) });
86
+ if (r.ok) md = tarFile(Buffer.from(await r.arrayBuffer()), "package/CHANGELOG.md");
87
+ } catch {}
88
+ if (md) REGISTRY_LOGS.set(version, md);
89
+ return md;
90
+ }
91
+
37
92
  // ---- `symbiot app` : the same UI in a chrome-less browser window ----------
38
93
  // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
39
94
  // template literal). Talks to the local API with the per-launch token.
@@ -156,8 +211,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
156
211
  await learnNpm(ask); return json(res, pendingReview());
157
212
  }
158
213
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
159
- if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
160
- if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump, tick: b.tick })); }
214
+ if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
215
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump, tick: b.tick })); return json(res, approveChanges(repo, { bump: b.bump, tick: b.tick, notes })); }
161
216
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
162
217
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
163
218
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -222,6 +277,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
222
277
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
223
278
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
224
279
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
280
+ // Home (home.mjs): the liquid's live data, and its talk; Adapt (adapt.mjs): its
281
+ // shape from how you use it (commit=1 when it wakes from rest, never mid-gesture).
282
+ if (u.pathname === "/api/home") return json(res, homeState({ fresh: u.searchParams.get("fresh") === "1" }));
283
+ if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
284
+ if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
285
+ if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
225
286
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
226
287
  if (u.pathname === "/api/lanes") return json(res, lanesState());
227
288
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
@@ -260,6 +321,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
260
321
  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)); }
261
322
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
262
323
  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 } : {}) }); }
324
+ // What's new: after an update, since the version you last saw (until you click Got it);
325
+ // ?latest=1, what the update on offer brings, from its package on npm
326
+ if (u.pathname === "/api/whatsnew") {
327
+ if (u.searchParams.get("latest") !== "1") return json(res, whatsNew());
328
+ const to = LATEST_VERSION; if (!semverGt(to, VERSION)) return json(res, { version: to, changes: [] });
329
+ const md = await registryChangelog(to);
330
+ return json(res, { version: to, changes: changesSince(md, VERSION, to), ...(md ? {} : { error: `Couldn't read ${to}'s changelog from npm.` }) });
331
+ }
332
+ if (u.pathname === "/api/whatsnew/seen" && req.method === "POST") { const cfg = loadConfig(); cfg.seenVersion = VERSION; return json(res, saveConfig(cfg) ? { ok: true } : { error: "Couldn't write the config file." }); }
263
333
  if (u.pathname === "/api/update" && req.method === "POST") {
264
334
  // Install the exact newest version (see updateCmd), then relaunch this
265
335
  // same app (same port+token => same URL) and exit. The page's heartbeat
@@ -304,4 +374,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
304
374
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
305
375
  }
306
376
 
307
- export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
377
+ export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
package/tasks.mjs CHANGED
@@ -8,7 +8,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
10
10
  import { handoverRules, ONLY_YOU } from "./handover.mjs";
11
- import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
11
+ import { userStyleLine } from "./adapt.mjs";
12
+ import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
12
13
  import { gitDefaultBranch } from "./drift.mjs";
13
14
  import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
14
15
 
@@ -250,14 +251,61 @@ function setVersion(path, to) {
250
251
  }
251
252
  return () => { for (const [file, text] of saved) try { writeFileSync(file, text); } catch {} };
252
253
  }
254
+ // ---- the changelog: what each release brought ---------------------------------
255
+ // A repo that keeps a CHANGELOG.md gets a section for each version Approve bumps
256
+ // to, "## 0.45.0 — 2026-10-06", newest first, with what was approved: each task
257
+ // (near-duplicates once), up to its first sentence. Changes approved without a
258
+ // task are named by the files they touch. With an AI connected, Approve has it
259
+ // word them as release notes first (writeups.mjs releaseNotes): opts.notes, one
260
+ // per task. Symbiot ships its own, and the app shows it as what's new in an
261
+ // update (server.mjs changesSince).
262
+ const ENTRY_MAX = 220;
263
+ const NOT_OWN = /(^|\/)(package(-lock)?\.json|npm-shrinkwrap\.json|CHANGELOG\.md)$/;
264
+ function changelogEntry(text) {
265
+ let s = String(text || "").replace(/\s+/g, " ").trim();
266
+ 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;
268
+ }
269
+ function changelogSection(version, texts, files = [], day = new Date().toISOString().slice(0, 10), notes = null) {
270
+ const own = files.filter((f) => !NOT_OWN.test(f));
271
+ const items = notes && notes.length ? [...new Set(notes)] : texts.length ? uniqueTasks(texts.map(changelogEntry))
272
+ : [own.length ? `Changes approved without a task, in ${own.slice(0, 6).join(", ")}${own.length > 6 ? ` and ${own.length - 6} more` : ""}.` : "Version bump only."];
273
+ return `## ${version} — ${day}\n\n${items.map((x) => "- " + x).join("\n")}\n`;
274
+ }
275
+ // Put the section in above the newest one (after the file's own heading and
276
+ // intro), once per version. Gives back a function that restores the file.
277
+ function noteChangelog(path, version, texts, files, notes) {
278
+ const file = join(path, "CHANGELOG.md"); let text = "";
279
+ try { text = readFileSync(file, "utf8"); } catch { return () => {}; }
280
+ if (new RegExp(`^##\\s+\\[?v?${version.replace(/\./g, "\\.")}\\b`, "m").test(text)) return () => {};
281
+ const at = text.search(/^## /m), sec = changelogSection(version, texts, files, undefined, notes) + "\n";
282
+ try { writeFileSync(file, at < 0 ? text.replace(/\s*$/, "\n\n") + sec : text.slice(0, at) + sec + text.slice(at)); } catch { return () => {}; }
283
+ return () => { try { writeFileSync(file, text); } catch {} };
284
+ }
285
+ // What Approve will put in the changelog, for your AI to word (writeups.mjs
286
+ // releaseNotes): the tasks it ships, in the order it ships them, and the files
287
+ // and diff behind them. null when it won't write one: no bump, no CHANGELOG.md,
288
+ // or the agent is still working there. opts as approveRepo / approveChanges take them.
289
+ function releaseInput(repo, opts = {}) {
290
+ const path = repoPathMap()[repo];
291
+ if (!path || !(opts.bump === "patch" || opts.bump === "minor") || !bumpOffer(path) || !existsSync(join(path, "CHANGELOG.md")) || runningHandoff(path)) return null;
292
+ const t = loadTasks(), ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
293
+ const review = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
294
+ const items = review.length ? review : t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
295
+ const files = workingChanges(path).files.map((f) => f.file).filter((f) => !NOT_OWN.test(f));
296
+ const texts = uniqueTasks(items.map((x) => x.text));
297
+ return texts.length || files.length ? { path, texts, files, diff: workingDiff(path, 30000) } : null;
298
+ }
253
299
  // Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
254
- // is offered one (bumpOffer). The bump is put back if the ship fails.
300
+ // is offered one (bumpOffer), noted in its CHANGELOG.md if it keeps one (in
301
+ // opts.notes' words, when given). Both are put back if the ship fails.
255
302
  function shipWithBump(path, texts, opts) {
256
303
  const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
257
304
  if (!offer) return shipChanges(path, texts, opts);
258
- const to = offer[opts.bump], undo = setVersion(path, to);
305
+ const to = offer[opts.bump], files = workingChanges(path).files.map((f) => f.file);
306
+ const undo = setVersion(path, to), undoLog = noteChangelog(path, to, texts, files, opts.notes);
259
307
  const r = shipChanges(path, texts, { ...opts, bumped: to });
260
- if (r.error) { undo(); return r; }
308
+ if (r.error) { undo(); undoLog(); return r; }
261
309
  return { ...r, bumped: to };
262
310
  }
263
311
  // What the last run in a folder said when it finished: agent.log after the last
@@ -472,6 +520,7 @@ function buildTasksMd(name, ctx, list) {
472
520
  if (ctx.commits && ctx.commits.length) { L.push("- **Recent commits:**"); for (const c of ctx.commits) L.push(` - ${c}`); }
473
521
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
474
522
  if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
523
+ if (ctx.connectors) L.push(`- **Connectors:** ${ctx.connectors}`);
475
524
  L.push("", "## Tasks");
476
525
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
477
526
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
@@ -482,6 +531,7 @@ function buildTasksMd(name, ctx, list) {
482
531
  `- 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.`,
483
532
  "- 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.",
484
533
  ONLY_YOU,
534
+ ...(userStyleLine() ? ["- Writing to the user (questions, options, your last message): " + userStyleLine()] : []),
485
535
  "- A `👤 You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
486
536
  "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
487
537
  `- Ideas, options or follow-ups outside these tasks go under \`## Suggestions\` as \`- \` bullets. The user can add them to their tasks in one click, and often adds every one, so judge them the same way: only ideas you'd recommend, best first, each saying what it changes and why it's worth doing. Symbiot shows them ${IDEAS_SHOWN} at a time. Ideas the user turned down are in \`.symbiot/SKIPPED.md\`: don't suggest them again.`,
@@ -498,7 +548,7 @@ function pushTasks(filter) {
498
548
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
499
549
  const byName = repoPathMap(); // from the already-scanned map when there is one
500
550
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
501
- const written = [], unresolved = [];
551
+ const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
502
552
  for (const name of Object.keys(groups)) {
503
553
  const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
504
554
  if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
@@ -515,11 +565,11 @@ function pushTasks(filter) {
515
565
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
516
566
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
517
567
  // held: an agent is still running there, so it lands when that one exits
518
- const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName) }, list));
568
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName), connectors }, list));
519
569
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
520
570
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
521
571
  }
522
572
  return { empty: false, written, unresolved, handoff: handoffCmd() };
523
573
  }
524
574
 
525
- export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
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 };