symbiot 0.50.3 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.51.1
6
+
7
+ - **Allowing an agent's list works when the list is fine.** An exact command such as `rm -r ~/dailify/site` no longer counts as dangerous; only Bash with no command, a shell, sudo, or a wildcard `rm`/`dd`/`chmod` does. Before, one such rule refused the whole list, silently, and the agent asked you again and again.
8
+ - **When a list is too wide, you're told why.** The note names the rules, the agent is asked for a narrower list, and it starts again, instead of waiting on a click that can't work.
9
+ - **An agent can never edit Symbiot's own settings.** Every list Symbiot turns on also blocks editing `config.json`, where what agents may do is kept.
10
+
11
+ ## 0.51.0
12
+
13
+ What your agents built on the symbiot repo yesterday, merged with everything since:
14
+
15
+ - **`symbiot post`.** Drafts three posts a week from your real git activity, releases and CHANGELOG (shipped, learned or fixed, and a longer one) in your voice from `voice.md`. They wait on the Dashboard for Approve, Edit or Skip; nothing is published without your approval, and every choice is logged. It can draft replies to comments too, for you to approve.
16
+ - **Company knowledge folders.** Point Symbiot at a folder of documents (Settings → Knowledge folders) and every chat can quote them and say which file; worked examples are kept apart from facts.
17
+ - **Reports.** What agents write up (findings, audits, plans) shows in one place, unread ones marked.
18
+ - **Waiting on replies.** When an agent's email needs an answer, Symbiot watches your inbox for it and hands the reply on to the next step itself.
19
+ - **Facts from a run.** An agent can hand back what it learned; Remember or Skip it on the run's block.
20
+ - **A first-run sandbox**, to walk through Symbiot's setup like a new user, apart from your real data.
21
+ - **Fixed:** the same task listed twice in different words, or spelled out in steps; a task you closed coming back; long tasks cut mid-sentence; mail and WhatsApp chats you've already read still counting as waiting on you; agent runs denied the Gmail, Calendar, Drive and Notion tools right after you connected them.
22
+
5
23
  ## 0.50.3
6
24
 
7
25
  - **Finer tags on the spheres.** A sphere's tag is the short form of its task: no "(§5 of .symbiot/BRIEF…)" asides, paths or markdown, cut at a word, in smaller type and two lines at most; point at it for the whole text. The layout makes room for every tag, so a tag never lands on another tag or sphere.
package/README.md CHANGED
@@ -24,6 +24,16 @@ The newer extras are [experimental](#experimental): email, CI checks,
24
24
  local-model setup, Screens and Watch, and Symbiot on a phone. The list there says
25
25
  what's stable and what isn't.
26
26
 
27
+ ## What is Symbiot for?
28
+
29
+ For developers and small teams who owe someone an update every week (a manager,
30
+ a client, a standup, the team channel) and would rather spend that time on the
31
+ work. The work is already in git: what you committed, merged and released, and
32
+ what's still uncommitted or out of sync. Symbiot reads it on your machine and
33
+ writes the update from it, so it says what actually happened, not what you
34
+ remember on a Friday afternoon. It also hands the next tasks to your coding
35
+ agent and brings its work back to you to approve.
36
+
27
37
  ## Install
28
38
 
29
39
  ```bash
@@ -103,6 +113,8 @@ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of
103
113
  did** — files changed and commits made, read from git, whichever agent it was.
104
114
  When an agent leaves **questions, options or ideas** for you, they show up on its
105
115
  block (see [Questions from your agent](#questions-from-your-agent)).
116
+ - **Reports** — what your agents wrote up for you (findings, audits, plans), to
117
+ read in the app (see [What a run hands back](#what-a-run-hands-back-facts-to-remember-replies-to-wait-for-reports)).
106
118
  - **Settings** — [Link your work](#link-your-work), your AI, the folders to scan, your agent command, the
107
119
  [weekly write-up and start at login](#every-week-and-at-login), plus the
108
120
  [experimental](#experimental) email, model recommendations and one-click local
@@ -127,6 +139,15 @@ the chrome-less window, it uses your default browser. On an Android phone it run
127
139
  in Termux ([experimental](#on-your-phone-android-in-termux)), or as an
128
140
  [Android app](#the-android-app-apk) of its own.
129
141
 
142
+ **Try it as someone new: `symbiot app --fresh`.** A brand-new Symbiot opens next
143
+ to yours, marked **sandbox** by its version, to walk through first run and
144
+ setup: no AI connected (keys in your environment are left out), no memory, no
145
+ linked accounts, no lanes, none of your repos. It has its own home folder under
146
+ your temp folder, deleted when you quit it (`--keep` keeps it, to see what setup
147
+ wrote), and it never offers an update. A folder you add to it is read like any
148
+ other, and agents it starts run as you on this computer, without your Claude
149
+ sign-in.
150
+
130
151
  ### Every week, and at login
131
152
 
132
153
  Two switches in Settings do what the old `symbiot-desktop` tray app did (that
@@ -377,6 +398,13 @@ Handoff says which of your linked sites aren't wired up, and each run's `TASKS.m
377
398
  says which connectors it has and which it doesn't, so it doesn't claim to have
378
399
  checked your mail.
379
400
 
401
+ Claude writes a claude.ai connector into `~/.claude.json` only after a session
402
+ has connected it, so the first run after you connect Gmail used to get Gmail's
403
+ tools with every call denied. A site you link in Symbiot (Gmail, Google Calendar,
404
+ Google Drive, Notion) now gets its claude.ai connector's rule on every Claude run,
405
+ even before Claude records it or while it waits to be authorized. A rule for tools
406
+ a run doesn't have does nothing.
407
+
380
408
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
381
409
  **Agents** tab. The command is saved as `agentCmd` in
382
410
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
@@ -548,7 +576,7 @@ Symbiot's config folder, readable by you only.
548
576
  **Connections** (on the Dashboard, and in Settings) has one entry per standard work site: Gmail,
549
577
  Outlook, Google and Outlook Calendar, GitHub, GitLab, Slack, Microsoft Teams,
550
578
  WhatsApp, Jira & Confluence, Linear, Asana, Trello, Google Drive, Notion,
551
- HubSpot and Salesforce. One click:
579
+ HubSpot, Salesforce and LinkedIn. One click:
552
580
 
553
581
  1. opens the site in Symbiot's own browser, where you sign in the usual way (its
554
582
  own login, SSO and 2FA; Symbiot never sees your password). Close the window
