symbiot 0.44.0 → 0.45.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/mind.mjs CHANGED
@@ -23,9 +23,10 @@ import { randomBytes } from "node:crypto";
23
23
  import { VERSION, CONFIG_DIR } from "./core.mjs";
24
24
  import { write } from "./ai.mjs";
25
25
  import { handoffCmd, runHandoff } from "./agents.mjs";
26
- import { addTask } from "./tasks.mjs";
26
+ import { addTask, pushTasks } from "./tasks.mjs";
27
27
  import { repoPathMap } from "./scan.mjs";
28
28
  import { OPS, handoverRules, ONLY_YOU } from "./handover.mjs";
29
+ import { styleOf, styleLine, userStyleLine } from "./adapt.mjs";
29
30
 
30
31
  const MIND_FILE = join(CONFIG_DIR, "mind.json");
31
32
  const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
@@ -114,12 +115,14 @@ This folder isn't a repo; there's nothing to change in it but this file. Use you
114
115
 
115
116
  - **Anything hard to undo you only ask about:** closing or deleting an account, deleting data, paying or buying, sending something to someone. Find out what it would affect first (what's running, what it costs, what's in it), then stop and ask in \`.symbiot/QUESTIONS.md\` with what you found, and don't go ahead until ANSWERS.md says to.
116
117
  - Is part of it a repo's work? Hand it over (below) rather than doing it from here.
117
- - Need the user? Only for what no agent can do: ask in \`.symbiot/QUESTIONS.md\` (a \`## Questions\` heading, a \`### \` heading per question, then 2–4 options as \`- \` bullets).
118
+ - Need the user? Only for what no agent can do: ask in \`.symbiot/QUESTIONS.md\` (a \`## Questions\` heading, a \`### \` heading per question, then 2–4 options as \`- \` bullets).${voiceLine()}
118
119
  ${ONLY_YOU}
119
120
  - When it's done, tick the task (\`- [x]\`) and say what you did in your last message: it's what goes back to whoever asked.
120
121
 
121
122
  ${handoverRules(names, OPS).join("\n")}`;
122
123
  }
124
+ // How to write to the user, for a brief: their talking style, if it's known.
125
+ const voiceLine = () => { const v = userStyleLine(); return v ? "\n- Writing to the user (questions, your last message): " + v : ""; };
123
126
  // Run the coding agent on a request, in a folder of its own (it shows in the
124
127
  // Agents tab, with its questions). { ok, job, dir } or { error }.
