symbiot 0.63.5 → 0.63.7

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/home.mjs CHANGED
@@ -16,11 +16,11 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
16
  import { watchBoard, draftReply, draftCards, draftAnswer } from "./watch.mjs";
17
17
  import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
18
18
  import { estimate, estimateWords } from "./estimate.mjs";
19
- import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
19
+ import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach, unchangedStep } from "./agents.mjs";
20
20
  import { linksState, signInAsked } from "./links.mjs";
21
21
  import { knowledgeState } from "./knowledge.mjs";
22
22
  import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig, sameTask } from "./core.mjs";
23
- import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
23
+ import { lanesState, stuckHandovers, allowHandover, skipHandover, handingOf, loadLedger, doneSince } from "./lanes.mjs";
24
24
  import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
25
25
  import { repoPathMap, laneMap, reposState, scanRoots } from "./scan.mjs";
26
26
  import { resolveProvider, PROVIDERS, claudeState } from "./ai.mjs";
@@ -312,6 +312,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
312
312
  const confirmed = deps.confirmed || (() => { try { return !!loadConfig().reposConfirmed; } catch { return false; } });
313
313
  const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
314
314
  const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
315
+ let led; const finished = deps.doneSince || ((path, q) => { try { return doneSince(path, q, { ledger: (led = led || loadLedger()), now }); } catch { return null; } });
315
316
  // can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
316
317
  const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
317
318
  // the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
@@ -354,8 +355,12 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
354
355
  const qs = (a.ask && a.ask.questions) || [], q = qs[0];
355
356
  if (!q) continue;
356
357
  const lane = laneOfPath(a.path, map), repo = lane || (lane === "" ? "" : a.name);
358
+ // the work it asks about is done already (another lane took it and ticked it): the card says so, and Close it is first
359
+ const pa = plainAsk(a.path, q), done = finished(a.path, q.q), opts = (q.options || []).slice(0, 2);
360
+ const closed = done ? { done, options: [`Close it: ${done.lane} has done this already (${done.text}). Check that it's so, and carry on without asking again. (recommended)`, opts[0]].filter(Boolean),
361
+ labels: ["Close it: it's done", ...(opts[0] ? [blobLabel(pa.labels ? pa.labels[0] : opts[0])] : [])] } : {};
357
362
  you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
358
- title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), ...plainAsk(a.path, q), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...(q.options || []).slice(0, 2)]), shape: "agents" });
363
+ title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), ...pa, options: opts, ...closed, ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...opts]), shape: "agents" });
359
364
  }
360
365
  // What stopped and can't go on without you, as a question too: a handover that
361
366
  // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
@@ -429,6 +434,8 @@ function PLAIN_RULE(r) {
429
434
  if (first === "sudo") return "make changes as administrator";
430
435
  return `run ${first}`;
431
436
  }
437
+ // a label without its "(recommended)": a second button isn't the recommended one
438
+ const blobLabel = (l) => String(l || "").replace(/\s*\(recommended\)\s*$/i, "");
432
439
  function plainAsk(path, q) {
433
440
  const text = String(q.q || ""), opts = (q.options || []).slice(0, 2);
434
441
  let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
@@ -437,7 +444,11 @@ function plainAsk(path, q) {
437
444
  ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
438
445
  labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
439
446
  }
440
- return { q: ask.replace(PATHISH, "a file"), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
447
+ // raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
448
+ // answerQuestions). Sending the plain words back matched nothing, so a permission card's
449
+ // Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
450
+ const shown = ask.replace(PATHISH, "a file");
451
+ return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
441
452
  }
442
453
 
443
454
  function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
@@ -466,7 +477,14 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
466
477
  const settle = deps.settle || settleNeeds;
467
478
  if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
468
479
  if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
469
- const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
480
+ // Done it, on a step that names a file (`.env`): only once that file has changed since
481
+ // the run asked. In your own words it goes ahead (done some other way), its agent told.
482
+ const still = n.kind === "approve" ? [] : (deps.unchanged || unchangedStep)(`${n.what} ${n.check || ""}`, a.path, a.endedAt || a.startedAt || 0);
483
+ const last = still.map((f) => `\`${f.name}\` ${f.at ? `was last changed on ${new Date(f.at).toISOString().slice(0, 10)}` : "isn't there"}`).join(" and ");
484
+ if (still.length && !text) return { error: `${last.replace(/`/g, "")}, before its agent asked, so it isn't done yet. Make the change and press Done it again, or say how you did it.` };
485
+ const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.`
486
+ : still.length ? `The user says it's done (${text}), but ${last}, before you asked. Check it yourself before relying on it, then carry on.`
487
+ : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
470
488
  noteAnswer(a.path, n.what, said);
471
489
  const e = (deps.run || runHandoff)(a.path, { force: true });
472
490
  if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
@@ -673,6 +691,9 @@ function workScene({ deps = {}, full = false } = {}) {
673
691
  for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
674
692
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
675
693
  }
694
+ // what each lane handed over and hasn't heard back on, with where it stands and how far the run on it has got
695
+ let led; const handing = deps.handing || ((path) => { try { return handingOf(path, { map, ledger: (led = led || loadLedger()) }); } catch { return []; } });
696
+ for (const [repo, path] of Object.entries(map)) { const h = handing(path); if (h.length) at(repo).handing = h.slice(0, 3); }
676
697
  const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
677
698
  const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