@@ -578,6 +606,43 @@ hides the ones you don't use. An entry with a built-in's id replaces it:
578
606
  Linking stays in the app. Like trusted sites, there's no command an agent could
579
607
  call to link a site itself.
580
608
 
609
+ ## Post about your week: `symbiot post`
610
+
611
+ `symbiot post` turns your last 7 days of real work into 3 draft LinkedIn posts,
612
+ in your own voice: what you **shipped**, one thing you **learned or fixed**, and a
613
+ **longer post** (or a short thread) about the best of it. It reads the commits,
614
+ release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scans,
615
+ and nothing else. **It never posts by itself**, and it doesn't schedule.
616
+
617
+ - **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
618
+ line of `---` between each. Or link LinkedIn (above) and click **Fill from
619
+ LinkedIn** on the Dashboard (`symbiot post voice --linkedin`), which reads your
620
+ recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
621
+ - **Only what git shows.** Your AI gets the week as numbered facts and must say
622
+ which ones each post uses. A post that names a number, version, name or user
623
+ count the facts don't show is sent back once, then dropped.
624
+ - **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
625
+ **Edit** and **Skip**. Approve copies the post to your clipboard and opens
626
+ LinkedIn's share box: you paste it and post it yourself.
627
+ - **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
628
+ edit, approval and skip, with its text, date and platform.
629
+ - **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
630
+ show on its Dashboard card, marked **maybe a customer** when they ask how to
631
+ install it, what it costs or about team use. **Draft a reply** has your agent
632
+ type a reply into the comment box without posting it (it can't press Post,
633
+ Comment, Reply or Send), and write it in its brief for you to paste.
634
+
635
+ ```bash
636
+ symbiot post # draft this week's 3 posts
637
+ symbiot post list # the drafts waiting on you
638
+ symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
639
+ symbiot post edit <id> "text" # change a draft's words
640
+ symbiot post skip <id> # drop one
641
+ symbiot post log # everything that happened
642
+ ```
643
+
644
+ With no AI connected it says so and does nothing else.
645
+
581
646
  ## One Symbiot, everywhere
582
647
 
583
648
  The chat on each Dashboard card and the chat on each task aren't separate bots:
@@ -624,6 +689,33 @@ hand, a cable, which network you're on), your identity or secrets (signing in, a
624
689
  account, spending money, sending something in your name). Every brief tells the
625
690
  agent so, and such a question says which: `👤 You (only you: your Meta token): …`.
626
691
 