125
128
  function actNow(request, { title, context, known, run = runHandoff, now = Date.now() } = {}) {
@@ -141,11 +144,39 @@ function actNow(request, { title, context, known, run = runHandoff, now = Date.n
141
144
 
142
145
  // ---- one voice --------------------------------------------------------------------
143
146
  const IDENTITY = `You are Symbiot, the one assistant in the user's Symbiot app. The Dashboard's cards, the Tasks list and the rest are only places they talk to you: it's all you, and you remember across them (see "What you know" and "Lately, elsewhere in the app").`;
144
- const RULES = `Reply with JSON only, no markdown fence:
147
+ // lanes: the repos "do" can point at; self: Symbiot's own repo among them, where
148
+ // a flaw in Symbiot itself is fixed.
149
+ function rulesFor(lanes = [], self = "") {
150
+ return `Reply with JSON only, no markdown fence:
145
151
  {"reply": "what you say, plain text, brief, no preamble",
146
- "do": null or {"agent": "what their coding agent should do now: self-contained, with names, links and ids"} or {"task": "a task for their list", "repo": "a repo name, or empty"},
152
+ "do": null or {"agent": "what a coding agent should do now: self-contained, with names, links and ids", "repo": "the lane it's for, or empty"} or {"task": "a task for later", "repo": "the lane it's for, or empty"},
147
153
  "remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
148
- When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" runs their coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). Use "task" for what's for later, or belongs in a repo's work. Don't ask their permission to hand it over: the agent asks them first, in the Agents tab, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null. "remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
154
+ When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, in the Agents tab, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
155
+ Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
156
+ Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
157
+ "remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
158
+ }
159
+ // Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
160
+ function selfLane(map) {
161
+ for (const [n, p] of Object.entries(map)) { try { if (JSON.parse(readFileSync(join(p, "package.json"), "utf8")).name === "symbiot") return n; } catch {} }
162
+ return "";
163
+ }
164
+ const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() === String(repo || "").trim().toLowerCase()) || "";
165
+ // Do it, in the right lane: a repo's work joins its tasks and its agent starts
166
+ // there, the way Send to repos does; anything else, an agent of its own (actNow).
167
+ function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, now = Date.now() } = {}) {
168
+ const lane = laneName(repo, map);
169
+ if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
170
+ const t = add(request, lane); if (t && t.error) return { error: t.error };
171
+ const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
172
+ const e = run(map[lane]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
173
+ return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
174
+ }
175
+ // For later: on the list, in a repo's lane if it names one Symbiot knows.
176
+ function taskIn(text, repo, { map = {}, add = addTask } = {}) {
177
+ const lane = laneName(repo, map), r = add(text, lane);
178
+ return r && r.error ? { error: r.error } : { ok: true, id: r.id, lane };
179
+ }
149
180
 
150
181
  function parseReply(raw) {
151
182
  const t = String(raw || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
@@ -158,9 +189,12 @@ function parseReply(raw) {
158
189
  // what this page is for; context: what the page shows; history: this chat's
159
190
  // own last turns. act: { agent(request) -> result, task(text, repo) -> result },
160
191
  // what "do" runs here. Gives { reply, did?, remembered }.
161
- async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, now = Date.now() }) {
192
+ async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
162
193
  const d = loadMind(), known = recallText(recall(question, now, d)), elsewhere = lately(where, d);
163
- const system = `${IDENTITY} ${role}\n\n${RULES}`;
194
+ let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
195
+ // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
196
+ const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
197
+ const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
164
198
  const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
165
199
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
166
200
  const raw = await ask(system, prompt);
@@ -168,12 +202,18 @@ async function converse({ where, role = "", context = "", history = "", question
168
202
  const j = parseReply(raw);
169
203
  let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
170
204
  const want = j.do && typeof j.do === "object" ? j.do : null;
205
+ // say where it actually went, from what happened, not from what the model meant
206
+ const repo = want ? String(want.repo || "") : "";
171
207
  if (want && want.agent && act.agent) {
172
- did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known)) };
173
- reply += did.error ? `\n\n(I couldn't hand it to your agent: ${did.error})` : "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
208
+ did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known, repo)) };
209
+ reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
210
+ : did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's in the Agents tab.`
211
+ : "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
174
212
  } else if (want && want.task && act.task) {
175
- did = { kind: "task", text: String(want.task).slice(0, 300), repo: String(want.repo || ""), ...(await act.task(String(want.task), String(want.repo || ""))) };
176
- reply += did.error ? `\n\n(I couldn't add the task: ${did.error})` : `\n\n→ Added to your tasks${did.repo ? ` for ${did.repo}` : ""}: ${did.text}`;
213
+ did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
214
+ reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
215
+ : did.lane ? `\n\n→ Added to ${did.lane}'s tasks: ${did.text}`
216
+ : `\n\n→ Added to your tasks, but not to a repo${repo ? ` (there's no lane called ${repo})` : ""}, so no agent will pick it up until it has one: ${did.text}`;
177
217
  }
178
218
  // read again: another chat may have written while the model answered
179
219
  const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
@@ -181,7 +221,7 @@ async function converse({ where, role = "", context = "", history = "", question
181
221
  saveMind(d2);
182
222
  return { reply, ...(did ? { did } : {}), remembered };
183
223
  }
184
- // The usual "task" act: added to your list (a repo's, if named).
185
- const addToTasks = (text, repo) => { const r = addTask(text, repo); return r && r.error ? { error: r.error } : { ok: true, id: r.id }; };
224
+ // The usual "task" act, for a caller with no lanes of its own to pass.
225
+ const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };
186
226
 
187
- export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, parseReply, converse, addToTasks };
227
+ export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.44.0",
3
+ "version": "0.45.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,12 @@
26
26
  "mind.mjs",
