symbiot 0.55.1 → 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/handback.mjs CHANGED
@@ -33,7 +33,7 @@ import { remember, actIn } from "./mind.mjs";
33
33
  import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
34
34
  import { inboxMail } from "./mail.mjs";
35
35
  import { desktopNotify } from "./desktop.mjs";
36
- import { repoPathMap } from "./scan.mjs";
36
+ import { laneMap } from "./scan.mjs";
37
37
 
38
38
  const AWAIT_FILE = join(CONFIG_DIR, "awaiting.json");
39
39
  const RUNS_DIR = join(CONFIG_DIR, "drafts"); // runs of their own (act-…) and drafted replies
@@ -151,7 +151,7 @@ function handOn(w, r, { map, act = actIn, notify = desktopNotify, now = Date.now
151
151
  // One pass, from the app every minute: take in new waits, look for their
152
152
  // replies, and hand on the ones that came in. `news`, `inbox`, `act`, `notify`
153
153
  // and `running` can be given (the tests do).
154
- function awaitTick({ map = repoPathMap(), now = Date.now(), news, inbox, act, notify, running } = {}) {
154
+ function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, act, notify, running } = {}) {
155
155
  const d = loadWaits(), added = collectWaits(d, { map, running, now }), open = d.waits.filter((w) => w.status === "waiting");
156
156
  const replied = [];
157
157
  if (open.length) {
package/handover.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  // buildTasksMd, mind.mjs actBrief), by lanes.mjs and by handback.mjs.
8
8
 
9
9
  const OPS = "ops";
10
+ // Marketing across every product, a lane of its own too (marketing.mjs)
11
+ const MARKETING = "marketing";
10
12
  const MAX_LANES_LISTED = 40;
11
13
  // How long a handover can be: an email to draft, quoted whole, fits. 4000 cut
12
14
  // the one to ops mid-email (a repo lane's task links to the rest: addTask).
@@ -15,10 +17,11 @@ const HANDOVER_MAX = 20000;
15
17
  // The lines a brief gets about lanes. here: this agent's lane; lanes: the others
16
18
  // it can hand to (repo folder names).
17
19
  function handoverRules(lanes = [], here = "") {
18
- const others = [...new Set(lanes.filter((l) => l && l !== here))].slice(0, MAX_LANES_LISTED);
20
+ const mk = lanes.includes(MARKETING) && here !== MARKETING, others = [...new Set(lanes.filter((l) => l && l !== here && l !== MARKETING))].slice(0, MAX_LANES_LISTED);
19
21
  return ["## Hand over what's another lane's job",
20
22
  `Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it, and its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
21
23
  `- \`${OPS}\`: anything outside a repo: this computer (find or install a tool, a JDK, an SDK), accounts and services (cloud, GitHub, DNS, email), the user's connectors.`,
24
+ ...(mk ? [`- \`${MARKETING}\`: marketing for any product: posts, demo videos and screenshots, launches, landing-page copy, campaigns. It drafts them for the user to approve, and never posts.`] : []),
22
25
  `- A repo, by its folder name, for work that belongs in that project${others.length ? `: ${others.map((l) => `\`${l}\``).join(", ")}` : ""}.`,
23
26
  ""];
24
27
  }
@@ -69,6 +72,14 @@ const ACTS = /\b(?:send|sent|sending|post|posted|publish|delete|remove|pay|close
69
72
  const CATCH = /\b(?:catch|before you|check|make sure|careful|fix (?:that|it)|first)\b/i;
70
73
  const NOT_THEIRS = /\bconnectors?\b|\bclaude\.ai\b/i; // a connector to sign in again shows on Home already
71
74
  const SENDS = /draft|reply|email|mail|message|post/i;
75
+ // "when / if / once / until …" before it: it says how something behaves, not that
76
+ // something waits on the user now ("It's lit amber when something needs you: … work
77
+ // waiting for your OK" described a feature, and showed as a step only you could do).
78
+ const COND = /\b(?:when|whenever|if|once|until|unless)\b/i;
79
+ function asksNow(s) {
80
+ for (const re of [HELD, YOURS]) { const m = re.exec(s); if (m) { const c = COND.exec(s); if (!c || c.index > m.index) return true; } }
81
+ return false;
82
+ }
72
83
  function leftToYou(text) {
73
84
  const points = String(text || "").replace(/```[\s\S]*?```/g, (m) => m.replace(/\s+/g, " ")).split(/\n+|\s+-\s+(?=\*\*)/).map((p) => p.replace(/^\s*[-*•]\s+/, "").trim()).filter(Boolean)
74
85
  .map((p) => { const lm = p.match(/^\*\*([^*]{2,60}?):?\*\*:?\s*/), body = lm ? p.slice(lm[0].length) : p;
@@ -76,7 +87,7 @@ function leftToYou(text) {
76
87
  const clean = (x, n) => { x = String(x || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); return x.length > n ? x.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : x; };
77
88
  for (let k = 0; k < points.length; k++) {
78
89
  const { label, ss, off } = points[k]; if (off) continue;
79
- const i = ss.findIndex((s) => !DID.test(s) && (HELD.test(s) || YOURS.test(s)));
90
+ const i = ss.findIndex((s) => !DID.test(s) && asksNow(s));
80
91
  if (i < 0) continue;
81
92
  const s = ss[i], next = points[k + 1] && !points[k + 1].off ? points[k + 1].ss : [];
82
93
  // what to check first: the catch it named (in this point, then the next), else what it says next
@@ -104,4 +115,4 @@ function parseHandoffs(md) {
104
115
  return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
105
116
  }
106
117
 
107
- export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
118
+ export { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
package/home.mjs CHANGED
@@ -11,42 +11,28 @@
11
11
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
12
12
  import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
- import { readFileSync, writeFileSync } from "node:fs";
15
- import { watchBoard } from "./watch.mjs";
16
- import { pendingReview, pushTasks } from "./tasks.mjs";
14
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { watchBoard, draftReply } from "./watch.mjs";
16
+ import { pendingReview, pushTasks, addTask } from "./tasks.mjs";
17
17
  import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
18
18
  import { linksState } from "./links.mjs";
19
19
  import { knowledgeState } from "./knowledge.mjs";
20
- import { CONFIG_DIR, loadTasks } from "./core.mjs";
20
+ import { CONFIG_DIR, loadTasks, saveTasks } from "./core.mjs";
21
21
  import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
22
- import { converse, actIn, actNow, taskIn } from "./mind.mjs";
23
- import { repoPathMap } from "./scan.mjs";
22
+ import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
23
+ import { repoPathMap, laneMap } from "./scan.mjs";
24
24
  import { resolveProvider } from "./ai.mjs";
25
- import { reportsNews } from "./reports.mjs";
25
+ import { reportsNews, listReports } from "./reports.mjs";
26
26
  import { awaitingState, inboxSight } from "./handback.mjs";
27
27
  import { newClashes } from "./checks.mjs";
28
28
  import { postsState } from "./post.mjs";
29
+ import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles } from "./marketing.mjs";
29
30
 
30
31
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
31
32
  let cached = null;
32
33
  const RUNS = join(CONFIG_DIR, "drafts"); // an ops run's folder (mind.mjs actNow): its own lane, not a repo
33
34
  const RUNS_LANE = "ops"; // what the work scene calls them all, together
34
35
 
35
- // A project's name as people say it: the folder can lag behind (CallForge AI is
36
- // Dailify). The README's title when it's a short name, else package.json's name
37
- // when it's a word, else the folder's. Read once per folder.
38
- const NAMES = new Map();
39
- function displayName(path, folder) {
40
- folder = String(folder || ""); if (!path) return folder;
41
- if (NAMES.has(path)) return NAMES.get(path);
42
- const read = (f) => { try { return readFileSync(join(path, f), "utf8"); } catch { return ""; } };
43
- const plainName = (s) => { s = String(s || "").replace(/[*`[\]]|^_+|_+$/g, "").trim(); return s && s.length <= 32 && s.split(/\s+/).length <= 4 && !/[:—–|]|\s-\s/.test(s) ? s : ""; };
44
- const h1 = plainName((read("README.md").match(/^#\s+(.+?)\s*#*\s*$/m) || [])[1]);
45
- let pkg = ""; try { pkg = String(JSON.parse(read("package.json")).name || "").replace(/^@[^/]+\//, ""); } catch {}
46
- 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;
47
- NAMES.set(path, name);
48
- return name;
49
- }
50
36
  // The lane a folder is: the repo it is (by the scan's map), or an ops run.
51
37
  const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
52
38
 
@@ -85,6 +71,165 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
85
71
  }
86
72
  return out;
87
73
  }
74
+
75
+ // ---- next up: what can be done when nothing waits on you ----------------------------
76
+ // With "Only the user can do" empty, Home suggests a few things that CAN be done next,
77
+ // each with its gain spelled out and one tap that starts an agent or makes a task
78
+ // (homeNext), from what Symbiot already knows: unread reports, a task with no project,
79
+ // a handover that errored and was left, a project's tasks waiting days for its agent,
80
+ // new mail or chats with no reply, and a decision you made that no task carries yet.
81
+ // One you took stays away a week (NEXT_FILE); the app lets you dismiss the box.
82
+ const NEXT_FILE = join(CONFIG_DIR, "home-next.json"), NEXT_MAX = 5, NEXT_KEEP = 7 * 86400000, DAY = 86400000;
83
+ const STALE_AFTER = 3 * DAY, RETRY_WITHIN = 14 * DAY, DECIDED_WITHIN = 14 * DAY;
84
+ function loadNext(file = NEXT_FILE) { try { const d = JSON.parse(readFileSync(file, "utf8")); return d && typeof d.taken === "object" && d.taken ? d : { taken: {} }; } catch { return { taken: {} }; } }
85
+ function saveNext(d, file = NEXT_FILE) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(file, JSON.stringify(d), { mode: 0o600 }); } catch {} }
86
+ // a remembered decision that asks for something to be done ("the user wants…", "decided to…"), not a fact about how things are
87
+ const WANTS = /\b(?:wants?|decided|asked for|plans? to|going to|agreed to)\b/i;
88
+ const words4 = (s) => new Set(String(s || "").toLowerCase().match(/[a-z0-9]{4,}/g) || []);
89
+ // The lane a task's words name (whatsapp_module for "…the WhatsApp module…"), or "".
90
+ function laneNamed(text, lanes) {
91
+ const t = " " + String(text || "").toLowerCase().replace(/[^a-z0-9]+/g, " ") + " ";
92
+ let best = "", len = 0;
93
+ for (const l of lanes) { const k = l.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); if (k.length >= 4 && k.length > len && t.includes(" " + k + " ")) { best = l; len = k.length; } }
94
+ return best;
95
+ }
96
+ const ago = (ms) => { const d = Math.floor(ms / DAY); return d >= 2 ? `${d} days` : d === 1 ? "a day" : "today"; };
97
+ // [{ id, title, gain, time, act: "agent"|"task"|"drafts"|"retry"|"go"|"assign", label }], best first.
98
+ function nextUp({ now = Date.now(), map = {}, list = [], named = displayName, deps = {} } = {}) {
99
+ const tryOr = (f, d) => { try { return f(); } catch { return d; } };
100
+ const taken = (deps.taken || (() => loadNext().taken))() || {};
101
+ const fresh = (id) => !(taken[id] && now - taken[id] < NEXT_KEEP);
102
+ const lanes = Object.keys(map), out = [];
103
+ const busy = new Set(list.filter((a) => a.status === "running").map((a) => laneOfPath(a.path, map)).filter(Boolean));
104
+ const parked = new Set(tryOr(deps.parked || parkedPaths, []) || []);
105
+ const allTasks = (tryOr(deps.tasks || loadTasks, []) || []).filter(Boolean), tasks = allTasks.filter((t) => !t.done && !t.archived && !t.review);
106
+ // unread reports: an agent reads them all and leaves one page, their next steps as ideas you add in a click
107
+ const rep = tryOr(deps.reports || reportsNews, { count: 0 }) || { count: 0 };
108
+ if (rep.count && fresh("next:reports")) out.push({ id: "next:reports", title: `Triage the ${rep.count} unread report${rep.count > 1 ? "s" : ""}`, gain: `clears the backlog: one page to read, and their next steps as tasks you add in a click`, time: `~${Math.max(5, Math.min(30, Math.ceil(rep.count * 1.25 / 5) * 5))} min, by an agent`, act: "agent", label: "Start an agent" });
109
+ // a handover that errored and was left (older than Home's asks): run it on its own
110
+ const retry = (tryOr(() => (deps.stuck || stuckHandovers)({ now, within: RETRY_WITHIN }), []) || []).filter((h) => fresh("next:retry:" + h.id));
111
+ for (const h of retry.slice(0, 1)) out.push({ id: "next:retry:" + h.id, title: `Retry ${h.from.lane}'s handover: ${plain(firstLine(h.text), 70)}`, gain: `it errored ${ago(now - (h.at || now))} ago (${plain(String(h.error || "").replace(/[.\s]+$/, ""), 60) || "it didn't start"}), and ${h.from.lane}'s agent carries on once it's done`, time: "runs by itself", act: "retry", label: "Retry it" });
112
+ // a project's tasks waiting days for its agent, nothing at work there
113
+ const byRepo = {};
114
+ for (const t of tasks) if (t.repo && map[t.repo] && !busy.has(t.repo) && !parked.has(map[t.repo])) (byRepo[t.repo] = byRepo[t.repo] || []).push(t);
115
+ const stale = Object.keys(byRepo).map((r) => ({ repo: r, n: byRepo[r].length, oldest: Math.min(...byRepo[r].map((t) => Number(t.ts) || now)) }))
116
+ .filter((g) => now - g.oldest > STALE_AFTER && fresh("next:go:" + g.repo)).sort((a, b) => a.oldest - b.oldest);
117
+ for (const g of stale.slice(0, 2)) out.push({ id: "next:go:" + g.repo, title: `Send ${named(map[g.repo], g.repo)}'s ${g.n} waiting task${g.n > 1 ? "s" : ""} to its agent`, gain: `the oldest has waited ${ago(now - g.oldest)}, and nothing is at work there`, time: "runs by itself", act: "go", label: "Start its agent" });
118
+ // a task with no project: the project its words name gets it, else an agent of its own
119
+ for (const t of tasks.filter((x) => !x.repo && fresh("next:float:" + x.id)).slice(0, 1)) {
120
+ const lane = laneNamed(t.text, lanes);
121
+ out.push({ id: "next:float:" + t.id, title: `${lane ? `Give ${named(map[lane], lane)}` : "Start"} "${plain(t.text, 60)}"`, gain: lane ? `it has no project, so no agent picks it up: ${lane}'s agent takes it now` : "it has no project, so no agent picks it up: one of its own starts on it", time: "runs by itself", act: lane ? "assign" : "agent", label: "Start an agent", ...(lane ? { repo: lane } : {}) });
122
+ }
123
+ // new mail or chats with no reply: your agent drafts them, unsent, for you to check
124
+ const cards = ((tryOr(deps.board || (() => watchBoard(24, now)), { cards: [] }) || {}).cards || []).filter((c) => c.count > 0 && /^(mail|chat|social)$/.test(c.source || "") && fresh("next:drafts:" + c.id));
125
+ for (const c of cards.slice(0, 1)) { const k = Math.min(3, c.count), where = plain(String(c.name || "").split(" - ")[0], 30);
126
+ out.push({ id: "next:drafts:" + c.id, title: c.count > k ? `Draft replies to the newest ${k} of the ${c.label} on ${where}` : `Draft replies to the ${c.label} on ${where}`, gain: "each waits as a draft, unsent, for you to check and send", time: `~${k * 3} min, by an agent`, act: "drafts", label: "Draft replies" }); }
127
+ // a decision you made lately (Symbiot's memory) that no open task carries yet
128
+ if (out.length < 3) {
129
+ const nodes = (tryOr(deps.mind || (() => loadMind().nodes), []) || []).filter((n) => n.kind === "decision");
130
+ const carried = allTasks.map((t) => words4(t.text)); // done or not: what a task already took on
131
+ const facts = nodes.flatMap((n) => (n.facts || []).map((f) => ({ n, f }))).filter(({ f }) => now - (f.ts || 0) < DECIDED_WITHIN).sort((a, b) => b.f.ts - a.f.ts);
132
+ for (const { n, f } of facts) {
133
+ const id = "next:decision:" + n.id + ":" + (f.ts || 0), w = words4(f.text);
134
+ if (!fresh(id) || !WANTS.test(f.text) || carried.some((c) => [...w].filter((x) => c.has(x)).length >= Math.max(3, w.size * 0.5))) continue;
135
+ const lane = laneNamed(n.name + " " + f.text, lanes);
136
+ out.push({ id, title: `Make a task of what you decided: ${plain(f.text, 70)}`, gain: `so it's done${lane ? ` in ${named(map[lane], lane)}` : ""}, not only remembered`, time: "a tap", act: "task", label: "Make a task", ...(lane ? { repo: lane } : {}), text: f.text });
137
+ break;
138
+ }
139
+ }
140
+ return out.slice(0, NEXT_MAX);
141
+ }
142
+ // Take a suggestion (its id, from homeState's next): it starts its agent or makes
143
+ // its task, and stays away a week. { ok, said } or { error }.
144
+ function homeNext(id, deps = {}) {
145
+ id = String(id || ""); cached = null;
146
+ const h = deps.state || homeState({ fresh: true }), s = (h.next || []).find((x) => x.id === id);
147
+ if (!s) return { error: (h.you || []).length ? "Something needs you first: it's at the top of Home." : "That suggestion isn't here any more." };
148
+ const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
149
+ const run = deps.run || runHandoff, ref = id.split(":").slice(2).join(":");
150
+ let r;
151
+ if (s.act === "agent" && id === "next:reports") {
152
+ const reps = (deps.reportsList || listReports)({ map }).filter((x) => x.new).slice(0, 20);
153
+ if (!reps.length) return { error: "No unread reports left." };
154
+ r = (deps.act || actNow)(`Triage the user's ${reps.length} unread Symbiot reports, so they read one page instead of ${reps.length}. Read each:\n${reps.map((x) => `- ${x.title} (${x.lane || "ops"}): ${x.file}`).join("\n")}\n\nWrite \`.symbiot/REPORTS-TRIAGE.md\` (a "# " title first): per report, two lines at most on what it found and what's still open, the ones that need a decision first. Put each open next step under \`## Suggestions\` in \`.symbiot/QUESTIONS.md\`, starting with its project's folder name (\`- [repo: <lane>] …\`), so the user adds it as a task in a click. Change nothing else.`, { title: "Home: what's next" });
155
+ } else if (s.act === "agent") {
156
+ const t = (deps.tasks || loadTasks)().find((x) => x.id === ref); if (!t) return { error: "That task isn't on the list any more." };
157
+ r = (deps.act || actNow)(`${t.text}\n\n(This was on the user's task list with no project. If it belongs in one of their repos, hand it over to that lane.)`, { title: "Home: what's next" });
158
+ } else if (s.act === "assign") {
159
+ const all = (deps.tasks || loadTasks)(), t = all.find((x) => x.id === ref); if (!t) return { error: "That task isn't on the list any more." };
160
+ t.repo = s.repo; (deps.save || saveTasks)(all);
161
+ r = goLane(s.repo, map, { push: deps.push, run });
162
+ } else if (s.act === "go") r = goLane(ref, map, { push: deps.push, run });
163
+ else if (s.act === "retry") r = (deps.allow || allowHandover)(ref, {});
164
+ else if (s.act === "task") r = (deps.task || taskIn)(s.text, s.repo || "", { map });
165
+ else if (s.act === "drafts") {
166
+ const card = ((deps.board || (() => watchBoard(24)))().cards || []).find((c) => c.id === ref), items = ((card && card.items) || []).filter((n) => n.need).slice(0, 3);
167
+ if (!items.length) return { error: "Nothing there waits for a reply any more." };
168
+ const done = items.map((n) => (deps.draft || draftReply)(n.id, { run })), okN = done.filter((x) => x && x.ok).length;
169
+ r = okN ? { ok: true, n: okN } : done[0];
170
+ }
171
+ if (!r || r.error) return { error: (r && r.error) || "It didn't start. Check your agent in Settings." };
172
+ const d = loadNext(deps.file); d.taken[id] = Date.now(); for (const k of Object.keys(d.taken)) if (Date.now() - d.taken[k] > NEXT_KEEP) delete d.taken[k]; saveNext(d, deps.file);
173
+ const said = s.act === "task" ? `It's a task now${s.repo ? ` in ${s.repo}` : ""}.` : s.act === "drafts" ? `Your agent is drafting ${r.n} repl${r.n > 1 ? "ies" : "y"}. They wait, unsent, for you to check.` : s.act === "retry" ? "Started it as a run of its own. What it does goes back to the agent that asked." : r.queued ? "Its agent takes it once the run there now finishes." : "An agent started on it. It's on the Workdesk.";
174
+ return { ok: true, said };
175
+ }
176
+ // A lane's waiting tasks to its agent, the way Go does for one project.
177
+ function goLane(repo, map, { push = pushTasks, run = runHandoff } = {}) {
178
+ if (!map[repo]) return { error: `${repo} isn't one of your projects any more.` };
179
+ const p = (push || pushTasks)({ repo }); if (!p || !p.written || !p.written.length) return { error: `Nothing of ${repo}'s is waiting to start.` };
180
+ const e = (run || runHandoff)(map[repo]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
181
+ return e.parked ? { error: `${repo} is parked: unpark it to start its agent.` } : { ok: true, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
182
+ }
183
+
184
+ // ---- Marketing: a lane of its own, across products (marketing.mjs) ----------------------
185
+ // What its page shows, and its orb on Home: what needs you there (its agent's questions,
186
+ // its work waiting for your OK, the week's post drafts waiting on you), its tasks with the
187
+ // product each markets, other lanes' tasks about marketing, and what its agent drafted.
188
+ // on: whether Home shows its orb (once posts can be drafted, or it has anything at all).
189
+ function marketingState({ deps = {}, list, pend, map, named = displayName } = {}) {
190
+ const tryOr = (f, d) => { try { return f(); } catch { return d; } };
191
+ map = map || tryOr(deps.repos || laneMap, {}) || {};
192
+ const dir = map[MARKETING] || MARKETING_DIR;
193
+ const posts = tryOr(deps.posts || postsState, null) || { posts: [], done: [] };
194
+ const tasks = (tryOr(deps.tasks || loadTasks, []) || []).filter((t) => t && !t.done && !t.archived);
195
+ // the products to tag by: your repos', the ones tasks were tagged with, and those its agent drafted for
196
+ const known = [...new Set([...productNames(map), ...tasks.map((t) => productOf(t.text)).filter(Boolean)])];
197
+ const files = tryOr(() => (deps.files || draftFiles)(dir, known), []) || [];
198
+ const names = [...new Set([...known, ...files.map((f) => f.product).filter(Boolean)])];
199
+ list = list || tryOr(deps.agents || agentsList, []) || [];
200
+ const run = list.find((a) => a.path === dir), qs = (run && run.ask && run.ask.questions) || [], working = !!(run && run.status === "running");
201
+ const needs = qs.map((q, i) => ({ kind: "ask", id: "ask:" + i, text: plain(q.q, 160), options: (q.options || []).slice(0, 2), product: productOf(q.q, names) }));
202
+ const ready = (pend || tryOr(deps.pending || pendingReview, []) || []).find((r) => r.repo === MARKETING && r.path && !r.running && ((r.tasks || []).length || (r.files || []).length));
203
+ if (ready) { const n = (ready.tasks || []).length, f = (ready.files || []).length; needs.push({ kind: "approve", id: "approve", text: `${n ? `${n} piece${n > 1 ? "s" : ""} of marketing done` : "Marketing's agent changed things without a task"}${f ? ` · ${f} file${f > 1 ? "s" : ""}` : ""}: only you decide`, count: n || f }); }
204
+ for (const p of (posts.posts || []).filter((x) => x.status === "waiting")) needs.push({ kind: "draft", id: "draft:" + p.id, text: plain(firstLine(p.text), 120), product: productOf(p.text, names) });
205
+ const lane = tasks.filter((t) => t.repo === MARKETING).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), status: t.review ? "review" : working ? "working" : "waiting", ts: t.ts || 0 }));
206
+ const elsewhere = tasks.filter((t) => t.repo !== MARKETING && MARKETING_WORDS.test(t.text)).slice(0, 12).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), repo: t.repo || "", name: t.repo ? named(map[t.repo], t.repo) : "" }));
207
+ const seen = [...new Set([...lane, ...elsewhere, ...needs, ...files].map((x) => x.product).filter(Boolean))];
208
+ const on = !!(posts.canDraft || (posts.posts || []).length || (posts.done || []).length || lane.length || elsewhere.length || files.length || needs.length || run);
209
+ return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at }) => ({ rel, name, product, at })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
210
+ }
211
+ // A task for Marketing, tagged with the product it markets (the page's Add). The task or { error }.
212
+ function marketingTask(text, product = "", { add = addTask } = {}) {
213
+ text = String(text || "").trim(); if (!text) return { error: "Say what the marketing task is." };
214
+ cached = null; return add(tagged(text, product), MARKETING);
215
+ }
216
+ // Another lane's task about marketing, moved to Marketing (and tagged): its agent takes it
217
+ // from then on. Not one waiting for your OK: approve or send it back where it is first.
218
+ function moveToMarketing(id, product = "", { load = loadTasks, save = saveTasks, map } = {}) {
219
+ const all = load(), t = all.find((x) => x.id === String(id || ""));
220
+ if (!t || t.done || t.archived) return { error: "That task isn't open any more." };
221
+ if (t.review) return { error: "It waits for your OK where it is: approve it or send it back first." };
222
+ if (product || !/^\s*\[/.test(t.text)) t.text = tagged(t.text, product || productOf(t.text, productNames(map || (() => { try { return laneMap(); } catch { return {}; } })())));
223
+ t.repo = MARKETING; save(all); cached = null;
224
+ return { ok: true, id: t.id };
225
+ }
226
+ // Start Marketing's agent on its waiting tasks (the page's Start its agent). { ok } or { error }.
227
+ function marketingGo(deps = {}) {
228
+ cached = null;
229
+ const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
230
+ return goLane(MARKETING, map, { push: deps.push, run: deps.run });
231
+ }
232
+
88
233
  // { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
89
234
  // lanes: [{ from, to, text, status, times }], working: n, at }
90
235
  // An ask carries what its blob on home needs: the lane (repo, or "" for an ops
@@ -97,7 +242,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
97
242
  const lanes = deps.lanes || (() => { try { return lanesState(); } catch { return { handoffs: [] }; } });
98
243
  const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
99
244
  const connected = deps.connected || (() => !!resolveProvider());
100
- const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
245
+ const repos = deps.repos || (() => { try { return laneMap(); } catch { return {}; } });
101
246
  const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
102
247
  const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
103
248
  // can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
@@ -108,8 +253,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
108
253
  const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
109
254
  const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
110
255
  const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
111
- // Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
112
- const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
256
+ // Marketing, a lane of its own across products: its orb, lit when something there needs you
257
+ const marketing = deps.marketing || ((o) => { try { const m = marketingState(o); return m.on ? { needs: m.needCount, working: m.working, waiting: m.waiting } : false; } catch { return false; } });
113
258
  const named = deps.name || displayName;
114
259
  const you = [], map = repos() || {};
115
260
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
@@ -118,11 +263,12 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
118
263
  if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: a key, or a free local model", shape: "settings", focus: "ai" });
119
264
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
120
265
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
121
- if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
266
+ if (!Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
122
267
  // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
123
268
  // Home didn't say "All handled" truthfully. One click for the one on this computer.
124
269
  if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
125
- for (const r of pending()) {
270
+ const pend = pending();
271
+ for (const r of pend) {
126
272
  if (!r.path || r.running) continue;
127
273
  const n = (r.tasks || []).length, files = (r.files || []).length;
128
274
  if (!n && !files) continue;
@@ -149,7 +295,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
149
295
  ? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
150
296
  : { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
151
297
  }
152
- const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
298
+ const bd = board() || {}, feeds = (bd.cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
153
299
  // a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
154
300
  const clash = clashes();
155
301
  if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
@@ -157,7 +303,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
157
303
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
158
304
  const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
159
305
  const working = list.filter((a) => a.status === "running").length;
160
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
306
+ // nothing waits on you: a few things that can be done next (none while anything does)
307
+ const next = you.length ? [] : nextUp({ now, map, list, named, deps: { board: () => bd, reports: () => rep, ...(deps.next || {}) } });
308
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, next, marketing: marketing({ list, map, named, pend }), at: now };
161
309
  if (!deps.board) cached = out;
162
310
  return out;
163
311
  }
@@ -203,7 +351,7 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
203
351
  if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
204
352
  return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
205
353
  }
206
- return { error: "Answer that one on its block in Agents." };
354
+ return { error: "Answer that one on its block on the Workdesk." };
207
355
  }
208
356
 
209
357
  // Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
@@ -243,6 +391,8 @@ function homeContext(h) {
243
391
  `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
244
392
  `New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
245
393
  `Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
394
+ ...(h.marketing ? [`Marketing (a lane of its own, for marketing across all their products; "repo": "${MARKETING}"): ${h.marketing.needs ? `${h.marketing.needs} thing${h.marketing.needs > 1 ? "s" : ""} there need${h.marketing.needs > 1 ? "" : "s"} them` : "nothing needs them"}${h.marketing.working ? ", its agent is at work" : ""}.`] : []),
395
+ ...((h.next || []).length ? ["Could be done next (Home suggests these, one tap each):", ...h.next.map((s) => `- ${s.title}: ${s.gain} (${s.time})`)] : []),
246
396
  ].join("\n");
247
397
  }
248
398
 
@@ -256,7 +406,7 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
256
406
  const shotNote = pics.length ? `\n\nScreenshots the user attached (open them with your Read tool): ${pics.map((i) => i.path).join(", ")}` : "";
257
407
  if (!ask && !resolveProvider()) return { error: "not-connected" };
258
408
  const h = state || homeState({ now });
259
- let map = {}; try { map = repoPathMap(); } catch {}
409
+ let map = {}; try { map = laneMap(); } catch {}
260
410
  const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
261
411
  const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data }) => ({ mime, data })), ...(ask ? { ask } : {}),
262
412
  act: {
@@ -280,7 +430,7 @@ function workScene({ deps = {} } = {}) {
280
430
  const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
281
431
  const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
282
432
  const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
283
- const map = (deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } }))() || {};
433
+ const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
284
434
  const named = deps.name || displayName;
285
435
  const parked = new Set((deps.parked || (() => { try { return parkedPaths(); } catch { return []; } }))());
286
436
  const isParked = (repo) => !!map[repo] && parked.has(map[repo]);
@@ -343,4 +493,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
343
493
  return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
344
494
  }
345
495
 
346
- export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
496
+ export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps };
package/lanes.mjs CHANGED
@@ -24,7 +24,7 @@ import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
24
24
  import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
25
25
  import { addTask, pushTasks } from "./tasks.mjs";
26
26
  import { actNow } from "./mind.mjs";
27
- import { repoPathMap } from "./scan.mjs";
27
+ import { laneMap } from "./scan.mjs";
28
28
  import { parseRun, lastRunText, readRunLog } from "./work.mjs";
29
29
  import { OPS, parseHandoffs } from "./handover.mjs";
30
30
 
@@ -53,7 +53,7 @@ function laneTarget(lane, map) {
53
53
 
54
54
  // Pick up what a folder's agent handed over and start the lanes it named.
55
55
  // Each handover is started once (its key: where from, which lane, what).
56
- function dispatch(path, { map = repoPathMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
56
+ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
57
57
  const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
58
58
  const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
59
59
  const started = [];
@@ -151,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
151
151
 
152
152
  // One pass: start what's been handed over, from every lane, then report back
153
153
  // what's done. The app runs it every 20 seconds.
154
- function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
154
+ function lanesTick({ map = laneMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
155
155
  const ledger = loadLedger();
156
156
  let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
157
157
  const started = [];
@@ -208,10 +208,10 @@ function pastIt(e, hs) {
208
208
  }
209
209
  // The errored handovers that still wait on you, newest first:
210
210
  // [{ id, from, text, error, at, dirs, q, options }].
211
- function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
211
+ function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs, within = STUCK_FOR } = {}) {
212
212
  const hs = ledger.handoffs, out = [];
213
213
  hs.forEach((e) => {
214
- if (e.status !== "error" || e.skipped || now - (e.at || 0) > STUCK_FOR) return;
214
+ if (e.status !== "error" || e.skipped || now - (e.at || 0) > within) return;
215
215
  if (pastIt(e, hs)) return;
216
216
  const dirs = dirsOf(e.text, e.from.path);
217
217
  out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
@@ -286,7 +286,7 @@ async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
286
286
  if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
287
287
  cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
288
288
  let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
289
- const moves = await orcaMoves(repos, map || repoPathMap(), { remoteOf });
289
+ const moves = await orcaMoves(repos, map || laneMap(), { remoteOf });
290
290
  for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
291
291
  return { moves };
292
292
  }
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 };