692
+ ### What a run hands back: facts to remember, replies to wait for, reports
693
+
694
+ Every brief also tells the agent what else it can leave in `.symbiot/`:
695
+
696
+ - **`REMEMBER.json`**: lasting facts it found (who someone is, which account is
697
+ what, a decision), as `[{ "name", "kind", "fact" }]`. A run can't write
698
+ Symbiot's memory itself. Once it stops, its block in the Agents tab shows them,
699
+ each ticked, with **Remember** and **Skip**. Remember puts the ticked ones into
700
+ memory, marked with the run's name; Skip drops them. Either way it's asked once.
701
+ - **`AWAITING.json`**: an email it sent (or drafted for you to send) that waits on
702
+ a reply, as `[{ "to", "subject", "asked", "next", "task", "lane" }]`. Symbiot
703
+ watches for the reply itself, so you never have to say "they replied": in what
704
+ Watch finds new in your inbox, and, with Email switched on, in the inbox beside
705
+ the Sent folder you linked (headers only, and only the reply it waits for). When
706
+ it's in, the lane that does the next step (`lane`, else the one that sent it)
707
+ gets it as a task and its agent starts, told what was asked for and what to do
708
+ next. The task it's for says so in its chat, and you get a notification. The
709
+ Dashboard lists **Waiting on replies**, with **Stop waiting** on each. Anything
710
+ the reply leaves for you to decide comes back as that agent's question.
711
+ - **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
712
+ is, with a `# ` title. **Reports** in the app lists every one your runs left,
713
+ in your repos and in runs of their own, newest first, unread ones marked; click
714
+ one to read it there, tables and all. Home shows how many are unread.
715
+ `symbiot reports` lists them in a terminal, and `symbiot reports <id>` prints
716
+ one. Symbiot's own files there (TASKS, QUESTIONS, ANSWERS, HANDOFF, SKIPPED)
717
+ and the briefs handed in (`BRIEF*.md`) aren't reports.
718
+
627
719
  ## Connect it (once)
628
720
 
629
721
  Pick the AI you want it to write with:
@@ -688,12 +780,44 @@ coloured) with their languages, and can receive tasks like a repo.
688
780
  gigantic folder — it stops and shows what it found so far, marked **partial**,
689
781
  instead of hanging. The CLI shows scan progress on one line as it goes.
690
782
 