27
27
  "handover.mjs",
28
28
  "lanes.mjs",
29
+ "adapt.mjs",
30
+ "home.mjs",
29
31
  "phone.mjs",
30
32
  "desktop.mjs",
31
33
  "README.md",
34
+ "CHANGELOG.md",
32
35
  "LICENSE"
33
36
  ],
34
37
  "engines": {
@@ -62,7 +65,7 @@
62
65
  "@anthropic-ai/sdk": "^0.128.0"
63
66
  },
64
67
  "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",
68
+ "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/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
66
69
  "lint": "eslint ."
67
70
  },
68
71
  "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";
13
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList, startWaiting } from "./agents.mjs";
14
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
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 })); }
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)); }
@@ -178,6 +233,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
178
233
  }
179
234
  if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
180
235
  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 })); }
236
+ if (u.pathname === "/api/agents/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipIdea(String(b.path || ""), b.text)); }
181
237
  if (u.pathname === "/api/mail") return json(res, mailState());
182
238
  if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
183
239
  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) }); }
@@ -221,6 +277,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
221
277
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
222
278
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
223
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)); }
224
286
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
225
287
  if (u.pathname === "/api/lanes") return json(res, lanesState());
226
288
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
@@ -259,6 +321,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
259
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)); }
260
322
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
261
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." }); }
262
333
  if (u.pathname === "/api/update" && req.method === "POST") {
263
334
  // Install the exact newest version (see updateCmd), then relaunch this
264
335
  // same app (same port+token => same URL) and exit. The page's heartbeat
@@ -303,4 +374,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
303
374
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
304
375
  }
305
376
 
306
- 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,18 +251,94 @@ 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
  }
311
+ // What the last run in a folder said when it finished: agent.log after the last
312
+ // run's "=== name time ===" and "$ command" lines.
313
+ function runSummary(path) {
314
+ let log = ""; try { log = readFileSync(join(path, ".symbiot", "agent.log"), "utf8"); } catch { return ""; }
315
+ const at = log.lastIndexOf("\n=== "), rest = at < 0 ? log : log.slice(log.indexOf("\n", at + 1) + 1);
316
+ return rest.replace(/^\$ .*\n?/, "").trim().slice(-8000);
317
+ }
318
+ // Whether a run's summary says it finished a task: one of its lines (a bullet,
319
+ // a paragraph) has most of the task's words, and doesn't say it wasn't done. A
320
+ // guess the user confirms, so it leans to ticking: an unticked task only comes
321
+ // back as a task.
322
+ const NOT_DONE = /\b(didn['’]?t|did not|couldn['’]?t|could not|can['’]?t|cannot|wasn['’]?t|isn['’]?t|haven['’]?t|not (yet|done|finished|ticked|started)|still (open|to do|needs?)|left (it|them|alone)|blocked)\b/i;
323
+ const keyWords = (s) => new Set(taskWords(s).split(" ").filter((w) => w.length > 3).map((w) => w.replace(/s$/, "")));
324
+ function saidFinished(summary, text) {
325
+ const want = keyWords(text); if (want.size < 2) return false;
326
+ return String(summary || "").split(/\n/).some((l) => {
327
+ if (NOT_DONE.test(l)) return false;
328
+ const has = keyWords(l); let n = 0; for (const w of want) if (has.has(w)) n++;
329
+ return n >= Math.min(3, want.size) && n / want.size >= 0.4;
330
+ });
331
+ }
332
+ // The open tasks of a repo whose changes are approved without one: Approve
333
+ // offers to tick them, checked when the last run's summary says it finished them,
334
+ // so finished work doesn't go out again as new tasks on the next send.
335
+ function finishedOffer(path, open) {
336
+ const summary = runSummary(path);
337
+ return uniqueTasks(open.map((x) => x.text)).slice(0, 12).map((text) => { const x = open.find((y) => y.text === text); return { id: x.id, text, finished: saidFinished(summary, text) }; });
338
+ }
263
339
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
264
- // uncommitted changes no ticked task covers (untasked: approve them as-is).
340
+ // uncommitted changes no ticked task covers (untasked: approve them as-is, with
341
+ // `open`, the repo's open tasks, to tick the ones the run finished).
265
342
  // running: an agent is still editing there, so its changes may be half done.