678
699
  for (const t of troubles({ stuck: stuck(), list: AG, map, named, drafts: drafts() })) {
package/index.mjs CHANGED
@@ -38,7 +38,7 @@ import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.
38
38
  import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
39
39
  import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
40
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
- import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
41
+ import { mapPage, wholePage, pressRegion, typeRegion, chooseRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer, parsePairLink } from "./phone.mjs";
44
44
  import { qrMatrix } from "./qr.mjs";
@@ -408,19 +408,22 @@ async function cmdScreens() {
408
408
  for (const s of list) console.log(`${s.id} ${s.name} ${c.d(`${(s.regions || []).length} regions · ${s.page ? s.page.url : s.w + "×" + s.h + " " + s.via}`)}`);
409
409
  return;
410
410
  }
411
- if (sub === "map") { const body = { site: a1, name: flag("name", ""), whole: has("whole") }; return out(screenJson((await viaApp("/api/screens/map", body)) || await mapPage(body.site, body.name, { whole: body.whole }))); }
411
+ // whose tab in the app's hidden browser: the agent run's folder (SYMBIOT_AGENT), else here, so
412
+ // two agents at once each keep their own page (headless.mjs withPage)
413
+ const who = process.env.SYMBIOT_AGENT || process.cwd();
414
+ if (sub === "map") { const body = { site: a1, name: flag("name", ""), whole: has("whole"), who }; return out(screenJson((await viaApp("/api/screens/map", body)) || await mapPage(body.site, body.name, { whole: body.whole, who }))); }
412
415
  if (sub === "whole") {
413
416
  const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
414
- return out(screenJson((await viaApp("/api/screens/whole", { id: s.id })) || await wholePage(s.id)));
417
+ return out(screenJson((await viaApp("/api/screens/whole", { id: s.id, who })) || await wholePage(s.id, { who })));
415
418
  }
416
419
  if (sub === "scroll") {
417
420
  const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
418
- const body = { id: s.id, to: a2 || "down" };
419
- return out(screenJson((await viaApp("/api/screens/scroll", body)) || await scrollPage(body.id, body.to)));
421
+ const body = { id: s.id, to: a2 || "down", who };
422
+ return out(screenJson((await viaApp("/api/screens/scroll", body)) || await scrollPage(body.id, body.to, { who })));
420
423
  }
421
424
  if (sub === "show") { const s = find(a1); return out(s ? screenJson(s) : { error: "No screen " + (a1 || "") + ". symbiot screens lists them." }); }
422
425
  if (sub === "signin") { const r = (await viaApp("/api/screens/signin", { site: a1 })) || await signIn(a1); return out(r.ok ? { ...r, next: "Sign in in the window that opened, close it, then map again." } : r); }
423
- if (sub === "press" || sub === "type" || sub === "upload") {
426
+ if (sub === "press" || sub === "type" || sub === "choose" || sub === "upload") {
424
427
  const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
425
428
  const want = String(a2 || "").toLowerCase(), rs = s.regions || [];
426
429
  const r = rs.find((x) => x.id === a2) || rs.find((x) => x.label.toLowerCase() === want) || (rs.filter((x) => x.label.toLowerCase().includes(want)).length === 1 && rs.find((x) => x.label.toLowerCase().includes(want)));
@@ -428,9 +431,11 @@ async function cmdScreens() {
428
431
  // a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send, or Enter (it sends in a chat)
429
432
  // upload: the files resolved and checked here, where a relative path means something
430
433
  const f = sub === "upload" ? uploadFiles([a3, ...more]) : null; if (f && f.error) return out(f);
431
- const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}), ...(f ? { files: f.files } : {}) };
432
- const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : sub === "type" ? await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }) : await uploadRegion(s.id, r.id, f.files, { confirmed: body.confirmed }));
433
- if (f && done && /answered 404/.test(done.error || "")) return out({ error: "The Symbiot app that's running is older than this command and can't upload: restart it (symbiot app), then try again." });
434
+ // type --replace "old" "new": only that part of the field typed over
435
+ const part = sub === "type" && has("replace") ? { replace: String(a3 == null ? "" : a3), text: String(more[0] == null ? "" : more[0]) } : null;
436
+ const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, who, ...(sub === "type" ? (part || { text: a3 }) : {}), ...(sub === "type" ? { enter: has("enter") } : {}), ...(sub === "choose" ? { option: a3 } : {}), ...(f ? { files: f.files } : {}) };
437
+ const done = (await viaApp("/api/screens/" + (part ? "replace" : sub), body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend, who }) : sub === "type" ? await typeRegion(s.id, r.id, body.text, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend, who, ...(part ? { replace: part.replace } : {}) }) : sub === "choose" ? await chooseRegion(s.id, r.id, a3, { confirmed: body.confirmed, who }) : await uploadRegion(s.id, r.id, f.files, { confirmed: body.confirmed, who }));
438
+ if ((f || part || sub === "choose") && done && /answered 404/.test(done.error || "")) return out({ error: `The Symbiot app that's running is older than this command and can't ${f ? "upload" : part ? "replace part of a field" : "choose"}: restart it (symbiot app), then try again.` });
434
439
  // not a trusted site: say how to go ahead (only you can trust a site, in the app's Settings)
435
440
  if (done && done.confirm) return out({ error: `${done.error} Add --yes to go ahead, or list ${done.host} under Trusted sites in Symbiot's Settings.` });
436
441
  return out(screenJson(done));