783
+ ### Knowledge folders
784
+
785
+ Repos are your code; a **knowledge folder** is your documents — a company folder
786
+ of how work is done, policies, price lists, notes. Add one under **Settings →
787
+ Knowledge folders** in the app, or with `symbiot knowledge add ~/Company`. Symbiot
788
+ reads its Markdown, CSV and text files (Word, PDF and Excel are counted as "not
789
+ read yet") into an index of its own, `~/.config/symbiot/knowledge.json`, readable
790
+ only by you and kept apart from its memory. While the app runs it re-reads only
791
+ the files that changed, every few minutes; `symbiot knowledge index` does it now.
792
+
793
+ Every chat in the app — the home bar, a Dashboard card, a task — then gets the few
794
+ passages that best answer what you asked, each with the file it came from, and
795
+ quotes and cites that file when it uses one.
796
+
797
+ - **Examples aren't facts.** Say which paths in a folder hold worked examples
798
+ (`--examples templates/,active/`, or the second box in Settings; `templates/`
799
+ unless you say otherwise, meaning any folder of that name). A file whose front
800
+ matter says `example: true` is one too. Examples never come back in a chat;
801
+ `symbiot knowledge search … --with-examples` finds them, marked as examples.
802
+ Add the folder again to change its examples.
803
+ - **Cases.** A folder's `README.md` with *Who does it*, *When*, *Steps*, *Hands
804
+ off to* and *What Symbiot should learn and remember*, and items whose front
805
+ matter has `owner`, `status`, `due` and `waiting_on` (like
806
+ `active/<item>/STATUS.md`), answer "what's waiting on me?" and "who owns X?"
807
+ straight from those fields. "Me" is the name in `symbiot knowledge me "Your
808
+ Name"` (saved as `myName` in config.json), or else your git `user.name`; your
809
+ first name on its own counts too.
810
+ - `symbiot knowledge` lists the folders and what was read; `search`, `waiting`
811
+ and `owner` answer from the terminal (`symbiot knowledge help`).
812
+
691
813
  ## What it reads, and what it doesn't
692
814
 
693
815
  - **Reads:** your local git — commit messages and changed-file names, plus
694
816
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. If you switch
695
817
  [email](#your-sent-email-without-an-api-symbiot-mail) on, it also reads the headers
696
- of mail you sent (subject, recipients, date). All local.
818
+ of mail you sent (subject, recipients, date), and, while an agent's email
819
+ [waits on a reply](#what-a-run-hands-back-facts-to-remember-replies-to-wait-for-reports),
820
+ the headers in your inbox there to find that reply (only it is kept). All local.
697
821
  - **Sends to the AI:** commit messages and dates, changed-file **names**, the
698
822
  folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
699
823
  the README and any `CLAUDE.md`/`AGENTS.md` conventions. With email on, it also
@@ -934,6 +1058,13 @@ chats with unread messages from them count on the Dashboard and in Standup, get
934
1058
  notification and a brief, and the brief and the card's chat are told who each one
935
1059
  is from.
936
1060
 
1061
+ **Mail: only unread mail needs you.** Gmail starts each unread row with "unread".
1062
+ Symbiot keeps that mark, so an email you've already read (on your phone, say) is
1063
+ listed on the card marked "read", but it isn't counted, notified or briefed. One
1064
+ you read after Watch found it stops counting at the next read, and counts again if
1065
+ you mark it unread. Another inbox gets the same once Watch has seen it mark a row
1066
+ unread. Until then, every new email counts.
1067
+
937
1068
  **Draft a reply.** A new email under Watching has **Draft a reply**. It hands the
938
1069
  email to your coding agent (the command in Settings → Handoff), which opens it in
939
1070
  your inbox through Screens, reads it, writes a reply and leaves it in Drafts, for
package/adapt.mjs CHANGED
@@ -48,7 +48,7 @@ const MIN_EVENTS = 12; // under this many uses, adaptation fad
48
48
  const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
49
49
  const HYSTERESIS = 0.15;
50
50
  const FITTS = { a: 0.1, b: 0.15 }; // seconds; only ratios matter here
51
- const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings"];
51
+ const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports"];
52
52
 
53
53
  function loadAdapt(file = ADAPT_FILE) {
54
54
  try { const d = JSON.parse(readFileSync(file, "utf8")); return { shapes: d.shapes || {}, trans: d.trans || {}, hours: d.hours || {}, modes: d.modes || {}, slots: d.slots || {}, shown: d.shown || null, events: d.events || 0 }; }
package/agents.mjs CHANGED
@@ -7,8 +7,9 @@ import { join, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
9
9
  import { randomBytes, createHash } from "node:crypto";
10
- import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
10
+ import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
11
  import { parseRun, lastRunText } from "./work.mjs";
12
+ import { parseFacts } from "./handover.mjs";
12
13
 
13
14
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
14
15
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -84,14 +85,20 @@ function claudeConnectors(dir = "", file = join(homedir(), ".claude.json")) {
84
85
  const proj = dir && j.projects && typeof j.projects === "object" ? j.projects[dir] : null;
85
86
  const needsAuth = new Set(names(j.mcpNeedsAuthNoticed));
86
87
  return [...new Set([...names(j.claudeAiMcpEverConnected), ...servers(j.mcpServers), ...servers(proj && proj.mcpServers)])]
87
- .map((name) => ({ name, rule: "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_"), ready: !needsAuth.has(name) }));
88
+ .map((name) => ({ name, rule: connectorRule(name), ready: !needsAuth.has(name) }));
88
89
  }
89
90
  // A Claude command with the ready connectors' rules added, for this run only:
90
91
  // the saved command stays as typed, so linking or unlinking one takes effect on
91
92
  // the next run. Any other agent's command runs as-is (Settings says so).
92
- function withConnectors(tmpl, dir, file) {
93
+ // Claude records a claude.ai connector (claudeAiMcpEverConnected) only once a
94
+ // session has connected it, so the first run after you connect Gmail got Gmail's
95
+ // tools without a rule for them, and every call was denied. A site linked in
96
+ // Symbiot is allowed its claude.ai connector's tools too, recorded or not (a rule
97
+ // for tools a run doesn't have does nothing).
98
+ function withConnectors(tmpl, dir, file, linked = loadConfig().linked) {
93
99
  if (!isClaudeCmd(tmpl)) return tmpl;
94
- return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
100
+ const rules = [...claudeConnectors(dir, file).filter((c) => c.ready).map((c) => c.rule), ...linkedRules(linked)];
101
+ return rules.reduce((cmd, r) => allowTool(cmd, r), tmpl).replace(/\s+$/, "");
95
102
  }
96
103
  // Linking a site in Symbiot (Links: Gmail, Drive…) signs Symbiot's own browser in;
97
104
  // it doesn't give Claude's runs that site's tools. Those come from Claude's own
@@ -101,6 +108,14 @@ function withConnectors(tmpl, dir, file) {
101
108
  // with the one it has (connector: its name, or "") and whether it's ready.
102
109
  const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
103
110
  const LINK_CONNECTOR = { gmail: ["Gmail", /gmail/i], outlook: ["Outlook", /outlook|microsoft 365/i], gcal: ["Google Calendar", /google calendar/i], gdrive: ["Google Drive", /google drive/i], notion: ["Notion", /notion/i], slack: ["Slack", /slack/i], jira: ["Jira & Confluence", /atlassian|jira|confluence/i], linear: ["Linear", /linear/i], asana: ["Asana", /asana/i], hubspot: ["HubSpot", /hubspot/i] };
111
+ // The claude.ai connector each of those is, by the name Claude gives it (as seen
112
+ // in ~/.claude.json), for its rule before Claude has recorded it (withConnectors).
113
+ const CLAUDE_AI_CONNECTOR = { gmail: "claude.ai Gmail", gcal: "claude.ai Google Calendar", gdrive: "claude.ai Google Drive", notion: "claude.ai Notion" };
114
+ const connectorRule = (name) => "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_");
115
+ function linkedRules(l) {
116
+ if (!l || typeof l !== "object" || Array.isArray(l)) return [];
117
+ return Object.keys(l).filter((id) => CLAUDE_AI_CONNECTOR[id]).map((id) => connectorRule(CLAUDE_AI_CONNECTOR[id]));
118
+ }
104
119
  function linkedConnectors(conns = claudeConnectors(), l = loadConfig().linked) {
105
120
  if (!l || typeof l !== "object" || Array.isArray(l)) return [];
106
121
  return Object.keys(l).filter((id) => LINK_CONNECTOR[id]).map((id) => {
@@ -116,7 +131,10 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
116
131
  const nm = (s) => s.replace(/^claude\.ai\s+/i, ""), names = off.map((x) => x.name), them = names.length > 1 ? names.slice(0, -1).join(", ") + " and " + names.at(-1) : names[0];
117
132
  const has = ready.length ? `this run can use ${ready.map((c) => `${nm(c.name)} (\`${c.rule}__…\` tools)`).join(", ")}.` : "";
118
133
  const not = off.length ? ` ${them} ${off.length === 1 ? "is" : "are"} linked in Symbiot but not ${off.some((x) => x.connector) ? "ready " : ""}as a Claude connector, so this run has no tools for ${off.length === 1 ? "it" : "them"}: don't say you checked ${off.length === 1 ? "it" : "them"}. What's new there is in \`node "${CLI}" watch new\`; to read more, ask the user (👤) to connect ${them} in claude.ai → Settings → Connectors.` : "";
119
- return (has + not).trim();
134
+ // withConnectors allows these anyway: Claude may have connected one since
135
+ const early = off.filter((x) => CLAUDE_AI_CONNECTOR[x.id]).map((x) => `\`${connectorRule(CLAUDE_AI_CONNECTOR[x.id])}__…\``);
136
+ const anyway = early.length ? ` If ${early.join(" or ")} tools are here after all, the connector was just connected: they're allowed, so use them.` : "";
137
+ return (has + not + anyway).trim();
120
138
  }
121
139
  // For Settings → Handoff: the connectors, whether this command's runs get them, and
122
140
  // the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
@@ -179,11 +197,11 @@ function earlierRuns() {
179
197
  const tracked = new Set(HANDOFFS.map((e) => e.path)), out = [];
180
198
  for (const r of loadRuns()) {
181
199
  if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
182
- const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path);
183
- if (!busy && !ask.questions.length && !wait) continue;
200
+ const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
201
+ if (!busy && !ask.questions.length && !wait && !facts.length) continue;
184
202
  const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
185
203
  let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readFileSync(log, "utf8"))); if (!busy) end = statSync(log).mtimeMs; } catch {}
186
- out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
204
+ out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, remember: facts.length ? facts : null, fromHeld: false });
187
205
  }
188
206
  return out;
189
207
  }