266
343
  function pendingReview() {
267
344
  const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
@@ -271,7 +348,9 @@ function pendingReview() {
271
348
  const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) } : { branch: "", files: [], stat: "" }) }; });
272
349
  for (const repo of sent.sort()) {
273
350
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
274
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
351
+ const wc = workingChanges(path); if (!wc.files.length) continue;
352
+ const open = finishedOffer(path, t.filter((x) => x.repo === repo && !x.done && !x.archived && !x.review));
353
+ out.push({ repo, path, tasks: [], untasked: true, open, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
275
354
  }
276
355
  return out;
277
356
  }
@@ -389,14 +468,24 @@ function approveRepo(repo, opts = {}) {
389
468
  }
390
469
  // "Approve changes without a task": ship the uncommitted changes even though no
391
470
  // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
392
- // awaiting review in the repo, this is just Approve.
471
+ // awaiting review in the repo, this is just Approve. opts.tick: ids of the
472
+ // repo's open tasks the changes finished (the run didn't tick them): they're
473
+ // approved with the changes, as Approve does, so they don't go out again.
393
474
  function approveChanges(repo, opts = {}) {
394
475
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
395
476
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
396
477
  const busy = stillWorking(repo, path); if (busy) return busy;
397
478
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
398
- const r = shipWithBump(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
399
- return r.error ? r : { ...r, approved: 0 };
479
+ const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
480
+ const ticked = (t) => t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
481
+ const r = shipWithBump(path, uniqueTasks(ticked(loadTasks()).map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
482
+ if (r.error) return r;
483
+ // read again: the ship took a while, and the list may have changed meanwhile
484
+ const t = loadTasks(), items = ticked(t), now = Date.now();
485
+ for (const x of items) { 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]; }
486
+ const removed = items.length ? applyRemovals(t) : 0;
487
+ if (items.length) saveTasks(t);
488
+ return { ...r, approved: items.length, ...(removed ? { removed } : {}) };
400
489
  }
401
490
  // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
402
491
  function sendBack(id) {
@@ -431,6 +520,7 @@ function buildTasksMd(name, ctx, list) {
431
520
  if (ctx.commits && ctx.commits.length) { L.push("- **Recent commits:**"); for (const c of ctx.commits) L.push(` - ${c}`); }
432
521
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
433
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}`);
434
524
  L.push("", "## Tasks");
435
525
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
436
526
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
@@ -441,9 +531,10 @@ function buildTasksMd(name, ctx, list) {
441
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.`,
442
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.",
443
533
  ONLY_YOU,
534
+ ...(userStyleLine() ? ["- Writing to the user (questions, options, your last message): " + userStyleLine()] : []),
444
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.",
445
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.",
446
- `- 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.`,
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.`,
447
538
  "- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
448
539
  "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
449
540
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
@@ -457,7 +548,7 @@ function pushTasks(filter) {
457
548
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
458
549
  const byName = repoPathMap(); // from the already-scanned map when there is one
459
550
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
460
- const written = [], unresolved = [];
551
+ const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
461
552
  for (const name of Object.keys(groups)) {
462
553
  const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
463
554
  if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
@@ -474,11 +565,11 @@ function pushTasks(filter) {
474
565
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
475
566
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
476
567
  // held: an agent is still running there, so it lands when that one exits
477
- 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));
478
569
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
479
570
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
480
571
  }
481
572
  return { empty: false, written, unresolved, handoff: handoffCmd() };
482
573
  }
483
574
 
484
- export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, 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 };