@@ -445,6 +450,12 @@ async function cmdScreens() {
445
450
  symbiot screens press <id> <region> [--yes] press a region there, map where it lands
446
451
  symbiot screens type <id> <field> "text" [--enter] [--yes]
447
452
  type into a field (Enter sends it), map the result
453
+ symbiot screens type <id> <field> --replace "old" "new" [--yes]
454
+ change only that part of what's in the field,
455
+ the rest (a post's picture too) left as it is
456
+ symbiot screens choose <id> <menu> "option" [--yes]
457
+ pick an option in a menu (a dropdown: MX in a
458
+ record type), by its label; map the result
448
459
  symbiot screens upload <id> <field> <file> [<file>…] [--yes]
449
460
  put a file in the page's file box (a picture or
450
461
  video for a post): <field> is the box, or the
@@ -459,8 +470,9 @@ async function cmdScreens() {
459
470
  symbiot browser [site] the Symbiot Browser: sign in to your sites for Symbiot
460
471
  symbiot screens signin <site> sign in once, in the Symbiot Browser
461
472
  --yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
462
- While the app runs, these use its hidden browser, which stays open a few minutes:
463
- press on the screen the last command printed carries on from that page as it is
473
+ While the app runs, these use its hidden browser, which stays open a few minutes, with
474
+ a tab for each agent: press on the screen the last command printed carries on from that
475
+ page as it is
464
476
  (type without --enter, then press the form's button).`);
465
477
  if (sub !== "help") process.exitCode = 1;
466
478
  }
@@ -858,6 +870,44 @@ async function cmdMarketing() {
858
870
  if (sub !== "help") process.exitCode = 1;
859
871
  }
860
872
 
873
+ // ---- `symbiot capture`: a product's pictures and clips, for its pick tray (capture.mjs) ----
874
+ // Run by the app (it has the browser and ffmpeg, and the tray is its), else here. What
875
+ // Marketing's agent runs on a product's first run, instead of capture scripts of its own.
876
+ async function cmdCapture() {
877
+ const VAL = ["--url", "--window", "--screens", "--seconds"];
878
+ const [product = "help"] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !VAL.includes(all[i - 1]));
879
+ if (product === "help" || has("help")) {
880
+ console.log(`${c.b("symbiot capture")} ${c.d("— pictures and short clips of a product, for Marketing's pick tray")}
881
+ symbiot capture <product> [--url <its address>] [--screens /a,/b] [--seconds 6] [--no-clips]
882
+ a web app: its key screens (from its routes and
883
+ README), each a picture, the first few a clip too
884
+ symbiot capture <product> --window "<its window's title>" [--seconds 6]
885
+ a desktop app: its window, a picture and a clip
886
+ (under Wayland you pick it in your desktop's
887
+ screen-sharing prompt, the first time only)
888
+ Its address: --url, else the app of it running here (start it first, with demo data
889
+ where it has some). Symbiot itself: a copy with made-up work in it, started and
890
+ stopped for the capture, never your own.
891
+ Before anything reaches the tray, names, emails, phone numbers,
892
+ addresses, keys and tokens, account and card numbers, what's typed into forms,
893
+ notifications, chats, avatars and pictures are found and blurred. Look them over
894
+ under Marketing: switch a box off, or draw one of your own. The unblurred originals
895
+ stay in the lane's .symbiot/tray-originals/, never posted.`);
896
+ if (product !== "help") process.exitCode = 1;
897
+ return;
898
+ }
899
+ const body = { product, url: flag("url", ""), window: flag("window", ""), screens: String(flag("screens", "")).split(",").map((x) => x.trim()).filter(Boolean), clips: !has("no-clips"), ...(Number(flag("seconds", 0)) > 0 ? { seconds: Number(flag("seconds", 0)) } : {}) };
900
+ console.log(c.d(`Capturing ${product}: a minute or two…`));
901
+ const r = (await viaApp("/api/marketing/capture", body)) || await (await import("./capture.mjs")).captureProduct(body);
902
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
903
+ console.log(`${c.g("✓")} ${r.note}`);
904
+ for (const it of r.items || []) console.log(c.d(` ${r.tray}/${it}`));
905
+ for (const s of r.skipped || []) console.log(c.y(` ${s.clip ? "no clip of" : "skipped"} ${s.path}: ${s.why}`));
906
+ for (const f of r.failed || []) console.log(c.y(` not blurred yet: ${f}`));
907
+ if (r.warning) console.log(c.y(r.warning));
908
+ if (r.seed) console.log(c.d(` It has demo data (${r.seed}): capture with that loaded, not real data.`));
909
+ }
910
+
861
911
  // ---- `symbiot reports`: what agents wrote up for you (reports.mjs) ---------------
862
912
  function cmdReports() {
863
913
  const id = argv.slice(1).find((x) => !x.startsWith("--"));
@@ -910,6 +960,9 @@ ${c.b("Experimental")}
910
960
  symbiot marketing posted <draft> --url <link>
911
961
  mark a Marketing draft posted (or superseded);
912
962
  symbiot marketing help for more
963
+ symbiot capture <product> pictures and clips of a product for Marketing's
964
+ pick tray, blurred where they show anything private
965
+ (symbiot capture help for more)
913
966
  symbiot screens map <site> map a web page's buttons in a hidden browser
914
967
  (symbiot screens help for more)
915
968
  symbiot watch add <screen id> keep track of a mapped page: what's new on it
@@ -963,6 +1016,7 @@ async function main() {
963
1016
  if (cmd === "phone") return cmdPhone();
964
1017
  if (cmd === "post" || cmd === "posts") return cmdPost();
965
1018
  if (cmd === "marketing") return cmdMarketing();
1019
+ if (cmd === "capture") return cmdCapture();
966
1020
  if (cmd === "knowledge" || cmd === "know") return cmdKnowledge();
967
1021
  if (cmd === "reports" || cmd === "report") return cmdReports();
968
1022
  if (cmd === "week")return cmdRun("week");
package/lanes.mjs CHANGED
@@ -14,15 +14,17 @@
14
14
  // result?, reportedAt? }] }. status: started, held (that lane's agent was busy:
15
15
  // it starts when it's free), blocked (it didn't start, and nothing running there will
16
16
  // start it: note says why), done (reported back) or error (reported back too).
17
- // chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
18
- // lanes can't hand the same thing back and forth forever.
17
+ // chain: how many handovers in a row led here; past MAX_CHAIN no agent is started on
18
+ // it, so two lanes can't hand the same thing back and forth forever. To a repo it's
19
+ // still added to that lane's tasks (blocked), for its next run: dropped, ops's
20
+ // User-Agent fix for symbiot was only found in lanes.json (2026-10-12).
19
21
  import { join, basename, dirname, resolve, relative } from "node:path";
20
22
  import { homedir } from "node:os";
21
23
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync, statSync } from "node:fs";
22
24
  import { createHash, randomBytes } from "node:crypto";
23
25
  import { execFile } from "node:child_process";
24
26
  import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
25
- import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks } from "./agents.mjs";
27
+ import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks, workOf } from "./agents.mjs";
26
28
  import { addTask, removeTask, pushTasks } from "./tasks.mjs";
27
29
  import { actNow, actBrief } from "./mind.mjs";
28
30
  import { laneMap } from "./scan.mjs";
@@ -86,7 +88,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
86
88
  const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
87
89
  const started = [];
