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/CHANGELOG.md +450 -0
- package/README.md +63 -2
- package/adapt.mjs +222 -0
- package/agents.mjs +74 -8
- package/core.mjs +18 -1
- package/headless.mjs +8 -0
- package/home.mjs +74 -0
- package/lanes.mjs +5 -3
- package/mind.mjs +54 -14
- package/package.json +5 -2
- package/server.mjs +78 -7
- package/tasks.mjs +104 -13
- package/ui.mjs +243 -11
- package/watch.mjs +52 -21
- package/writeups.mjs +38 -11
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
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),
|
|
176
|
-
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
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
|
|
185
|
-
const addToTasks = (text, repo) => {
|
|
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.
|
|
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()[
|
|
160
|
-
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[
|
|
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 {
|
|
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)
|
|
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],
|
|
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)
|
|
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
|
|
399
|
-
|
|
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 };
|