symbiot 0.55.0 → 0.55.2
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 +18 -0
- package/README.md +133 -52
- package/agents.mjs +136 -6
- package/away.mjs +18 -12
- package/guard.mjs +6 -2
- package/handback.mjs +2 -2
- package/handover.mjs +14 -3
- package/home.mjs +183 -33
- package/index.mjs +4 -4
- package/lanes.mjs +6 -6
- package/marketing.mjs +130 -0
- package/mind.mjs +9 -9
- package/package.json +4 -3
- package/reports.mjs +3 -3
- package/scan.mjs +9 -2
- package/server.mjs +28 -13
- package/tasks.mjs +14 -10
- package/ui.mjs +309 -128
- package/watch.mjs +5 -5
- package/writeups.mjs +3 -3
package/marketing.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// symbiot — Marketing: a lane of its own, for marketing work across every product the
|
|
2
|
+
// user makes (Symbiot, Dailify, the agent products…), not one repo's. Tasks go to it as
|
|
3
|
+
// "marketing" (a lane, the way ops is one), from the Marketing page, any chat, or
|
|
4
|
+
// another lane's handover; its agent works in ~/.config/symbiot/marketing, a git repo
|
|
5
|
+
// of its own (local only, no remote), so what it drafts is reviewed and approved like
|
|
6
|
+
// any repo's work (Approve commits it there). Each item is tagged with the product it
|
|
7
|
+
// markets: "[Dailify] …" as written, else the product its words name. Home shows the
|
|
8
|
+
// lane as an orb among the projects, lit when something there needs you (home.mjs
|
|
9
|
+
// marketingState). Low level on purpose: scan.mjs (laneMap) imports it.
|
|
10
|
+
import { join, relative, basename } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { CONFIG_DIR } from "./core.mjs";
|
|
14
|
+
import { MARKETING } from "./handover.mjs";
|
|
15
|
+
|
|
16
|
+
const MARKETING_DIR = join(CONFIG_DIR, "marketing");
|
|
17
|
+
const DRAFTS = "drafts";
|
|
18
|
+
const README = `# Marketing
|
|
19
|
+
|
|
20
|
+
Symbiot's marketing lane: posts, demo videos and screenshots, launches and campaigns for
|
|
21
|
+
every product, not one repo's. Its agent drafts here, one folder per product under
|
|
22
|
+
\`${DRAFTS}/\`; you review and approve each piece under Marketing in Symbiot, and post it
|
|
23
|
+
yourself. Nothing here is ever posted, published or sent by an agent.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// The lane's folder, made the first time a task goes to it: a README (its name on the
|
|
27
|
+
// Workdesk), .symbiot kept out of git, and a first commit, so Approve has a branch to
|
|
28
|
+
// commit on. Your git name and email when you have them, else Symbiot's.
|
|
29
|
+
function ensureMarketing(dir = MARKETING_DIR) {
|
|
30
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(join(dir, DRAFTS), { recursive: true });
|
|
33
|
+
if (!existsSync(join(dir, "README.md"))) writeFileSync(join(dir, "README.md"), README);
|
|
34
|
+
if (!existsSync(join(dir, ".gitignore"))) writeFileSync(join(dir, ".gitignore"), ".symbiot/\n");
|
|
35
|
+
} catch { return ""; }
|
|
36
|
+
const git = (args, env = {}) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", timeout: 20000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } });
|
|
37
|
+
if (git(["init", "-q", "-b", "main"]).status !== 0 && git(["init", "-q"]).status !== 0) return "";
|
|
38
|
+
const me = String(git(["config", "user.email"]).stdout || "").trim();
|
|
39
|
+
const who = me ? {} : { GIT_AUTHOR_NAME: "Symbiot", GIT_AUTHOR_EMAIL: "symbiot@localhost", GIT_COMMITTER_NAME: "Symbiot", GIT_COMMITTER_EMAIL: "symbiot@localhost" };
|
|
40
|
+
git(["add", "-A"]);
|
|
41
|
+
git(["commit", "-q", "-m", "Marketing: a lane of its own"], who);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A project's name as people say it (Home's droplets, and the products to tag by): the
|
|
46
|
+
// folder can lag behind (CallForge AI is Dailify). The README's title when it's a short name, else package.json's name
|
|
47
|
+
// when it's a word, else the folder's. Read once per folder.
|
|
48
|
+
const NAMES = new Map();
|
|
49
|
+
function displayName(path, folder) {
|
|
50
|
+
folder = String(folder || ""); if (!path) return folder;
|
|
51
|
+
if (NAMES.has(path)) return NAMES.get(path);
|
|
52
|
+
const read = (f) => { try { return readFileSync(join(path, f), "utf8"); } catch { return ""; } };
|
|
53
|
+
const plainName = (s) => { s = String(s || "").replace(/[*`[\]]|^_+|_+$/g, "").trim(); return s && s.length <= 32 && s.split(/\s+/).length <= 4 && !/[:—–|]|\s-\s/.test(s) ? s : ""; };
|
|
54
|
+
const h1 = plainName((read("README.md").match(/^#\s+(.+?)\s*#*\s*$/m) || [])[1]);
|
|
55
|
+
let pkg = ""; try { pkg = String(JSON.parse(read("package.json")).name || "").replace(/^@[^/]+\//, ""); } catch {}
|
|
56
|
+
const name = h1 || (/^[a-z][a-z0-9-]{1,30}$/i.test(pkg) && pkg.toLowerCase() !== folder.toLowerCase().replace(/\s+/g, "-") ? pkg.charAt(0).toUpperCase() + pkg.slice(1) : "") || folder;
|
|
57
|
+
NAMES.set(path, name);
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
// The products to tag by: each repo's name as people say it, capitalised.
|
|
61
|
+
function productNames(map = {}) {
|
|
62
|
+
const out = new Set();
|
|
63
|
+
for (const [n, p] of Object.entries(map)) { if (n === MARKETING) continue; const d = displayName(p, n); if (d) out.add(d.charAt(0).toUpperCase() + d.slice(1)); }
|
|
64
|
+
return [...out];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- product tags -------------------------------------------------------------------
|
|
68
|
+
// "[Dailify] Write the launch post": the tag as written. Without one, the first product
|
|
69
|
+
// its words name as it's written (capitalised: "Dailify" in a sentence, not "server").
|
|
70
|
+
const TAG = /^\s*\[([^\]\n]{2,40})\]\s*/;
|
|
71
|
+
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
|
+
function productOf(text, names = []) {
|
|
73
|
+
const t = String(text || ""), m = t.match(TAG); if (m) return m[1].trim();
|
|
74
|
+
let best = "", at = Infinity;
|
|
75
|
+
for (const n of names) {
|
|
76
|
+
const s = String(n || "").trim(); if (s.length < 3 || !/^[A-Z0-9]/.test(s)) continue;
|
|
77
|
+
const r = new RegExp(`(^|[^A-Za-z0-9])${reEsc(s)}(?![A-Za-z0-9])`).exec(t);
|
|
78
|
+
if (r && r.index < at) { at = r.index; best = s; }
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
const untagged = (text) => String(text || "").replace(TAG, "");
|
|
83
|
+
// A task's text with its product's tag first (one tag: a new one replaces the old).
|
|
84
|
+
function tagged(text, product) {
|
|
85
|
+
const p = String(product || "").replace(/[[\]\n]/g, "").trim().slice(0, 40), t = untagged(text).trim();
|
|
86
|
+
return p ? `[${p}] ${t}` : t;
|
|
87
|
+
}
|
|
88
|
+
// What counts as marketing in another lane's tasks: posting, launches, campaigns, pricing.
|
|
89
|
+
const MARKETING_WORDS = /\b(marketing|symbiot post|posting|posts? (?:to|on) linkedin|linkedin posts?|social (?:media )?posts?|launch|campaign|audience|newsletter|brand|pricing|4-week test|landing page|drafts to post|demo video)\b/i;
|
|
90
|
+
|
|
91
|
+
// ---- what its agent has drafted -------------------------------------------------------
|
|
92
|
+
// drafts/<product>/<name>.md (two levels down at most), newest first: [{ file, name,
|
|
93
|
+
// product, at }]. Its product: the "product:" line at its top, else its folder's name.
|
|
94
|
+
function draftFiles(dir = MARKETING_DIR, names = [], { max = 30 } = {}) {
|
|
95
|
+
const root = join(dir, DRAFTS), out = [];
|
|
96
|
+
const walk = (d, depth) => {
|
|
97
|
+
let ents = []; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
98
|
+
for (const e of ents) {
|
|
99
|
+
if (e.name.startsWith(".")) continue;
|
|
100
|
+
const p = join(d, e.name);
|
|
101
|
+
if (e.isDirectory()) { if (depth < 2) walk(p, depth + 1); continue; }
|
|
102
|
+
if (!/\.md$/i.test(e.name)) continue;
|
|
103
|
+
let head = "", at = 0; try { head = readFileSync(p, "utf8").slice(0, 600); at = statSync(p).mtimeMs; } catch {}
|
|
104
|
+
const tagLine = (head.match(/^\s*product:\s*(.+)$/im) || [])[1], folder = relative(root, d).split("/")[0] || "";
|
|
105
|
+
const product = (tagLine || "").trim() || names.find((n) => n.toLowerCase() === folder.toLowerCase()) || (folder ? folder.charAt(0).toUpperCase() + folder.slice(1) : "");
|
|
106
|
+
const title = (head.match(/^#\s+(.+)$/m) || [])[1];
|
|
107
|
+
out.push({ file: p, rel: relative(dir, p), name: (title || basename(e.name, ".md").replace(/[-_]+/g, " ")).trim().slice(0, 120), product, at });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(root, 0);
|
|
111
|
+
return out.sort((a, b) => b.at - a.at).slice(0, max);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- its brief ----------------------------------------------------------------------------
|
|
115
|
+
// The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
|
|
116
|
+
// its tasks name and where each one's code and docs are, and its rules.
|
|
117
|
+
function marketingBrief(list = [], { map = {}, names = productNames(map) } = {}) {
|
|
118
|
+
const named = [...new Set(list.map((t) => productOf(t.text, names)).filter(Boolean))];
|
|
119
|
+
// its repo: by folder, or by its name as people say it (Dailify is in CallForge AI)
|
|
120
|
+
const pathOf = (p) => { const k = Object.keys(map).find((n) => n !== MARKETING && map[n] && [n, basename(map[n]), displayName(map[n], n)].some((x) => x.toLowerCase() === p.toLowerCase())); return k ? map[k] : ""; };
|
|
121
|
+
const where = named.map((p) => ({ p, path: pathOf(p) }));
|
|
122
|
+
return ["## This lane: marketing",
|
|
123
|
+
"This lane markets every product the user makes, not one repo's: posts, demo videos and screenshots, launches, landing-page copy, campaigns. This folder is the lane's own (a local git repo, no remote): what you write here is reviewed and approved under Marketing in Symbiot.",
|
|
124
|
+
...(where.length ? ["- **Products in these tasks** (read each one's README and docs for what it is and its end goal):", ...where.map((w) => ` - ${w.p}${w.path ? `: \`${w.path}\`` : " (not one of the user's repos here: ask in QUESTIONS.md where it lives, or hand it over)"}`)] : []),
|
|
125
|
+
`- Write each piece as a \`.md\` under \`${DRAFTS}/<product>/\` (\`${DRAFTS}/dailify/launch-post.md\`), a \`# \` title first, then \`product: <Product>\`, so the Marketing page tags it. Its screenshots and clips go next to it.`,
|
|
126
|
+
"- Every post opens with a strong hook (its first line written for reach), says plainly what the product is, and states its end goal. Where the end goal isn't written down, draft one and flag it in QUESTIONS.md for the user to confirm.",
|
|
127
|
+
"- Never post, publish, schedule or send anything, and never sign in anywhere as the user: they approve each draft under Marketing and post it themselves. A post that waits on their OK is a question in QUESTIONS.md, with what to check first."];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief };
|
package/mind.mjs
CHANGED
|
@@ -24,8 +24,8 @@ import { VERSION, CONFIG_DIR, clipWords } from "./core.mjs";
|
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
25
|
import { handoffCmd, runHandoff } from "./agents.mjs";
|
|
26
26
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
27
|
-
import {
|
|
28
|
-
import { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
27
|
+
import { laneMap } from "./scan.mjs";
|
|
28
|
+
import { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
29
29
|
import { styleOf, styleLine, userStyleLine } from "./adapt.mjs";
|
|
30
30
|
import { knowledgeFor } from "./knowledge.mjs";
|
|
31
31
|
|
|
@@ -99,7 +99,7 @@ function forget(id) {
|
|
|
99
99
|
// The brief for a run of its own: the request, what the page showed, what's
|
|
100
100
|
// known, and the rule that anything hard to undo is asked first.
|
|
101
101
|
function actBrief(request, { title = "Symbiot", context = "", known = "", lanes = null, now = Date.now() } = {}) {
|
|
102
|
-
let names = lanes; if (!names) { try { names = Object.keys(
|
|
102
|
+
let names = lanes; if (!names) { try { names = Object.keys(laneMap()); } catch { names = []; } }
|
|
103
103
|
const short = clipWords(request.replace(/\s+/g, " "), 160); // its task line; the whole request is quoted above it
|
|
104
104
|
return `# For your agent: ${title}
|
|
105
105
|
_written by symbiot ${VERSION} · ${new Date(now).toISOString().slice(0, 10)}_
|
|
@@ -153,8 +153,8 @@ function rulesFor(lanes = [], self = "") {
|
|
|
153
153
|
{"reply": "what you say, plain text, brief, no preamble",
|
|
154
154
|
"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"},
|
|
155
155
|
"remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
|
|
156
|
-
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,
|
|
157
|
-
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
|
+
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, on the Workdesk, 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.
|
|
157
|
+
Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${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.` : ""}
|
|
158
158
|
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.
|
|
159
159
|
"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.`;
|
|
160
160
|
}
|
|
@@ -201,7 +201,7 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
201
201
|
// your knowledge folders (knowledge.mjs): a few short excerpts, each with its file to cite; never examples
|
|
202
202
|
let kn = { text: "", steps: [] }; try { kn = knowledgeFor(question); } catch {}
|
|
203
203
|
steps.push(...kn.steps);
|
|
204
|
-
let lanes = map; if (!lanes) { try { lanes =
|
|
204
|
+
let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
|
|
205
205
|
// how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
|
|
206
206
|
const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
|
|
207
207
|
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
|
|
@@ -217,8 +217,8 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
217
217
|
if (want && want.agent && act.agent) {
|
|
218
218
|
did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
|
|
219
219
|
reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
|
|
220
|
-
: 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
|
|
221
|
-
: "\n\n→ Handed to your agent. It's
|
|
220
|
+
: 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 on the Workdesk.`
|
|
221
|
+
: "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
222
222
|
} else if (want && want.task && act.task) {
|
|
223
223
|
did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
|
|
224
224
|
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
@@ -235,6 +235,6 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
235
235
|
return { reply, ...(did ? { did } : {}), remembered, steps };
|
|
236
236
|
}
|
|
237
237
|
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
238
|
-
const addToTasks = (text, repo) => { let map = {}; try { map =
|
|
238
|
+
const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
239
239
|
|
|
240
240
|
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.55.
|
|
3
|
+
"version": "0.55.2",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"fonts",
|
|
44
44
|
"mapknn.mjs",
|
|
45
45
|
"guard.mjs",
|
|
46
|
-
"away.mjs"
|
|
46
|
+
"away.mjs",
|
|
47
|
+
"marketing.mjs"
|
|
47
48
|
],
|
|
48
49
|
"engines": {
|
|
49
50
|
"node": ">=18"
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
77
78
|
},
|
|
78
79
|
"scripts": {
|
|
79
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
80
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
80
81
|
"lint": "eslint ."
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
package/reports.mjs
CHANGED
|
@@ -20,10 +20,10 @@ import { createHash } from "node:crypto";
|
|
|
20
20
|
import { CONFIG_DIR } from "./core.mjs";
|
|
21
21
|
import { runFolders, laneOf, runTitle } from "./handback.mjs";
|
|
22
22
|
import { runningHandoff } from "./agents.mjs";
|
|
23
|
-
import {
|
|
23
|
+
import { laneMap } from "./scan.mjs";
|
|
24
24
|
|
|
25
25
|
const SEEN_FILE = join(CONFIG_DIR, "reports.json");
|
|
26
|
-
const NOT_REPORTS = /^(?:TASKS
|
|
26
|
+
const NOT_REPORTS = /^(?:TASKS(?:\.[\w-]+)?|QUESTIONS|ANSWERS|HANDOFF|SKIPPED|CLAUDE|AGENTS)\.md$|^BRIEF/i; // TASKS.next.md too: the tasks held for after a run
|
|
27
27
|
const MAX_READ = 512 * 1024, MAX_LIST = 200;
|
|
28
28
|
const idOf = (file) => createHash("sha1").update(file).digest("hex").slice(0, 12);
|
|
29
29
|
|
|
@@ -40,7 +40,7 @@ function titleOf(text, name) {
|
|
|
40
40
|
// run, size, mtime, new, running }]. `folders`, `running` and `seen` can be given
|
|
41
41
|
// (the tests do).
|
|
42
42
|
function listReports({ map, folders, running = runningHandoff, seen } = {}) {
|
|
43
|
-
if (!folders) { map = map ||
|
|
43
|
+
if (!folders) { map = map || laneMap(); folders = runFolders(map); }
|
|
44
44
|
const s = (seen || loadSeen()).seen, out = [];
|
|
45
45
|
for (const folder of folders) {
|
|
46
46
|
const dir = join(folder, ".symbiot");
|
package/scan.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { VERSION, loadConfig, saveConfig, sh, repoState } from "./core.mjs";
|
|
|
10
10
|
import { detectHandoffs } from "./agents.mjs";
|
|
11
11
|
import { PROVIDERS, resolveProvider } from "./ai.mjs";
|
|
12
12
|
import { mapKnn } from "./mapknn.mjs";
|
|
13
|
+
import { MARKETING, MARKETING_DIR } from "./marketing.mjs";
|
|
13
14
|
|
|
14
15
|
const MAX_COMMITS = 140;
|
|
15
16
|
|
|
@@ -444,7 +445,8 @@ async function buildMapScan() {
|
|
|
444
445
|
if (scanExpired()) break;
|
|
445
446
|
await yieldTick();
|
|
446
447
|
const det = detectFolder(d); folders++; scanTick(det.name);
|
|
447
|
-
|
|
448
|
+
let last = ""; try { last = new Date(statSync(d).mtimeMs).toISOString().slice(0, 10); } catch {} // the Map sorts folders by it, as repos by their last commit
|
|
449
|
+
add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files, last } });
|
|
448
450
|
edges.push({ source: "me", target: "folder:" + d });
|
|
449
451
|
for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
|
|
450
452
|
} }
|
|
@@ -547,6 +549,11 @@ function repoPathMap() {
|
|
|
547
549
|
return byName;
|
|
548
550
|
}
|
|
549
551
|
|
|
552
|
+
// The lanes a task can go to: every repo and project folder, and Marketing, a lane of
|
|
553
|
+
// its own for marketing across products (marketing.mjs). A repo of yours called
|
|
554
|
+
// marketing keeps the name. Counting your repos is repoPathMap's job, not this.
|
|
555
|
+
function laneMap() { const m = repoPathMap(); if (!Object.keys(m).some((n) => n.toLowerCase() === MARKETING)) m[MARKETING] = MARKETING_DIR; return m; }
|
|
556
|
+
|
|
550
557
|
// The SAME repo set the Map uses — reuse its cached scan when present, else do
|
|
551
558
|
// the identical discovery. So week/standup/todo/drift all agree with the Map.
|
|
552
559
|
function discoveredRepos() {
|
|
@@ -554,4 +561,4 @@ function discoveredRepos() {
|
|
|
554
561
|
return findAllRepos();
|
|
555
562
|
}
|
|
556
563
|
|
|
557
|
-
export { SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
|
|
564
|
+
export { laneMap, SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
|
package/server.mjs
CHANGED
|
@@ -14,9 +14,9 @@ import { createServer } from "node:http";
|
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
16
16
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
17
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
|
|
17
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
|
|
18
18
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
19
|
-
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
19
|
+
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap } from "./scan.mjs";
|
|
20
20
|
import { computeDrift } from "./drift.mjs";
|
|
21
21
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
22
22
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
@@ -30,7 +30,8 @@ import { mindState, forget } from "./mind.mjs";
|
|
|
30
30
|
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
31
31
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
32
32
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
33
|
-
import { homeState, homeAsk, homeAnswer, workScene, workGo, firstSteps } from "./home.mjs";
|
|
33
|
+
import { homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, firstSteps, marketingState, marketingGo, marketingTask, moveToMarketing } from "./home.mjs";
|
|
34
|
+
import { MARKETING_DIR } from "./marketing.mjs";
|
|
34
35
|
import { listReports, readReport, markAllRead } from "./reports.mjs";
|
|
35
36
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
36
37
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
@@ -214,6 +215,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
214
215
|
const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
215
216
|
const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
|
|
216
217
|
let UPDATING = null; // an update in flight: { target, attempt, retrying? }
|
|
218
|
+
let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
|
|
217
219
|
process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
|
|
218
220
|
const server = createServer(async (req, res) => {
|
|
219
221
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
@@ -254,9 +256,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
254
256
|
if (!ask.length) return json(res, partly(list));
|
|
255
257
|
await learnNpm(ask); return json(res, partly(pendingReview()));
|
|
256
258
|
}
|
|
257
|
-
if (u.pathname === "/api/pending/diff") { const p =
|
|
258
|
-
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([
|
|
259
|
-
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([
|
|
259
|
+
if (u.pathname === "/api/pending/diff") { const p = laneMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
260
|
+
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
|
|
261
|
+
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[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 })); }
|
|
260
262
|
if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
|
|
261
263
|
if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
|
|
262
264
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
@@ -276,8 +278,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
276
278
|
if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
|
|
277
279
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
|
|
278
280
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
279
|
-
if (u.pathname === "/api/agent/trust") return json(res, { full: trustFull() });
|
|
280
|
-
if (u.pathname === "/api/agent/trust/set" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); if (b.full) delete cfg.agentTrust; else cfg.agentTrust = "ask"; saveConfig(cfg); return json(res, { full: trustFull() }); }
|
|
281
|
+
if (u.pathname === "/api/agent/trust") return json(res, { full: trustFull(), sandbox: sandboxState() });
|
|
282
|
+
if (u.pathname === "/api/agent/trust/set" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); if (b.full) delete cfg.agentTrust; else cfg.agentTrust = "ask"; saveConfig(cfg); return json(res, { full: trustFull(), sandbox: sandboxState() }); }
|
|
281
283
|
if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
|
|
282
284
|
if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || ""), { force: !!b.force }); return json(res, { opened: !!e && !e.busy && !e.blocked, busy: !!(e && e.busy), auto: !!(e && e.auto), blocked: !!(e && e.blocked), note: (e && e.note) || "", id: (e && e.id) || "" }); }
|
|
283
285
|
if (u.pathname === "/api/setup-local" && req.method === "POST") {
|
|
@@ -347,8 +349,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
347
349
|
const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
|
|
348
350
|
let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
|
|
349
351
|
if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
|
|
350
|
-
|
|
352
|
+
let lane = null; try { lane = marketingState(); } catch {}
|
|
353
|
+
return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t, lane });
|
|
351
354
|
}
|
|
355
|
+
// Marketing's own lane (marketing.mjs): a task for it, tagged with its product; another
|
|
356
|
+
// lane's marketing task moved to it; its agent started; a draft its agent wrote, opened
|
|
357
|
+
if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
|
|
358
|
+
if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
|
|
359
|
+
if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
|
|
360
|
+
if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
|
|
352
361
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
353
362
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
|
|
354
363
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -374,14 +383,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
374
383
|
if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
|
|
375
384
|
if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
|
|
376
385
|
if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
|
|
386
|
+
if (u.pathname === "/api/home/next" && req.method === "POST") { const b = await readBody(req); return json(res, homeNext(b.id)); }
|
|
377
387
|
if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
|
|
378
388
|
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" } : {}) }));
|
|
379
389
|
if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
|
|
380
390
|
// Lanes (lanes.mjs): work agents handed to each other, and where it stands.
|
|
381
391
|
if (u.pathname === "/api/lanes") return json(res, lanesState());
|
|
382
392
|
// a parked project (lane) starts no agent runs until it's unparked (agents.mjs parkLane)
|
|
383
|
-
if (u.pathname === "/api/lanes/parked") { const map =
|
|
384
|
-
if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map =
|
|
393
|
+
if (u.pathname === "/api/lanes/parked") { const map = laneMap(), ps = parkedPaths(); return json(res, { repos: Object.keys(map).filter((n) => ps.includes(map[n])), paths: ps }); }
|
|
394
|
+
if (u.pathname === "/api/lanes/park" && req.method === "POST") { const b = await readBody(req), map = laneMap(), p = Object.values(map).includes(String(b.path || "")) ? String(b.path) : map[String(b.repo || "")]; if (!p) return json(res, { error: "That project isn't on this computer." }); return json(res, parkLane(p, b.on !== false)); }
|
|
385
395
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
386
396
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
387
397
|
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
@@ -418,7 +428,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
418
428
|
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)); }
|
|
419
429
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
420
430
|
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install
|
|
421
|
-
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
431
|
+
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1") NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
422
432
|
// What's new: after an update, since the version you last saw (until you click Got it);
|
|
423
433
|
// ?latest=1, what the update on offer brings, from its package on npm
|
|
424
434
|
if (u.pathname === "/api/whatsnew") {
|
|
@@ -473,7 +483,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
473
483
|
server.on("error", (e) => {
|
|
474
484
|
// Stable port busy (an older instance still exiting during an update, or a
|
|
475
485
|
// second app): retry briefly, then fall back to a random port.
|
|
476
|
-
if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
|
|
486
|
+
if (e && e.code === "EADDRINUSE" && tries < (RELAUNCH ? 40 : 8)) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
|
|
487
|
+
// after an update, the window only ever looks for the stable port: a copy on another
|
|
488
|
+
// port would serve nobody and linger (one did, for hours), so it bows out
|
|
489
|
+
else if (RELAUNCH) process.exit(0);
|
|
477
490
|
else { try { server.listen(0, "127.0.0.1"); } catch {} }
|
|
478
491
|
});
|
|
479
492
|
server.listen(PORT, "127.0.0.1");
|
|
@@ -487,6 +500,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
487
500
|
// an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
|
|
488
501
|
setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
|
|
489
502
|
setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();
|
|
503
|
+
// a finished run's last words, read by your AI for anything left to you that the patterns missed (agents.mjs readLastWords)
|
|
504
|
+
setInterval(() => { readLastWords().catch(() => {}); }, 60000).unref();
|
|
490
505
|
// knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
|
|
491
506
|
const knowTick = () => { try { const r = knowledgeTick(); if (r && (r.read || r.removed || !checksState().at)) runChecks(); } catch {} };
|
|
492
507
|
setTimeout(knowTick, 5000).unref(); setInterval(knowTick, 3 * 60 * 1000).unref();
|
package/tasks.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { userStyleLine } from "./adapt.mjs";
|
|
|
12
12
|
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
|
|
13
13
|
import { gitDefaultBranch } from "./drift.mjs";
|
|
14
14
|
import { readRunLog } from "./work.mjs";
|
|
15
|
-
import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
|
|
15
|
+
import { repoPathMap, laneMap, openWork, detectRepo } from "./scan.mjs";
|
|
16
|
+
import { MARKETING_DIR, ensureMarketing, marketingBrief } from "./marketing.mjs";
|
|
16
17
|
|
|
17
18
|
// ---- tasks: a persistent checklist (stored by core.mjs) -------------------
|
|
18
19
|
// One line (TASKS.md has a task per line, and a tick only matches a whole one),
|
|
@@ -107,7 +108,7 @@ function applyDrops(t, map) {
|
|
|
107
108
|
// Tasks held for an agent that has since finished land here too, and an agent
|
|
108
109
|
// starts on what's still open in them (one `push --open` started can't do that).
|
|
109
110
|
function syncTasks() {
|
|
110
|
-
const t = loadTasks(); const map =
|
|
111
|
+
const t = loadTasks(); const map = laneMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
|
|
111
112
|
const dropped = applyDrops(t, map);
|
|
112
113
|
for (const x of t) {
|
|
113
114
|
if (x.archived || x.done || x.review || !x.repo) continue;
|
|
@@ -375,7 +376,7 @@ function finishedOffer(path, open) {
|
|
|
375
376
|
function pendingReview() {
|
|
376
377
|
const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
|
|
377
378
|
const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
|
|
378
|
-
const map = Object.keys(by).length || sent.length ?
|
|
379
|
+
const map = Object.keys(by).length || sent.length ? laneMap() : {};
|
|
379
380
|
const am = autoMergeRepos();
|
|
380
381
|
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: "" }) }; });
|
|
381
382
|
for (const repo of sent.sort()) {
|
|
@@ -497,7 +498,7 @@ function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArr
|
|
|
497
498
|
function approveRepo(repo, opts = {}) {
|
|
498
499
|
const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
|
|
499
500
|
if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
|
|
500
|
-
const path =
|
|
501
|
+
const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
|
|
501
502
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
502
503
|
const r = shipWithBump(path, uniqueTasks(items.map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
503
504
|
if (r.error) return r;
|
|
@@ -514,7 +515,7 @@ function approveRepo(repo, opts = {}) {
|
|
|
514
515
|
// approved with the changes, as Approve does, so they don't go out again.
|
|
515
516
|
function approveChanges(repo, opts = {}) {
|
|
516
517
|
if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
|
|
517
|
-
const path =
|
|
518
|
+
const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
|
|
518
519
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
519
520
|
if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
|
|
520
521
|
const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
|
|
@@ -532,7 +533,7 @@ function approveChanges(repo, opts = {}) {
|
|
|
532
533
|
function sendBack(id) {
|
|
533
534
|
const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
|
|
534
535
|
it.review = false; delete it.reviewAt; saveTasks(t);
|
|
535
|
-
const path = it.repo &&
|
|
536
|
+
const path = it.repo && laneMap()[it.repo];
|
|
536
537
|
if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && sameTask(l.replace(/^\s*-\s*\[x\]\s*/i, ""), it.text) ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
|
|
537
538
|
return it;
|
|
538
539
|
}
|
|
@@ -562,13 +563,14 @@ function buildTasksMd(name, ctx, list) {
|
|
|
562
563
|
if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
|
|
563
564
|
if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
|
|
564
565
|
if (ctx.connectors) L.push(`- **Connectors:** ${ctx.connectors}`);
|
|
566
|
+
if (ctx.about && ctx.about.length) L.push("", ...ctx.about);
|
|
565
567
|
L.push("", "## Tasks");
|
|
566
568
|
const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
|
|
567
569
|
const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
|
|
568
570
|
for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
569
571
|
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
|
|
570
572
|
L.push(...handoverRules(ctx.lanes || [], name), ...HANDBACK);
|
|
571
|
-
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its
|
|
573
|
+
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Workdesk, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
572
574
|
`- 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.`,
|
|
573
575
|
"- 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.",
|
|
574
576
|
ONLY_YOU,
|
|
@@ -582,7 +584,7 @@ function buildTasksMd(name, ctx, list) {
|
|
|
582
584
|
return L.join("\n") + "\n";
|
|
583
585
|
}
|
|
584
586
|
function pushTasks(filter) {
|
|
585
|
-
const byName =
|
|
587
|
+
const byName = laneMap(); // from the already-scanned map when there is one, and Marketing (marketing.mjs)
|
|
586
588
|
// what an approved Drop/Merge named, or an agent deleted from its brief, never goes out again
|
|
587
589
|
const all = loadTasks(); let changed = applyRemovals(all) + applyDrops(all, byName);
|
|
588
590
|
// what an agent ticked since the Tasks tab last looked goes to review first, as
|
|
@@ -603,7 +605,7 @@ function pushTasks(filter) {
|
|
|
603
605
|
const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
|
|
604
606
|
for (const name of Object.keys(groups)) {
|
|
605
607
|
const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
|
|
606
|
-
if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
|
|
608
|
+
if (!path || (path === MARKETING_DIR && !ensureMarketing(path))) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
|
|
607
609
|
try {
|
|
608
610
|
// Cheap, per-repo signals only — no full drift scan (that can be very slow
|
|
609
611
|
// on big repos and would block the request).
|
|
@@ -619,7 +621,9 @@ function pushTasks(filter) {
|
|
|
619
621
|
if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
|
|
620
622
|
if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
|
|
621
623
|
// held: an agent is still running there, so it lands when that one exits
|
|
622
|
-
|
|
624
|
+
// Marketing's brief says what the lane is, the products its tasks name and where they are
|
|
625
|
+
const about = path === MARKETING_DIR ? marketingBrief(list, { map: byName }) : [];
|
|
626
|
+
const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName), connectors, about }, list));
|
|
623
627
|
written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
|
|
624
628
|
} catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
|
|
625
629
|
}
|