88
90
  for (const h of asked) {
89
- const key = createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
91
+ const key = keyOf(path, h);
90
92
  if (ledger.handoffs.some((x) => x.key === key || (x.was || []).includes(key))) continue;
91
93
  const prior = supersedes(h, from, ledger, { running, tasks });
92
94
  if (prior) {
@@ -106,8 +108,8 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
106
108
  started.push(prior); continue;
107
109
  }
108
110
  const e = { id: randomBytes(4).toString("hex"), key, from, to: { lane: h.lane, path: "" }, text: h.text, at: now, chain, status: "started" };
109
- const to = laneTarget(h.lane, map);
110
- if (chain > MAX_CHAIN) Object.assign(e, { status: "error", error: `${MAX_CHAIN} handovers in a row led here, so this one wasn't started. Do it yourself, or ask the user.` });
111
+ const to = laneTarget(h.lane, map), looped = chain > MAX_CHAIN;
112
+ if (looped && (!to || to.lane === OPS || to.lane.toLowerCase() === from.lane.toLowerCase())) Object.assign(e, { status: "error", error: `${MAX_CHAIN} handovers in a row led here, so this one wasn't started. Do it yourself, or ask the user.` });
111
113
  else if (!to) Object.assign(e, { status: "error", error: `There's no lane called ${h.lane}. Lanes: ops, or a repo: ${Object.keys(map).slice(0, 30).join(", ")}.` });
112
114
  else if (to.lane.toLowerCase() === from.lane.toLowerCase()) Object.assign(e, { status: "error", error: `${to.lane} is your own lane: do it yourself.` });
113
115
  else if (to.lane === OPS) {
@@ -119,12 +121,13 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
119
121
  if (t.error) Object.assign(e, { status: "error", error: t.error });
120
122
  else {
121
123
  push({ repo: to.lane });
122
- const r = run(to.path) || {};
123
- Object.assign(e, { to, task: t.id, ...startedAs(r) });
124
+ if (looped) Object.assign(e, { to, task: t.id, status: "blocked", note: `${MAX_CHAIN} handovers in a row led here, so no agent was started on it: it waits in ${to.lane}'s tasks for its next run` });
125
+ else { const r = run(to.path) || {}; Object.assign(e, { to, task: t.id, ...startedAs(r) }); }
124
126
  }
125
127
  }
126
128
  ledger.handoffs.push(e); started.push(e);
127
129
  }
130
+ noteSent(path, asked, ledger);
128
131
  return started;
129
132
  }
130
133
 
@@ -179,6 +182,73 @@ function partlyDone(path, { ledger = loadLedger() } = {}) {
179
182
  for (const h of parseHandoffs(readSym(path, "HANDOFF.md"))) if (!mine.some((x) => x.to.lane.toLowerCase() === h.lane.toLowerCase() && x.text === h.text)) handed.push({ lane: h.lane, text: clipWords(firstLine(h.text), 90) });
180
183
  return questions || handed.length ? { questions, handed: handed.slice(0, 3) } : null;
181
184
  }