@@ -326,18 +344,44 @@ const HELD = "TASKS.next.md";
326
344
  function writeTasks(path, md) {
327
345
  const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
328
346
  if (runningHandoff(path)) { writeFileSync(join(dir, HELD), md); return true; }
329
- writeFileSync(join(dir, "TASKS.md"), md);
347
+ writeFileSync(join(dir, "TASKS.md"), md); noteHanded(path, md);
330
348
  try { unlinkSync(join(dir, HELD)); } catch {} // superseded by this brief
331
349
  return false;
332
350
  }
351
+ // ---- a task the agent deleted from TASKS.md --------------------------------
352
+ // TASKS.md is rebuilt from tasks.json on every send, so an agent deleting a
353
+ // line (the user said to drop that task) changed nothing, and the next brief
354
+ // brought it back (GhostAIChat's `pod install`). handed.json keeps what the
355
+ // brief in TASKS.md handed out; a task it handed out that's no longer in the
356
+ // file (ticked or not) was deleted: droppedTasks lists them, and tasks.mjs
357
+ // closes them (applyDrops). The task lines are the `## Tasks` section's, or the
358
+ // whole file's when it has none; a file emptied or gone deleted nothing.
359
+ const HANDED = "handed.json", BOX = /^\s*-\s*\[[ x]\]\s*/i;
360
+ function briefTasks(md) {
361
+ const lines = String(md || "").split("\n"), at = lines.findIndex((l) => /^##\s+Tasks\s*$/.test(l));
362
+ const end = at < 0 ? lines.length : lines.findIndex((l, i) => i > at && /^##\s/.test(l));
363
+ return lines.slice(at + 1, end < 0 ? lines.length : end).filter((l) => BOX.test(l)).map((l) => l.replace(BOX, "").trim()).filter(Boolean);
364
+ }
365
+ function noteHanded(path, md, also = []) {
366
+ try { writeFileSync(join(path, ".symbiot", HANDED), JSON.stringify({ at: Date.now(), tasks: [...briefTasks(md), ...also] })); } catch {}
367
+ }
368
+ function droppedTasks(path) {
369
+ let h = null; try { h = JSON.parse(readSymbiot(path, HANDED)); } catch {}
370
+ const md = readSymbiot(path, "TASKS.md");
371
+ if (!h || !Array.isArray(h.tasks) || !md.trim() || !/^##\s+Tasks\s*$|^\s*-\s*\[[ x]\]/im.test(md)) return [];
372
+ const now = briefTasks(md);
373
+ return h.tasks.filter((t) => !now.some((n) => sameTask(n, t))).map((text) => ({ text, at: h.at || 0 }));
374
+ }
333
375
  // Swap the held brief in, keeping the ticks the agent made meanwhile: a tick
334
376
  // is how a task reaches review, so dropping one would lose that task's work.
377
+ // A task the agent deleted meanwhile stays out, and stays deleted in handed.json.
335
378
  function releaseHeldTasks(path) {
336
379
  const held = readSymbiot(path, HELD); if (!held || runningHandoff(path)) return false;
337
380
  const isTick = /^\s*-\s*\[x\]\s*/i, key = (l) => l.replace(/^\s*-\s*\[[ x]\]\s*/i, "").trim().toLowerCase();
338
381
  const ticked = new Set(readSymbiot(path, "TASKS.md").split("\n").filter((l) => isTick.test(l)).map(key));
339
- const md = held.split("\n").map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
340
- try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); return true; } catch { return false; }
382
+ const gone = droppedTasks(path).map((d) => d.text), isGone = (l) => /^\s*-\s*\[ \]/.test(l) && gone.some((g) => sameTask(g, key(l)));
383
+ const md = held.split("\n").filter((l) => !isGone(l)).map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
384
+ try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); noteHanded(path, md, gone); return true; } catch { return false; }
341
385
  }