185
+ // What a folder handed over that hasn't come back yet, each with where it stands,
186
+ // for that lane's block (Agents, Home): the sections of its HANDOFF.md not sent
187
+ // yet, and the ledger's handovers from it, with how far the receiving run has got.
188
+ // whatsapp_module's test-number handover sat from 07:33 (2026-10-12) behind ops's
189
+ // run on its earlier version, and nothing on its block said so, so the user
190
+ // answered as if ops already had it. [{ lane, text, gone, why, doing?, progress? }]:
191
+ // gone, its lane has it (why says how it's going); otherwise why says what it waits on.
192
+ const keyOf = (path, h) => createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
193
+ const findSent = (ledger, key) => ledger.handoffs.slice().reverse().find((x) => x.key === key) || ledger.handoffs.slice().reverse().find((x) => (x.was || []).includes(key));
194
+ // What the run there is doing and how far through its tasks it is.
195
+ function runWork(p) { try { const { work, progress } = workOf(p, readSym(p, "agent.log")); const t = work && (work.todos || []).find((x) => x.status === "in_progress"); return { doing: clipWords(String((t && t.active) || (work && work.doing) || ""), 100), progress }; } catch { return {}; } }
196
+ function handingOf(path, { ledger, map, running = runningHandoff, now = Date.now(), changedAt, work = runWork } = {}) {
197
+ if (!path) return [];
198
+ ledger = ledger || loadLedger();
199
+ const asked = parseHandoffs(readSym(path, "HANDOFF.md"));
200
+ const mine = ledger.handoffs.filter((x) => x.from.path === path && !x.skipped && now - (x.at || 0) < STUCK_FOR && (x.status === "error" ? !pastIt(x, ledger.handoffs) : !x.reportedAt && x.status !== "done")); // one that couldn't go stays until you Allow or Skip it
201
+ if (!asked.length && !mine.length) return [];
202
+ map = map || laneMap();
203
+ if (changedAt == null) try { changedAt = statSync(join(path, ".symbiot", "HANDOFF.md")).mtimeMs; } catch { changedAt = now; }
204
+ const scanned = path.startsWith(ACT_DIR) || Object.values(map).includes(path), out = [];
205
+ const short = (t) => clipWords(firstLine(t).replace(/\*\*/g, ""), 120);
206
+ for (const h of asked) {
207
+ if (findSent(ledger, keyOf(path, h))) continue; // sent: the ledger's row says where it stands
208
+ const age = now - changedAt, row = (why) => out.push({ lane: h.lane, text: short(h.text), gone: false, why });
209
+ if (!scanned) row("Not sent: Symbiot doesn't have this folder as a lane, so it never reads its HANDOFF.md");
210
+ else if (age < 60000) row("Not sent yet: Symbiot picks it up within 20 seconds");
211
+ else row(`Not sent after ${ago(age)}: Symbiot only sends handovers while its app is open`);
212
+ }
213
+ for (const e of mine.slice().reverse()) {
214
+ const row = (gone, why, more = {}) => out.push({ lane: e.to.lane, text: short(e.text), gone, why, ...more });
215
+ if (e.status === "error") { row(false, `Couldn't hand it over: ${e.error || "it didn't start"}`); continue; }
216
+ const p = e.to.path, going = !!(p && running(p)), began = p ? lastRunStart(p) : 0;
217
+ if (began < e.at && going) row(true, e.superseded ? `Sent: ${e.to.lane} is on the earlier version now (its run started ${ago(now - began)} ago), and gets this one when that run ends` : `Sent, and queued: ${e.to.lane}'s agent is busy, and starts on it when that run ends`);
218
+ else if (began < e.at && e.status === "blocked") row(false, `Sent, but it didn't start: ${e.note || "its agent didn't start"}`);
219
+ else if (began < e.at) row(true, `Sent: ${e.to.lane}'s agent starts on it when it's free`);
220
+ else if (going) { const w = work(p) || {}; row(true, "Sent: its agent is on it now", { ...(w.doing ? { doing: w.doing } : {}), ...(w.progress ? { progress: w.progress } : {}) }); }
221
+ else if (openQuestions(p)) row(true, "Sent: its agent asked you something first");
222
+ else row(true, "Sent: its run ended, and what it did comes back within 20 seconds");
223
+ }
224
+ return out.slice(0, 6);
225
+ }
226
+ const ago = (ms) => { const m = Math.round(ms / 60000); return m < 1 ? "under a minute" : m < 120 ? `${m} min` : `${Math.round(m / 60)} hours`; };
227
+ // Once what's in a HANDOFF.md has gone, the file says so instead of still reading
228
+ // like a handover waiting to go: whatsapp_module's agent saw its file still there
229
+ // and told the user it hadn't been sent, while ops had both its handovers running
230
+ // (2026-10-12). The sections become a dated list of what went where, newest first;
231
+ // a new "### <lane>" section added to it is sent as usual.
232
+ const stamp = (t) => { const d = new Date(t), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
233
+ function sentState(e, key) {
234
+ if (e.status === "error") return `couldn't hand it over: ${String(e.error || "it didn't start").replace(/\.$/, "")}`;
235
+ if (e.key !== key) return "sent earlier, and replaced since by a newer version";
236
+ if (e.superseded) return e.to.lane === OPS ? "replaces the version sent earlier: ops's run on that one gets it when it ends" : `replaces the version sent earlier, as a task in ${e.to.lane}`;
237
+ if (e.status === "held") return `queued in ${e.to.lane}: its agent was busy, and starts on it when that run ends`;
238
+ if (e.status === "blocked") return `added to ${e.to.lane}'s tasks, but its agent didn't start (${String(e.note || "").replace(/\.$/, "")})`;
239
+ return `${e.to.lane}'s agent started on it`;
240
+ }
241
+ function noteSent(path, asked, ledger) {
242
+ const file = join(path, ".symbiot", "HANDOFF.md");
243
+ const rows = asked.map((h) => { const key = keyOf(path, h), e = findSent(ledger, key); return e ? `- ${stamp(e.at || Date.now())} → **${e.to.lane}**: ${sentState(e, key)}. "${clipWords(firstLine(h.text).replace(/\*\*/g, ""), 100)}"` : null; });
244
+ if (!rows.length || rows.some((r) => !r)) return false;
245
+ const had = readSym(path, "HANDOFF.md");
246
+ if (JSON.stringify(parseHandoffs(had)) !== JSON.stringify(asked)) return false; // its agent is writing to it: next pass
247
+ const lines = had.split("\n"), at = lines.findIndex((l) => /^##\s+Sent\s*$/.test(l)), before = [];
248
+ if (at >= 0) for (const l of lines.slice(at + 1)) { if (/^#/.test(l)) break; if (/^- /.test(l)) before.push(l); }
249
+ const md = `# Handovers: sent\nSymbiot sent what was in this file to its lane, so nothing here waits to go. Each lane's agent works on it (the Agents tab shows how far it has got), and what it did comes back to you in \`.symbiot/ANSWERS.md\`. To hand over something else, or a changed version of one below, add a \`### <lane>\` section to this file: Symbiot sends it within 20 seconds.\n\n## Sent\n${[...rows, ...before].slice(0, 12).join("\n")}\n`;
250
+ try { writeFileSync(file, md); return true; } catch { return false; }
251
+ }
182
252
  // Tell the agent that asked: the result as an answer in its ANSWERS.md, then
183
253
  // start it again to carry on (unless it's still running: it reads it next time).
184
254
  // Not past a step of the user's it waits on (a key for .env, "don't start another
@@ -231,6 +301,26 @@ function alike(a, b) {
231
301
  let n = 0; for (const w of s) if (l.has(w)) n++;
232
302
  return n / s.size >= 0.5;
233
303
  }
304
+ // A lane's question about work it handed over, once that work is done: whatsapp_module
305
+ // asked "Can I reach sts-prod myself to put the WhatsApp module back?" after its two
306
+ // handovers to ops stopped, and kept asking on Home after ops had put it back and ticked
307
+ // it (2026-10-12). It's out of date when three quarters of its words (sts-prod counts
308
+ // whole) are in one of this lane's handovers from the last week, the one it's most about,
309
+ // and the lane that took it has ticked it in its TASKS.md. { lane, text } for the card, or null.
310
+ const DONE_WITHIN = 7 * 86400000, ASK_SKIP = new Set(["can", "the", "and", "for", "you", "now", "how", "its", "it's", "myself", "yourself", ...COMMON]);
311
+ const askWords = (s) => new Set(String(s || "").toLowerCase().replace(/[^a-z0-9\s'-]/g, " ").split(/\s+/).map((w) => w.replace(/^[-']+|[-']+$/g, "")).filter((w) => w.length > 2 && !ASK_SKIP.has(w)).map((w) => w.replace(/s$/, "")));
312
+ function doneSince(path, q, { ledger = loadLedger(), now = Date.now(), read = readSym } = {}) {
313
+ const want = askWords(q); if (!path || want.size < 3) return null;
314
+ let best = null, top = 0.75; // the handover it's most about, newest first on a tie
315
+ for (const h of ledger.handoffs.slice().reverse()) {
316
+ if (h.from.path !== path || h.skipped || h.status === "error" || !h.to.path || now - (h.at || 0) > DONE_WITHIN) continue;
317
+ const has = askWords(firstLine(h.text)); let n = 0; for (const w of want) if (has.has(w)) n++;
318
+ if (n / want.size > top || (!best && n / want.size === top)) { best = h; top = n / want.size; }
319
+ }
320
+ if (!best) return null;
321
+ const head = firstLine(best.text), ticked = read(best.to.path, "TASKS.md").split("\n").filter((l) => /^\s*(?:[-*+]|\d+[.)])\s*\[[xX]\]/.test(l));
322
+ return ticked.some((l) => alike(l, head)) ? { lane: best.to.lane, text: clipWords(head.replace(/\*\*/g, "").replace(/^What['’]s needed:\s*/i, ""), 120) } : null;
323
+ }
234
324
  // The folders a handover names that a run would need: ones on this computer, not
235
325
  // the asking run's own, not Symbiot's, not your home itself or a hidden one in it
236
326
  // (~/.ssh). A file named in one counts as the folder it's in. Outermost only, 3 at most.
@@ -352,4 +442,4 @@ function lanesState() {
352
442
  return { handoffs: out.slice(0, 30) };
353
443
  }
354
444
 
355
- export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike };
445
+ export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, handingOf, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike, doneSince };
package/links.mjs CHANGED
@@ -24,6 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
25
25
  import { siteUrl, signIn, readPage, trustedSites, trustSite, untrustSite } from "./headless.mjs";
26
26
  import { watchState, addWatch, removeWatch, checkWatch, GITHUB_INBOX } from "./watch.mjs";
27
+ import { loadRuns, agentQuestions, answerQuestions } from "./agents.mjs";
27
28
 
28
29
  // The standard sites. url is where you sign in and, with watch, the page that's
29
30
  // read for what's new; hosts are trusted along with it.
@@ -136,12 +137,61 @@ async function checkLink(id, { check = checkWatch, read = readPage } = {}) {
136
137
  if (l[id].watch) { const r = await check(l[id].watch); if (r && r.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) }; }
137
138
  else {
138
139
  const p = await read(e.url, { password: true });
139
- if (p && p.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) };
140
+ if (p && (p.busy || WINDOW_OPEN.test(p.error || ""))) return { busy: true, item: linksState().items.find((x) => x.id === id) };
140
141
  const l2 = linked(); if (!l2[id]) return { error: `${e.name} isn't linked.` };
141
142
  l2[id] = { ...l2[id], ok: !!(p && !p.error && !p.login), checked: Date.now() }; saveLinked(l2);
142
143
  }
143
144
  return { ok: true, item: linksState().items.find((x) => x.id === id) };
144
145
  }
146
+ const WINDOW_OPEN = /Symbiot Browser is open/;
147
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
148
+ // Once the Symbiot Browser closes (Done, or its X: server.mjs): each linked site not read
149
+ // signed in yet is read now. One the hidden browser can't take yet (an agent's page open in
150
+ // it, or the window not quite gone) is tried again a little later, so a check never just
151
+ // doesn't happen. Gives the items read signed in.
152
+ async function checkSignIns({ check = checkLink, tries = 6, wait = 10000 } = {}) {
153
+ let todo = linksState().items.filter((x) => x.state !== "off" && x.state !== "ok").map((x) => x.id);
154
+ const now = [];
155
+ for (let i = 0; i < tries && todo.length; i++) {
156
+ if (i) await sleep(wait);
157
+ const again = [];
158
+ for (const id of todo) {
159
+ let r = null; try { r = await check(id); } catch {}
160
+ const it = r && r.item;
161
+ if (it && it.state === "ok") now.push(it);
162
+ else if (r && (r.busy || (it && WINDOW_OPEN.test(it.note || "")))) again.push(id);
163
+ }
164
+ todo = again;
165
+ }
166
+ return now;
167
+ }
168
+ // A question an agent left asking you to sign in to a site ("sign in to www.reddit.com in the
169
+ // Symbiot Browser, then click Done"), once that site reads signed in: answered for you, and its
170
+ // agent carries on. Before, it sat on Home after the sign-in it asked for was done. Gives
171
+ // [{ path, q }] answered.
172
+ function settleSignIns(items, { paths, ask = agentQuestions, answer = answerQuestions } = {}) {
173
+ const list = catalog().list, hosts = (items || []).flatMap((x) => { const e = list.find((c) => c.id === x.id); return e ? e.hosts.map((h) => ({ h, name: e.name })) : []; });
174
+ if (!hosts.length) return [];
175
+ const on = (site) => { const h = hostOf(siteUrl(site)); return h.includes(".") && hosts.find((x) => h === x.h || h.endsWith("." + x.h) || x.h.endsWith("." + h)); };
176
+ if (!paths) { try { paths = [...new Set(loadRuns().map((r) => r.path).filter(Boolean))]; } catch { paths = []; } }
177
+ const done = [];
178
+ for (const path of paths) {
179
+ let qs = []; try { qs = ask(path, "").questions || []; } catch {}
180
+ const rows = qs.map((q) => { const s = signInAsked([q.q, ...(q.options || [])].join("\n")), x = s && on(s.site); return x ? { q: q.q, a: `Done: you're signed in to ${x.name} in the Symbiot Browser now (Symbiot read it signed in just now), so carry on with it.` } : null; }).filter(Boolean);
181
+ if (!rows.length) continue;
182
+ let r = null; try { r = answer(path, rows.map((x) => ({ q: x.q, a: x.a })), { rerun: true }); } catch {}
183
+ if (r && r.ok) done.push(...rows.map((x) => ({ path, q: x.q })));
184
+ }
185
+ return done;
186
+ }
187
+ // Done in the Symbiot Browser, its X, or its last window going (server.mjs): the sites are
188
+ // checked, and the questions that settles answered. One at a time, so Done and the window
189
+ // closing because of it are checked once. Gives { signedIn, answered }.
190
+ let CHECKING = null;
191
+ function sitesCheck({ check = checkSignIns, settle = settleSignIns, after = 1500 } = {}) {
192
+ return CHECKING || (CHECKING = sleep(after).then(() => check()).then((ok) => ({ signedIn: ok, answered: settle(ok) }))
193
+ .catch(() => ({ signedIn: [], answered: [] })).finally(() => { CHECKING = null; }));
194
+ }
145
195
  // Unlink: stop watching it, and untrust the hosts this link trusted (never ones
146
196
  // you trusted yourself). A host another link still uses (Outlook and Outlook
147
197
  // Calendar share one) passes to that link instead, so it goes when the last
@@ -191,4 +241,4 @@ function signInAsked(text) {
191
241
  return null;
192
242
  }
193
243
 
194
- export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite, addSite, ownSites, signInAsked, OWN };
244
+ export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, checkSignIns, settleSignIns, sitesCheck, unlinkSite, addSite, ownSites, signInAsked, OWN };
package/marketing.mjs CHANGED
@@ -126,9 +126,9 @@ function draftFiles(dir = MARKETING_DIR, names = [], { max = 30 } = {}) {
126
126
  // two `---` lines, and the rest is notes (with the picture and the date they name).
127
127
  const MEDIA_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm" };
128
128
  const SEE_MORE = 210; // where LinkedIn's feed cuts a post off with "…see more"
129
- const HEAD_KEYS = { product: "product", platform: "platform", when: "when", schedule: "when", date: "when", media: "media", image: "media", video: "media", picture: "media" };
129
+ const HEAD_KEYS = { product: "product", platform: "platform", when: "when", schedule: "when", date: "when", media: "media", image: "media", video: "media", picture: "media", where: "where", subreddit: "where", community: "where", flair: "flair" };
130
130
  function parseDraft(text) {
131
- const src = String(text || "").replace(/\r\n/g, "\n"), out = { title: "", product: "", platform: "", when: "", media: [], body: "", notes: "", format: "" };
131
+ const src = String(text || "").replace(/\r\n/g, "\n"), out = { title: "", product: "", platform: "", when: "", media: [], body: "", notes: "", format: "", where: "", flair: "" };
132
132
  const lines = src.split("\n"), head = [];
133
133
  let i = 0;
134
134
  for (; i < lines.length; i++) { const l = lines[i]; if (/^##\s/.test(l) || /^\s*-{3,}\s*$/.test(l)) break; head.push(l); }
@@ -175,10 +175,12 @@ function author({ cfg = loadConfig(), dir = MARKETING_DIR } = {}) {
175
175
  return { name: name || "You", headline: String(p.headline || "").trim(), avatar: !!avatar, initials: (name || "You").split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase() };
176
176
  }
177
177
  // A file of the lane's, by its path there, only if it is there (no ../ out of it).
178
+ // Never in .symbiot/ (the unblurred originals a capture was made from, tray-originals, are
179
+ // there) nor .git/: nothing there is served, previewed or put on a post.
178
180
  function inLane(dir, rel) {
179
181
  if (!rel || /\0/.test(rel)) return "";
180
182
  const f = join(dir, String(rel));
181
- try { const real = realpathSync(f), root = realpathSync(dir); return real.startsWith(root + "/") ? real : ""; } catch { return ""; }
183
+ try { const real = realpathSync(f), root = realpathSync(dir); return real.startsWith(root + "/") && !/^\.(?:symbiot|git)(?:\/|$)/.test(real.slice(root.length + 1)) ? real : ""; } catch { return ""; }
182
184
  }
183
185
  // The text an approval is for: a draft's post changed after you approved it isn't approved.
184
186
  const sigOf = (body) => createHash("sha256").update(String(body || "")).digest("hex").slice(0, 16);
@@ -247,15 +249,41 @@ function draftPreview(rel, { dir = MARKETING_DIR, cfg } = {}) {
247
249
  const d = parseDraft(readFileSync(f, "utf8")), base = relative(realpathSync(dir), dirname(f));
248
250
  const media = d.media.map((m) => { const r = join(base, m), ext = m.split(".").pop().toLowerCase(); return inLane(dir, r) && MEDIA_TYPES[ext] ? { rel: r, name: basename(m), kind: MEDIA_TYPES[ext].startsWith("video") ? "video" : "image" } : null; }).filter(Boolean);
249
251
  const st = statusOf(dir, rel, d.body);
250
- return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
252
+ return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, ...whereOf(d), body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
251
253
  media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0,
252
254
  ...(st && FINAL.includes(st.status) ? { final: finalSay(st), posted: st.posted || 0, postedOn: st.postedOn || "", url: st.url || "", by: [].concat(st.by || []), edited: !!st.edited } : {}) };
253
255
  }
256
+ // Where a draft goes, past its platform: a subreddit (its where: or subreddit: line, else
257
+ // the first r/<name> its notes give, as r/ClaudeAI's draft does), and its flair (a flair:
258
+ // line, else 'flair "…"' in its notes). { where, flair, handle? }, each "" when it doesn't
259
+ // say; handle, the u/<name> a Reddit draft's notes say it's posted as.
260
+ function whereOf(d) {
261
+ const reddit = /reddit/i.test(d.platform || ""), sub = (t) => { const m = String(t || "").match(/(?:^|[^\w/])\/?r\/([A-Za-z0-9_]{2,21})\b/); return m ? `r/${m[1]}` : ""; };
262
+ const w = String(d.where || "").trim(), where = w ? (reddit && (sub(w) || (/^[A-Za-z0-9_]{2,21}$/.test(w) ? `r/${w}` : ""))) || w : reddit ? sub(d.notes) : "";
263
+ const fl = d.flair || (reddit ? (String(d.notes || "").match(/\bflair\s*[:]?\s*["“]([^"”\n]{1,64})["”]/i) || [])[1] || "" : "");
264
+ const handle = reddit ? (String(d.notes || "").match(/(?:^|[^\w/])\/?u\/([A-Za-z0-9_-]{3,20})\b/) || [])[1] || "" : ""; // who it's posted as, where the notes say
265
+ return { where, flair: String(fl).trim(), ...(handle ? { handle: `u/${handle}` } : {}) };
266
+ }
254
267
  // A picture or video a draft shows, or the avatar in config.profile: { file, type } or null.
255
268
  function laneMedia(rel, { dir = MARKETING_DIR, cfg = loadConfig() } = {}) {
256
269
  const f = rel === "avatar" ? String(((cfg && cfg.profile) || {}).avatar || "") : inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
257
270
  return type && existsSync(f) ? { file: f, type } : null;
258
271
  }
272
+ // A capture of a pick tray (tray.mjs) that Symbiot couldn't vouch for alone (a desktop
273
+ // recording, a clip, one whose pictures' text wasn't read) says why in its .blur.json
274
+ // (review) until you've looked it over (reviewed); a reel, until its clips have been. Till
275
+ // then it goes on no post. reviewOf: why, for a capture by name; lookOver: for a file.
276
+ const readJsonFile = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
277
+ function reviewOf(root, name, kind = "", parts = []) {
278
+ const one = (n) => { const sp = /^\w[\w.-]{0,100}$/.test(String(n)) ? readJsonFile(join(root, `${n}.blur.json`)) : null; return sp && sp.review && !sp.reviewed ? String(sp.review) : ""; };
279
+ if (kind === "reel") { const p = (parts || []).find((x) => one(x)); return p ? `Look over its clip ${p} first: ${one(p)}` : ""; }
280
+ return one(name);
281
+ }
282
+ function lookOver(rel, { dir = MARKETING_DIR } = {}) {
283
+ const m = String(rel || "").match(/^(drafts\/[^/]+\/tray)\/([^/]+)$/); if (!m) return "";
284
+ const idx = readJsonFile(join(dir, m[1], "tray.json")) || {}, it = (Array.isArray(idx.items) ? idx.items : []).find((x) => x && Array.isArray(x.files) && x.files.includes(m[2]));
285
+ return it ? reviewOf(join(dir, m[1]), it.name, it.kind, it.parts) : "";
286
+ }
259
287
  // A post's picture or video, picked (the pick tray, tray.mjs): its `media:` line set to the
260
288
  // files, by their path in the lane, written next to the draft's own (`media: tray/home.png`);
261
289
  // the post itself untouched. Approved before, it asks for your OK again: what you approved
@@ -269,6 +297,7 @@ function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
269
297
  for (const m of [].concat(files || []).map((x) => draftRel(x, dir)).filter(Boolean)) {
270
298
  const p = inLane(dir, m), type = p && MEDIA_TYPES[p.split(".").pop().toLowerCase()];
271
299
  if (!type) return { error: `${m} isn't a picture or video in the lane.` };
300
+ const why = lookOver(m, { dir }); if (why) return { error: `Look it over first, on the Marketing page: ${why}`, review: true };
272
301
  media.push({ name: relative(dirname(f), p), video: type.startsWith("video") });
273
302
  }
274
303
  if (media.length > 1 && media.some((m) => m.video)) return { error: "A post takes one video, or pictures, not both." };
@@ -289,20 +318,24 @@ function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
289
318
  // ---- its brief ----------------------------------------------------------------------------
290
319
  // The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
291
320
  // its tasks name and where each one's code and docs are, and its rules.
292
- function marketingBrief(list = [], { map = {}, names = productNames(map) } = {}) {
321
+ function marketingBrief(list = [], { map = {}, names = productNames(map), dir = MARKETING_DIR } = {}) {
293
322
  const named = [...new Set(list.map((t) => productOf(t.text, names)).filter(Boolean))];
294
323
  // its repo: by folder, or by its name as people say it (Dailify is in CallForge AI)
295
324
  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] : ""; };
296
325
  const where = named.map((p) => ({ p, path: pathOf(p) }));
326
+ // a product with no pick tray yet: its first run starts by capturing it (capture.mjs)
327
+ const trayless = where.filter((w) => w.path && !existsSync(join(dir, DRAFTS, w.p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), "tray", "tray.json")));
297
328
  return ["## This lane: marketing",
298
329
  "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.",
299
330
  ...(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)"}`)] : []),
331
+ ...trayless.map((w) => `- **${w.p} has no pick tray yet: start by capturing it.** Run \`node "${CLI}" capture "${w.p}"\` (add \`--url <its address>\` if it isn't running here; start it first with its demo data if it has some; \`--window "<title>"\` for a desktop app). Symbiot opens it in a browser of its own, takes its key screens from its routes and README as pictures and short clips, and blurs anything private before they reach \`${DRAFTS}/<product>/tray/\`. Then build its posts around those.`),
332
+ `- Pictures and clips of a product come from Symbiot's capture (\`node "${CLI}" capture <product>\`), never a capture script of your own: Symbiot won't run one with the app's access. A post's picture or video is one from its pick tray (\`${DRAFTS}/<product>/tray/\`), blurred, that the user has looked over there; never an unblurred original (\`.symbiot/tray-originals/\`), and never a screenshot you took yourself. Say in the draft's notes which capture you'd use; the user picks it on the Marketing page.`,
300
333
  `- 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.`,
301
334
  "- 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.",
302
335
  `- Each draft holds the post apart from your notes: a \`# \` title, then \`product:\`, \`platform:\` (linkedin), \`when:\` (YYYY-MM-DD HH:MM, if it's scheduled) and \`media:\` (its picture or video, next to it) lines, then \`## Post\` with exactly the text that goes out (its line breaks, its hashtags) and nothing else, then \`## Notes\` for your reasoning, sources and anything for the user. Symbiot shows \`## Post\` as the platform's own preview, and what you post is that text, unchanged.`,
303
- `- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`, and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
336
+ `- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\` (to change part of a post already written or scheduled, \`screens type <id> <field> --replace "<old words>" "<new words>"\`: retyping all of it drops its picture), and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
304
337
  + " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved.",
305
338
  `- Once a post is out (or in the platform's scheduler), mark it posted with its link: \`node "${CLI}" marketing posted <its file> --url <the post's link>\` (\`--at "YYYY-MM-DD HH:MM"\` for a scheduled one). A post you redo in a new file: mark the old one \`node "${CLI}" marketing superseded <old file> --by <new file>\`. Either takes it off the user's list for good, and Symbiot then refuses to approve or post it again: never edit \`.symbiot/drafts.json\` by hand.`];
306
339
  }
307
340
 
308
- export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, postedCmd, MEDIA_TYPES, inLane, day };
341
+ export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, reviewOf, lookOver, postedCmd, MEDIA_TYPES, inLane, day };