342
386
  // Land the held brief and, if it leaves anything open, start an agent on it.
343
387
  // Runs when an agent this process started exits, and when the app next checks
@@ -512,7 +556,7 @@ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbio
512
556
  // bullet under Questions is a question with no options.
513
557
  function parseQuestions(md) {
514
558
  const questions = [], suggestions = []; let sec = "q", cur = null;
515
- const clip = (s) => String(s).trim().slice(0, 1000); // as long as a task holds (addTask), so an idea added in one click arrives whole
559
+ const clip = (s) => clipWords(String(s).trim(), TASK_MAX); // as long as a task holds (addTask), so an idea added in one click arrives whole
516
560
  for (const raw of String(md || "").split(/\r?\n/)) {
517
561
  const l = raw.trim(); if (!l) continue;
518
562
  let m;
@@ -547,7 +591,7 @@ function suggestionTarget(s) {
547
591
  const SKIPPED = "SKIPPED.md";
548
592
  const skippedIdeas = (path) => [...readSymbiot(path, SKIPPED).matchAll(/^-\s+(.+)$/gm)].map((m) => m[1].trim());
549
593
  function skipIdea(path, text) {
550
- path = String(path || ""); text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
594
+ path = String(path || ""); text = clipWords(String(text || "").replace(/\s+/g, " ").trim(), TASK_MAX);
551
595
  if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
552
596
  if (!text) return { error: "No idea to skip." };
553
597
  const f = join(path, ".symbiot", SKIPPED);
@@ -581,23 +625,32 @@ function agentQuestions(path, repo) {
581
625
  // An allow list an agent proposed (.symbiot/allowlist.proposed.json) is turned on for
582
626
  // that folder only (.claude/settings.local.json, merged), unless it would let an agent
583
627
  // run anything at all (a shell, sudo, rm, Bash(*)): then it stays the user's step.
584
- const WIDE_RULE = /^Bash(\((\*|sudo|su|rm|bash|sh|zsh|dash|dd|mkfs|chmod|chown|eval|exec)\b[^)]*\))?$|^Bash\(\*/i;
628
+ // A rule is wide when it lets an agent run anything: Bash with no command, a shell
629
+ // (bash, sh, eval…) or sudo in any form, or a destructive command (rm, dd, chmod…)
630
+ // with a wildcard. The same command spelled out exactly (rm -r ~/x/site) is narrow.
631
+ const ANY_RULE = /^Bash(\(\s*\*.*\))?$|^Bash\(\s*(sudo|su|bash|sh|zsh|dash|fish|eval|exec|env|xargs)\b/i;
632
+ const HARM_RULE = /^Bash\(\s*(rm|dd|mkfs|chmod|chown|shred|truncate)\b/i;
633
+ const wideRule = (r) => ANY_RULE.test(r) || (HARM_RULE.test(r) && /\*/.test(r));
634
+ // Whatever an agent is allowed, it never edits Symbiot's own settings: that's where
635
+ // what agents may do is kept, so an agent could widen its own permissions there.
636
+ const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"); return [`Edit(${c})`, `Write(${c})`]; };
585
637
  function installAllowlist(path) {
586
638
  let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
587
639
  const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
588
640
  const allow = list(p.allow), deny = list(p.deny), dirs = list(p.additionalDirectories);
589
641
  if (!allow.length && !dirs.length) return null;
590
- if (allow.some((r) => WIDE_RULE.test(r))) return null;
642
+ const wide = allow.filter(wideRule);
643
+ if (wide.length) return { refused: wide };
591
644
  const f = join(path, ".claude", "settings.local.json");
592
645
  let cur = {}; try { cur = JSON.parse(readFileSync(f, "utf8")); } catch {}
593
646
  const perm = cur.permissions || {}, merge = (a, b) => [...new Set([...list(a), ...b])];
594
- cur.permissions = { ...perm, allow: merge(perm.allow, allow), deny: merge(perm.deny, deny), additionalDirectories: merge(perm.additionalDirectories, dirs) };
647
+ cur.permissions = { ...perm, allow: merge(perm.allow, allow), deny: merge(perm.deny, [...deny, ...GUARD()]), additionalDirectories: merge(perm.additionalDirectories, dirs) };
595
648
  try { mkdirSync(join(path, ".claude"), { recursive: true }); writeFileSync(f, JSON.stringify(cur, null, 2) + "\n", { mode: 0o600 }); } catch { return null; }
596
649
  return [`the allow list for this folder (${allow.length} rule${allow.length === 1 ? "" : "s"})`];
597
650
  }
598
- function grantFromStep(step, path = "") {
651
+ function grantFromStep(step, path = "", notes = {}) {
599
652
  const t = String(step || "");
600
- if (path && /allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(t)) { const g = installAllowlist(path); if (g) return g; }
653
+ if (path && /allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(t)) { const g = installAllowlist(path); if (g && g.refused) { notes.refused = g.refused; return null; } if (g) return g; }
601
654
  if (!/only you: a permission|only you: what (this|the) agent|\b(let|allow) (the )?agents?\b|\ballow (access|reading)\b/i.test(t)) return null;
602
655
  const got = [];
603
656
  const dir = (t.match(/(~\/[^\s,;)`'"]+|\/(?:home|Users|opt|srv|mnt|media|tmp)\/[^\s,;)`'"]+)/) || [])[1];
@@ -612,7 +665,7 @@ function answerQuestions(path, answers, opts = {}) {
612
665
  if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
613
666
  const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
614
667
  const rows = (Array.isArray(answers) ? answers : [])
615
- .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
668
+ .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, TASK_MAX) }))
616
669
  .filter((x) => x.q && x.a);
617
670
  if (!rows.length) return { error: "Pick or type at least one answer." };
618
671
  const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
@@ -627,9 +680,18 @@ function answerQuestions(path, answers, opts = {}) {
627
680
  const youOpts = (q) => ((asked.get(qKey(q)) || {}).options || []).filter(yourStep).map((o) => o.split("🤖")[0]);
628
681
  const steps = rows.filter((x) => yourStep(x.a) || holdAnswer(x.a)).map((x) => yourStep(x.a) ? { step: stepText(x.a), named: x.a.split("🤖")[0] }
629
682
  : { step: (youOpts(x.q).map(stepText)[0] || x.a).trim(), named: [x.a, ...youOpts(x.q)].join(" ") });
630
- const granted = [];
631
- const yours = steps.map((s) => s.step).filter(Boolean).filter((st) => { const g = grantFromStep(st, path); if (g) granted.push(...g); return !g; });
683
+ const granted = [], notes = {};
684
+ let yours = steps.map((s) => s.step).filter(Boolean).filter((st) => { const g = grantFromStep(st, path, notes); if (g) granted.push(...g); return !g; });
632
685
  if (granted.length) out.granted = granted;
686
+ // A list too wide to turn on by a click: say which rules, tell the agent to ask for
687
+ // less, and don't leave it as the user's step (picking it again would do nothing).
688
+ if (notes.refused) {
689
+ const which = notes.refused.slice(0, 3).map((r) => "`" + r + "`").join(", ");
690
+ try { writeFileSync(join(path, ".symbiot", "ANSWERS.md"), readSymbiot(path, "ANSWERS.md").replace(/\s*$/, "\n") + `\n### Symbiot didn't turn your allow list on\nIt has ${which}, which would let you run anything (a shell, sudo, or a wildcard rm/dd/chmod). Write a narrower .symbiot/allowlist.proposed.json: each such command spelled out exactly, the paths it touches named, and ask again.\n_${day}_\n`); } catch {}
691
+ out.refused = notes.refused;
692
+ yours = yours.filter((st) => !/allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(st));
693
+ out.note = `Not turned on: the list has ${which}, which would let an agent run anything. Your agent has been asked for a narrower one` + (opts.rerun ? " and is starting again." : ".");
694
+ }
633
695
  if (yours.length) {
634
696
  const step = yours.join(" "), files = namedFiles(steps.map((s) => s.named).join(" "), path);
635
697
  try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), cmd: runCmd(path), at: Date.now(), rerun: !!opts.rerun })); } catch {}
@@ -642,6 +704,10 @@ function answerQuestions(path, answers, opts = {}) {
642
704
  if (out.granted) out.note = "Allowed " + out.granted.join(", ") + " for your agents" + (opts.rerun ? ", and carried on. " : ". ") + (out.note || "");
643
705
  return out;
644
706
  }
707
+ // What a run left for Symbiot's memory (.symbiot/REMEMBER.json, handback.mjs):
708
+ // shown on its block with Remember and Skip once it has stopped. [] when none.
709
+ const FACTS = "REMEMBER.json";
710
+ const factsOf = (path) => parseFacts(readSymbiot(path, FACTS));
645
711
  // The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
646
712
  function heldTasks(path) {
647
713
  const md = readSymbiot(path, HELD);
@@ -675,8 +741,9 @@ function agentsList() {
675
741
  let log = ""; try { log = readFileSync(e.log, "utf8"); } catch {}
676
742
  const { tail, work, progress } = workOf(e.path, log);
677
743
  const first = !seen.has(e.path); seen.add(e.path);
678
- return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, fromHeld: !!e.fromHeld };
744
+ const facts = first && e.status !== "running" ? factsOf(e.path) : [];
745
+ return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
679
746
  }).concat(earlierRuns());
680
747
  }
681
748
 
682
- export { withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
749
+ export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
package/core.mjs CHANGED
@@ -36,6 +36,12 @@ function saveConfig(cfg) {
36
36
  const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
37
37
  function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
38
38
  function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
39
+ // How much a task holds: one line in TASKS.md, long enough for a handover or a
40
+ // pasted list of steps (1000 cut the Jono email task off mid-sentence). Longer,
41
+ // it keeps its whole text (full) and its line links to that (tasks.mjs addTask).
42
+ const TASK_MAX = 4000;
43
+ // A heading or a one-line summary: cut at a word and marked "…", never mid-word.
44
+ const clipWords = (s, max) => { s = String(s || ""); return s.length > max ? s.slice(0, max - 1).replace(/\s+\S*$/, "") + "…" : s; };
39
45
  // Two wordings of one task: the same words once case, spacing and punctuation
40
46
  // are set aside (a colon for a bracket, `code` for code), or one is the other
41
47
  // with a clause more on the end. An extension only counts when the shorter one
@@ -48,7 +54,7 @@ function sameTask(a, b) {
48
54
  const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
49
55
  if (x === y) return true;
50
56
  const [s, l] = x.length < y.length ? [x, y] : [y, x];
51
- return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b) || sameAsk(a, b);
57
+ return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b) || sameAsk(a, b) || spelledOut(a, b);
52
58
  }
53
59
  // Nearly the same task, two ways:
54
60
  // - nearly all the same words in the same order, 12+ words each ("…nothing
@@ -102,6 +108,24 @@ function sameAsk(a, b) {
102
108
  let both = 0; for (const w of x) if (y.has(w)) both++;
103
109
  return both / Math.min(x.size, y.size) >= ASK;
104
110
  }
111
+ // A task spelled out in steps is the task it spells out: "A mail connector linked
112
+ // in Symbiot still doesn't reach agent runs (Drive does now). Pass its tools
113
+ // through…" and "In the symbiot repo: a mail connector linked in Symbiot still
114
+ // doesn't reach agent runs, but the Drive connector now does. 1) Find where… 2)…".
115
+ // The steps name much more (CONTRIBUTING.md, agentCmd), so the whole of it is
116
+ // never the same ask; its first sentence, without "In the … repo:", is (sameAsk),
117
+ // set against the other task or that one's first sentence. Stricter than sameAsk
118
+ // on its own, as a first sentence is short: not when each has a word the other
119
+ // hasn't ("a mail connector…" isn't "a calendar connector…").
120
+ const STEPS = /(?:^|\s)1[.)]\s[\s\S]*\s2[.)]\s/;
121
+ const firstSentence = (s) => { const m = String(s || "").match(/^(.*?[.!?])["'`)\]]*(?:\s|$)/s); return m ? m[1] : String(s || ""); };
122
+ function bothAdd(a, b) { const x = askWords(a), y = askWords(b); return [...x].some((w) => !y.has(w)) && [...y].some((w) => !x.has(w)); }
123
+ function spelledOut(a, b) {
124
+ const [s, l] = String(a || "").length < String(b || "").length ? [String(a || ""), String(b || "")] : [String(b || ""), String(a || "")];
125
+ if (!STEPS.test(l) || STEPS.test(s)) return false;
126
+ const head = firstSentence(l.split(/(?:^|\s)1[.)]\s/)[0].trim()).replace(/^in (?:the )?\S+ (?:repo|project|folder)\s*[:,]\s*/i, "");
127
+ return [s, firstSentence(s)].some((t) => sameAsk(head, t) && !bothAdd(head, t));
128
+ }
105
129
  // One of each: near-duplicates collapse into the wording that says the most.
106
130
  function uniqueTasks(texts) {
107
131
  const out = [];
@@ -156,4 +180,4 @@ function repoState(repoPath) {
156
180
  return { branch, dirty, del, mod, add, stale, staleBy, behind };
157
181
  }
158
182
 
159
- export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
183
+ export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };