symbiot 0.26.0 → 0.27.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.
Files changed (3) hide show
  1. package/README.md +186 -27
  2. package/index.mjs +304 -63
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,20 +12,30 @@ symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
14
  symbiot app open the visual app in your browser
15
+ symbiot drift what's out of sync / at risk across your repos
16
+ symbiot push write tasks into each repo for your coding agent
15
17
  symbiot models recommend AI models for your hardware
18
+ symbiot setup-local set up a free local model (Ollama), one command
16
19
  symbiot login connect it to an AI (once)
17
20
  symbiot whoami show how it's connected
21
+ symbiot logout forget saved credentials
18
22
  symbiot help
19
23
  ```
20
24
 
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install -g symbiot # or run without installing: npx symbiot week
29
+ ```
30
+
21
31
  ## Prefer a window? `symbiot app`
22
32
 
23
33
  ```bash
24
34
  symbiot app
25
35
  ```
26
36
 
27
- Starts a tiny local server (127.0.0.1 only, protected by a one-time token) and
28
- opens the visual app in your browser — in a clean, chrome-less window if you have
37
+ Starts a tiny local server (127.0.0.1 only, protected by a token) and opens the
38
+ visual app in your browser — in a clean, chrome-less window if you have
29
39
  Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. No Electron,
30
40
  no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
31
41
 
@@ -33,29 +43,53 @@ Tabs:
33
43
 
34
44
  - **Map** — a live, interactive node graph of your work, built from your local git:
35
45
  you at the centre, your repos, and the languages and tools they share (so related
36
- projects cluster). **Scroll to zoom, drag to pan, click a node.** Clicking a repo
37
- shows its branch/commits/uncommitted/stack and a **Suggest next steps** button —
38
- your model reads that repo's recent commits + open TODOs and proposes *In flight /
39
- Next steps / Ideas*. The graph itself **needs no AI key** (pure local data);
40
- suggestions use your chosen model (free with local Ollama). This is the landing view.
46
+ projects cluster), plus the coding **agents and editors** you have installed and
47
+ the **AI** currently powering Symbiot. Project folders that **aren't git repos**
48
+ show up too (see [Where it looks](#where-it-looks)). **Scroll to zoom, drag to
49
+ pan, click a node.** Clicking a repo shows its branch/commits/uncommitted/stack, a
50
+ **Suggest next steps** button, and an **AI review**: what the project does, a
51
+ one-line verdict on whether it needs new work at all (it's told to prefer
52
+ stabilising over piling on features), and a short list of ideas you can tick
53
+ straight into **Tasks**. The graph itself **needs no AI key** (pure local data);
54
+ reviews and suggestions use your chosen model (free with local Ollama). While
55
+ the scan runs, the Map shows live progress (which phase, how many repos, which
56
+ one). This is the landing view.
57
+ - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
58
+ "fetch latest" and "check CI" toggles.
41
59
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
42
- - **Settings** — pick your AI. Free/private option: run **Ollama** locally, no key.
60
+ - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
61
+ type or repo, then **Send to repos** to hand just those to your agent (see
62
+ [`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
63
+ finishes lands in **Awaiting your review**, where you approve or send it back (see
64
+ [Review and approve](#review-and-approve-the-agents-work)). Ticking a task yourself
65
+ marks it done and it **auto-archives**. An **archived** view lets you restore any
66
+ of them.
67
+ - **Agents** — every agent run Symbiot has started (and local-model downloads),
68
+ with live status, elapsed time, exit code and the tail of its output.
69
+ - **Settings** — pick your AI, the folders to scan, your agent command, and get
70
+ model recommendations or a one-click local model.
71
+
72
+ **Stays current by itself.** The app checks npm for a newer Symbiot every couple of
73
+ minutes (and whenever you come back to the window). When there is one, a banner
74
+ offers **Update & restart**: it runs `npm install -g symbiot@latest`, relaunches the
75
+ app on the same address, and the open window reloads itself onto the new version —
76
+ no terminal, no reopening. The version you're on is shown next to the name. The
77
+ same mechanism means that if you restart `symbiot app` yourself, the open window
78
+ reconnects and reloads on its own.
79
+
80
+ The app listens on port **7391** so its address survives restarts; set
81
+ `SYMBIOT_PORT` to use another (it falls back to a free port if that one is busy).
43
82
 
44
83
  **Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
45
84
  and UI are just a local web page. The chrome-less window is detected per-OS
46
85
  (PATH on Linux, the `/Applications` bundle on macOS, `Program Files` on Windows);
47
86
  where no Chromium-family browser is found it opens your default browser instead.
48
87
 
49
- ## Install
50
-
51
- ```bash
52
- npm install -g symbiot # or run without installing: npx symbiot week
53
- ```
54
-
55
88
  ## Hand tasks to your coding agent: `symbiot push`
56
89
 
57
90
  ```bash
58
91
  symbiot push # or the "Send to repos" button in the app's Tasks tab
92
+ symbiot push --open # ...and run your agent command in each repo
59
93
  ```
60
94
 
61
95
  Writes a **`.symbiot/TASKS.md`** into each repo your tasks reference — a checklist
@@ -63,25 +97,83 @@ Writes a **`.symbiot/TASKS.md`** into each repo your tasks reference — a check
63
97
  open `TODO/FIXME` markers (with `file:line`), and current drift. Then point Claude
64
98
  Code / Cursor / any agent at it: *"Read `.symbiot/TASKS.md` and implement the
65
99
  unchecked items."* Tasks come from ticking a repo review's ideas, or adding your
66
- own in the Tasks tab.
100
+ own in the Tasks tab. Non-git project folders on the Map can receive tasks too.
101
+
102
+ The brief asks the agent to tick each item (`- [x]`) as it finishes it and to leave
103
+ its changes **uncommitted**, ready for your review.
104
+
105
+ ### Review and approve the agent's work
106
+
107
+ A tick from the agent doesn't archive anything. It moves the task into **Awaiting
108
+ your review** at the top of the Tasks tab, grouped by repo, with the branch, the
109
+ size of the uncommitted change and a **Show diff** button. For each repo:
110
+
111
+ - **Approve → PR** syncs the work. If you're on the default branch it creates
112
+ `symbiot/<task>`, commits everything except `.symbiot/` with the approved tasks as
113
+ the message, pushes, and opens a PR with the GitHub CLI (`gh`). If you're already
114
+ on a feature branch it commits and opens the PR from there. Each step that can't
115
+ happen stops there and says why: no `origin` remote means a local commit only, and
116
+ without `gh` it pushes and stops. Your commit is never lost. Then the tasks are
117
+ archived with their commit and PR link.
118
+ - **↩ (send back)** is for one that isn't right: it reopens the task and unticks it in
119
+ `TASKS.md`, so the next **Send to repos** hands it to the agent again.
120
+
121
+ Tasks waiting for review aren't re-sent to the agent.
122
+
123
+ ### Run your agent automatically
124
+
125
+ Set an **agent command** in Settings (*Hand off to your agent*), and every
126
+ **Send to repos** (or `symbiot push --open`) runs it in each repo it wrote to. It's
127
+ a template — `{dir}` is the repo path, `{prompt}` is the instruction to read
128
+ `TASKS.md` — so it works with any agent or editor:
129
+
130
+ ```
131
+ claude -p "{prompt}" --permission-mode acceptEdits
132
+ aider --message "{prompt}" --yes
133
+ code {dir}
134
+ ```
135
+
136
+ Settings shows **one-click presets** for what's installed on your machine:
137
+
138
+ - **Agents:** Claude Code (*make changes*, or *plan only* which asks first), Codex
139
+ (OpenAI), Aider, Cursor agent, Gemini CLI.
140
+ - **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
141
+ Orca's own agent, or with Claude running in a new tab. It launches Orca if it's
142
+ closed and waits for it to be ready first.
143
+ - **Editors:** VS Code, Cursor, Windsurf, Zed, Sublime Text, IntelliJ IDEA, Neovim
144
+ (on macOS, also found as `.app` bundles when the CLI isn't on your PATH).
145
+
146
+ Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
147
+ **Agents** tab. The command is saved as `agentCmd` in
148
+ `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
149
+ `{dir}` and `{prompt}`.
67
150
 
68
151
  ## What's out of sync? `symbiot drift`
69
152
 
70
153
  ```bash
71
- symbiot drift # add --ci to also check GitHub Actions state (needs gh)
154
+ symbiot drift # local git facts only — fast, no network
155
+ symbiot drift --fetch # fetch from origin first, so "behind" is current
156
+ symbiot drift --ci # also check GitHub Actions state (needs gh)
72
157
  ```
73
158
 
74
159
  A **deterministic** report of what's out of sync, stuck, or at risk across your
75
160
  repos — computed from git facts, each line citing the fact behind it (no model
76
- guessing). It flags: **stale checkouts**, **behind upstream**, **multiple
77
- worktrees**, **branches with work not on the default**, **PR merges that landed
78
- off the default branch** (the "merged but main didn't move" trap), and — if you
79
- configure it — **production running code that isn't on your default branch**.
161
+ guessing). It flags: **stale checkouts** (a working tree that's an old snapshot,
162
+ not new work), **uncommitted work**, **behind upstream**, **multiple worktrees**,
163
+ **branches with work not on the default**, **PR merges that landed off the default
164
+ branch** (the "merged but main didn't move" trap — split into *likely never
165
+ landed*, when files the PR added are missing from main, and *probably re-done*,
166
+ when they're all there), and — if you configure it — **production running code
167
+ that isn't on your default branch**. With `--ci` it tells a real failing run apart
168
+ from **CI that isn't running at all** (jobs never started — usually a billing or
169
+ spending limit), quoting GitHub's reason when it can. Works on local-only repos
170
+ too: without a remote it compares against your local default branch.
80
171
 
81
172
  For the production check, add a per-repo deploy command to
82
173
  `~/.config/symbiot/deploys.json` (read only from your own config, never from a
83
- repo), e.g. `{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also a
84
- **Drift** tab in the app.
174
+ repo), keyed by repo path or folder name, that prints the deployed commit sha:
175
+ `{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also the **Drift** tab
176
+ in the app.
85
177
 
86
178
  ## Which model? Ask your machine
87
179
 
@@ -94,6 +186,20 @@ marking which fit your RAM) to run free & private via [Ollama](https://ollama.co
94
186
  plus **paid** options (Claude / OpenAI / Gemini, cheap → top). Also available as
95
187
  a button in the app's Settings.
96
188
 
189
+ ### A free local model in one command: `symbiot setup-local`
190
+
191
+ ```bash
192
+ symbiot setup-local # the best model for your RAM
193
+ symbiot setup-local --model llama3.2:3b # or pick one
194
+ ```
195
+
196
+ Starts Ollama if it isn't running, downloads the model (with progress), and
197
+ switches Symbiot over to it — free, private, nothing leaves your machine. If
198
+ Ollama isn't installed yet it prints the one-line install for your OS (Homebrew on
199
+ macOS, winget on Windows, the official script on Linux) and you re-run it after.
200
+ Also the **Set up a free local model** button in Settings, where the download shows
201
+ in the Agents tab and Symbiot switches over when it finishes.
202
+
97
203
  ## Connect it (once)
98
204
 
99
205
  Pick the AI you want it to write with:
@@ -103,7 +209,8 @@ symbiot login # choose Claude, OpenAI, Gemini, or a local model (Ollama)
103
209
  ```
104
210
 
105
211
  You'll get a short menu; paste that provider's API key (or, for Ollama, just point
106
- it at your local server) and it's saved to `~/.config/symbiot/config.json`.
212
+ it at your local server) and it's saved to `~/.config/symbiot/config.json`. The
213
+ app's Settings tab does the same.
107
214
 
108
215
  | Provider | Get a key | Default model |
109
216
  |----------|-----------|---------------|
@@ -119,18 +226,43 @@ Environment keys are auto-detected too (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
119
226
  ## Use
120
227
 
121
228
  ```bash
122
- cd ~/your/projects # or use --dir
123
229
  symbiot week # writes up what you did, grouped and readable
124
230
  symbiot week --since 14 # a fortnight instead of a week
125
231
  symbiot standup # short: done + next
126
232
  symbiot todo # TODOs + uncommitted work, prioritised
127
233
  symbiot week --all # everyone's commits, not just yours
128
234
  symbiot week --plain # no colour/spinner, good for piping
235
+ symbiot week --dir ~/work # just this folder, this once
129
236
  ```
130
237
 
131
- By default it looks under your home folder for repos with recent commits, and
132
- summarises the commits you authored (the email you commit under in each repo).
133
- Point it somewhere specific with `--dir ~/work`.
238
+ It summarises the commits **you** authored — matching all your identities in each
239
+ repo (per-repo and global email, your GitHub noreply address, your name), and
240
+ counting everyone if that filter would drop almost all of an active repo's history.
241
+
242
+ ## Where it looks
243
+
244
+ By default Symbiot scans your **home folder**. To point it at where your work
245
+ actually lives — inside or outside your home folder, one place or several — add
246
+ folders under **Settings → Folders to scan for repos** in the app. Adding the first
247
+ extra folder keeps your home folder in the list; remove whichever you don't want.
248
+ They're saved as `scanRoots` in `~/.config/symbiot/config.json` (`~` works).
249
+ `--dir <path>` overrides them for a single run.
250
+
251
+ Every view uses the **same set of repos** — the Map, Week / Standup / Todo, Drift,
252
+ and Send to repos all agree. Heavy folders (`node_modules`, virtualenvs, caches, …)
253
+ are skipped, and worktrees of one repo count once (the freshest checkout).
254
+
255
+ **Not everything is a repo.** Inside your scan folders, Symbiot also picks up
256
+ **project folders without git** — any folder (up to three levels down) with a
257
+ project manifest such as `package.json`, `pyproject.toml`, `requirements.txt`,
258
+ `go.mod`, `Cargo.toml`, `pom.xml`, `build.gradle`, `Gemfile`, `composer.json`,
259
+ `Dockerfile`, `pubspec.yaml` or `CMakeLists.txt`. They appear on the Map (sand
260
+ coloured) with their languages, and can receive tasks like a repo.
261
+
262
+ **Scans can't hang.** A scan has an overall time limit (60 s by default; set
263
+ `SYMBIOT_SCAN_TIMEOUT` in seconds). If it runs out — a slow network drive, a
264
+ gigantic folder — it stops and shows what it found so far, marked **partial**,
265
+ instead of hanging. The CLI shows scan progress on one line as it goes.
134
266
 
135
267
  ## What it reads, and what it doesn't
136
268
 
@@ -144,14 +276,41 @@ Point it somewhere specific with `--dir ~/work`.
144
276
  commits matched you (e.g. "1090 of 1101"), the README's age, and the
145
277
  working-tree state — so you can see exactly what it read.
146
278
  - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
279
+ - **Runs only what you set:** the agent command is yours, and deploy commands are
280
+ read only from your own `~/.config/symbiot/`, never from a repo.
147
281
 
148
282
  ## Config
149
283
 
284
+ Everything lives in `~/.config/symbiot/`: `config.json` (your AI, `scanRoots`,
285
+ `agentCmd`; readable only by you), `tasks.json` (your tasks),
286
+ `deploys.json` (optional, for drift's production check) and `rules.md` (optional
287
+ conventions every repo review must respect, alongside each repo's own
288
+ `CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md`).
289
+
150
290
  - `SYMBIOT_MODEL` — override the model for any provider (e.g. `gpt-4o`,
151
291
  `claude-haiku-4-5`, `gemini-1.5-pro`).
292
+ - `SYMBIOT_SCAN_TIMEOUT` — the scan time limit, in seconds (default 60).
293
+ - `SYMBIOT_PORT` — the app's port (default 7391).
152
294
  - `symbiot whoami` shows the active provider, model, and where the credential
153
295
  came from. `symbiot logout` forgets saved credentials.
154
296
 
297
+ ## Development
298
+
299
+ ```bash
300
+ npm test
301
+ ```
302
+
303
+ Runs three suites, all against throwaway repos and an isolated `HOME`:
304
+
305
+ - `test/fixtures.mjs` — accuracy fixtures: the facts Symbiot collects (identity
306
+ matching, stale checkouts, worktrees, drift) and the `symbiot drift` report as
307
+ printed, plus the scan time limit.
308
+ - `test/smoke.mjs` — boots `symbiot app`, runs the page's own JavaScript against
309
+ a fake DOM (every tab, every button), and hits every endpoint the UI calls.
310
+ - `test/install.mjs` — runs the CLI through a bin symlink, then `npm pack` +
311
+ global install into a temp prefix and runs `symbiot help`. Needs npm registry
312
+ access for dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
313
+
155
314
  ---
156
315
 
157
316
  Part of Symbiot — an assistant that learns how you work and gives it back to you.
package/index.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  // --plain no colour, no spinner (for piping)
19
19
 
20
20
  import Anthropic from "@anthropic-ai/sdk";
21
- import { execSync, spawn } from "node:child_process";
21
+ import { execSync, spawn, spawnSync } from "node:child_process";
22
22
  import { homedir, totalmem, cpus as oscpus } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync, openSync, writeSync } from "node:fs";
@@ -58,6 +58,9 @@ let SERVING = false; // set while `symbiot app` runs — silences the CLI spinne
58
58
  let LAST_MAP = null; // cached graph so node clicks don't rescan
59
59
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
60
60
  let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
61
+ // a.b.c numeric compare: only a HIGHER npm version is an update (a local build
62
+ // ahead of npm must not be offered a "newer" older one)
63
+ function semverGt(a, b) { const p = (v) => String(v || "").replace(/^v/, "").split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0); const x = p(a), y = p(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }
61
64
  async function checkLatest() { try { const r = await fetch("https://registry.npmjs.org/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
62
65
 
63
66
  // ---- colour + spinner ------------------------------------------------------
@@ -118,18 +121,101 @@ function repoPathMap() {
118
121
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
119
122
  return byName;
120
123
  }
121
- // "Check what was handed out, see what's completed, then archive it": mark done
122
- // any task the agent ticked in TASKS.md, then auto-archive every done task.
124
+ // "Check what was handed out, see what's completed, then archive it" — with an
125
+ // approval step in between. An agent ticking an item in TASKS.md means "done,
126
+ // please review", NOT archived: it waits in review until the user approves it
127
+ // (approveRepo: branch + commit + push + PR, then archive) or sends it back
128
+ // (sendBack: unticked, open again). Only a task the USER ticks archives directly.
123
129
  function syncTasks() {
124
- const t = loadTasks(); const map = repoPathMap(); let completed = 0, archived = 0; const checkedByRepo = {};
130
+ const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0; const checkedByRepo = {};
125
131
  for (const x of t) {
126
- if (x.archived || x.done || !x.repo) continue;
132
+ if (x.archived || x.done || x.review || !x.repo) continue;
127
133
  if (!(x.repo in checkedByRepo)) checkedByRepo[x.repo] = map[x.repo] ? completedInRepo(map[x.repo]) : [];
128
- if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.done = true; completed++; }
134
+ if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
129
135
  }
130
136
  for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
131
137
  saveTasks(t);
132
- return { completed, archived };
138
+ return { review, archived };
139
+ }
140
+ // git with an argv (task text goes into commit messages — never through a shell)
141
+ function git(repo, args, timeout = 30000) {
142
+ const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
143
+ return { ok: r.status === 0, out: String(r.stdout || "").replace(/\s+$/, ""), err: String(r.stderr || (r.error && r.error.message) || "").trim() };
144
+ }
145
+ const NOT_SYMBIOT = ["--", ".", ":(exclude).symbiot"]; // .symbiot/ is Symbiot's scratch (brief + agent log), never shipped
146
+ function workingChanges(path) {
147
+ const st = git(path, ["status", "--porcelain", "-uall", ...NOT_SYMBIOT]);
148
+ const files = st.out ? st.out.split("\n").map((l) => ({ st: l.slice(0, 2).trim(), file: l.slice(3) })) : [];
149
+ const short = git(path, ["diff", "--shortstat", "HEAD", ...NOT_SYMBIOT]).out, n = (re) => +((short.match(re) || [])[1] || 0);
150
+ const ins = n(/(\d+) insertion/), del = n(/(\d+) deletion/), fresh = files.filter((f) => f.st === "??").length;
151
+ const stat = [ins && `+${ins}`, del && `−${del}`, fresh && `${fresh} new`].filter(Boolean).join(" ");
152
+ return { branch: git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).out, files, stat };
153
+ }
154
+ // The full diff the agent left behind: tracked changes + new files, capped.
155
+ function workingDiff(path, cap = 400000) {
156
+ let d = git(path, ["diff", "HEAD", ...NOT_SYMBIOT]).out;
157
+ for (const f of workingChanges(path).files.filter((x) => x.st === "??")) {
158
+ if (d.length > cap) break;
159
+ d += (d ? "\n" : "") + git(path, ["diff", "--no-index", "--", "/dev/null", f.file]).out; // exits 1 on a difference — output is what we want
160
+ }
161
+ return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
162
+ }
163
+ function pendingReview() {
164
+ const by = {}; for (const x of loadTasks()) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
165
+ const map = Object.keys(by).length ? repoPathMap() : {};
166
+ return Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
167
+ }
168
+ const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
169
+ // Sync approved work: off the default branch onto symbiot/<task>, commit the
170
+ // working tree (minus .symbiot/), push, and open a PR with gh. Each step that
171
+ // can't happen (no remote, push rejected, no gh) stops there and says so — the
172
+ // commit is never lost. opts.push=false stops after the commit, opts.pr=false
173
+ // after the push.
174
+ function shipChanges(path, texts, opts = {}) {
175
+ const ch = workingChanges(path);
176
+ if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
177
+ if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
178
+ const base = gitDefaultBranch(path); let branch = ch.branch;
179
+ if (branch === base) {
180
+ const stem = "symbiot/" + branchSlug(texts.length === 1 ? texts[0] : `${texts.length}-tasks-${new Date().toISOString().slice(0, 10)}`);
181
+ branch = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + branch]).ok; i++) branch = `${stem}-${i}`;
182
+ const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
183
+ }
184
+ const add = git(path, ["add", "-A", ...NOT_SYMBIOT]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
185
+ const subject = texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
186
+ const body = texts.map((x) => "- " + x).join("\n");
187
+ const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
188
+ const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
189
+ if (opts.push === false) return out;
190
+ if (!git(path, ["remote", "get-url", "origin"]).ok) return { ...out, note: "No origin remote — committed locally." };
191
+ const ps = git(path, ["push", "-u", "origin", branch], 120000);
192
+ if (!ps.ok) return { ...out, note: "Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error") };
193
+ out.pushed = true;
194
+ if (opts.pr === false) return out;
195
+ if (!hasCmd("gh")) return { ...out, note: "Pushed. Install the GitHub CLI (gh) to open the PR automatically." };
196
+ const gh = (args) => spawnSync("gh", args, { cwd: path, encoding: "utf8", timeout: 60000 });
197
+ const pr = gh(["pr", "create", "--head", branch, "--base", base, "--title", subject, "--body", body + "\n\nApproved in Symbiot."]);
198
+ const url = (String(pr.stdout || "").match(/https?:\/\/\S+/) || [])[0] || String(gh(["pr", "view", branch, "--json", "url", "-q", ".url"]).stdout || "").trim();
199
+ return url ? { ...out, pr: url } : { ...out, note: "Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop() };
200
+ }
201
+ function approveRepo(repo, opts = {}) {
202
+ const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
203
+ if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
204
+ const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
205
+ const r = shipChanges(path, items.map((x) => x.text), opts);
206
+ if (r.error) return r;
207
+ const now = Date.now();
208
+ for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
209
+ saveTasks(t);
210
+ return { ...r, approved: items.length };
211
+ }
212
+ // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
213
+ function sendBack(id) {
214
+ const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
215
+ it.review = false; delete it.reviewAt; saveTasks(t);
216
+ const path = it.repo && repoPathMap()[it.repo];
217
+ if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { const want = it.text.trim().toLowerCase(); writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase() === want ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
218
+ return it;
133
219
  }
134
220
  // Deterministic task classification (no model) so tasks batch by kind instead
135
221
  // of arriving as a flat, mixed pile. Order matters (most specific first).
@@ -160,11 +246,12 @@ function buildTasksMd(name, ctx, list) {
160
246
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
161
247
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
162
248
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
163
- L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Confirm with me before anything destructive."');
249
+ L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
250
+ L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
164
251
  return L.join("\n") + "\n";
165
252
  }
166
253
  function pushTasks(filter) {
167
- let tasks = loadTasks().filter((t) => !t.done && !t.archived);
254
+ let tasks = loadTasks().filter((t) => !t.done && !t.archived && !t.review);
168
255
  if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
169
256
  if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
170
257
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
@@ -258,14 +345,53 @@ function sh(cmd) {
258
345
  try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
259
346
  catch { return ""; } // timeout or error -> empty, never hang the scan
260
347
  }
348
+ // ---- scan deadline + progress ---------------------------------------------
349
+ // One slow disk or giant tree must never hang a scan (the 0.7.1 map hang): each
350
+ // scan gets an overall deadline (SYMBIOT_SCAN_TIMEOUT seconds), after which it
351
+ // stops and returns what it has, marked partial. Progress is a live stderr line
352
+ // in the CLI, and served at /api/scan for the app, which polls it while the Map
353
+ // loads. Nested scans (findAllRepos inside buildMap) share the outer deadline.
354
+ const SCAN_TIMEOUT_MS = (Number(process.env.SYMBIOT_SCAN_TIMEOUT) || 60) * 1000;
355
+ const FIND_TIMEOUT_MS = 20000; // one `find` walk; partial results are kept past it
356
+ const SCAN = { active: false, phase: "", done: 0, total: 0, item: "", startedAt: 0, deadline: 0, partial: false };
357
+ function scanBegin() {
358
+ if (SCAN.active) return false;
359
+ Object.assign(SCAN, { active: true, phase: "", done: 0, total: 0, item: "", startedAt: Date.now(), deadline: Date.now() + SCAN_TIMEOUT_MS, partial: false });
360
+ return true;
361
+ }
362
+ function scanPhase(phase, total) { Object.assign(SCAN, { phase, done: 0, total: total || 0, item: "" }); scanDraw(); }
363
+ function scanTick(item) { SCAN.done++; SCAN.item = item || ""; scanDraw(); }
364
+ function scanExpired() { if (SCAN.active && Date.now() > SCAN.deadline) SCAN.partial = true; return SCAN.partial; }
365
+ function scanEnd(owner) {
366
+ if (!owner) return;
367
+ SCAN.active = false;
368
+ if (SERVING) return;
369
+ if (!PLAIN && process.stderr.isTTY) process.stderr.write("\r\x1b[K");
370
+ if (SCAN.partial) process.stderr.write(c.y(`⚠ scan stopped after ${SCAN_TIMEOUT_MS / 1000}s — results are partial (raise SYMBIOT_SCAN_TIMEOUT, or narrow the scan folders)`) + "\n");
371
+ }
372
+ // Written synchronously (the scan blocks the event loop, so a timer-driven
373
+ // spinner would freeze): one overwritten stderr line, TTY only.
374
+ function scanDraw() {
375
+ if (SERVING || PLAIN || !process.stderr.isTTY) return;
376
+ const n = SCAN.total ? ` ${SCAN.done}/${SCAN.total}` : SCAN.done ? ` ${SCAN.done}` : "";
377
+ process.stderr.write(`\r\x1b[K${c.g("⠿")} ${c.d(`${SCAN.phase}${n}${SCAN.item ? " · " + SCAN.item : ""}`.slice(0, (process.stderr.columns || 80) - 3))}`);
378
+ }
379
+ // Run `find` directly (no shell, no pipe): a timeout then kills find itself
380
+ // rather than a shell whose piped children keep running, and whatever it found
381
+ // before the timeout is kept instead of discarded.
382
+ function findPaths(args, limit) {
383
+ const timeout = SCAN.active ? Math.max(1000, Math.min(FIND_TIMEOUT_MS, SCAN.deadline - Date.now())) : FIND_TIMEOUT_MS;
384
+ const r = spawnSync("find", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout, killSignal: "SIGKILL" });
385
+ if (r.error && r.error.code !== "ETIMEDOUT") return [];
386
+ if (r.error && SCAN.active) SCAN.partial = true;
387
+ return String(r.stdout || "").split("\n").filter(Boolean).slice(0, limit);
388
+ }
389
+ const PRUNE = ["node_modules", ".cache", ".local", ".npm", "venv", ".venv", ".gradle", "Pods"];
390
+ const anyName = (names) => ["(", ...names.flatMap((n, i) => (i ? ["-o", "-name", n] : ["-name", n])), ")"];
261
391
  // find .git dirs quickly by PRUNING heavy trees (node_modules etc.) instead of
262
392
  // crawling into them — this is the big speedup for the map scan.
263
393
  function findGitDirs(base, limit) {
264
- return sh(
265
- `find ${JSON.stringify(base)} -maxdepth 7 ` +
266
- `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git-crypt \\) -prune ` +
267
- `-o -name .git -print 2>/dev/null | head -${limit}`,
268
- ).split("\n").filter(Boolean);
394
+ return findPaths([base, "-maxdepth", "7", ...anyName([...PRUNE, ".git-crypt"]), "-prune", "-o", "-name", ".git", "-print"], limit);
269
395
  }
270
396
  function me() {
271
397
  return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
@@ -601,10 +727,10 @@ const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor"
601
727
  const AGENT_LIST = [
602
728
  ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
603
729
  ["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
604
- ["codex", "Codex (OpenAI) — make changes", 'codex exec --full-auto "{prompt}"'],
730
+ ["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
605
731
  ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
732
+ ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
606
733
  ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
607
- ["gemini", "Gemini CLI", 'gemini -p "{prompt}"'],
608
734
  ];
609
735
  // Cross-platform "is this command available?" (command -v on posix, where on win).
610
736
  function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
@@ -647,21 +773,27 @@ function detectHandoffs() {
647
773
  const q = JSON.stringify(orca);
648
774
  agents.unshift(
649
775
  { label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), kind: "agent" },
650
- { label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ` --command "claude {prompt}"`), kind: "agent" },
776
+ { label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ORCA_CLAUDE_CMD), kind: "agent" },
651
777
  );
652
778
  }
653
779
  return { agents, editors };
654
780
  }
655
781
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
656
782
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
657
- const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
783
+ // Plain words only: in the Orca preset this passes through two shells, so no
784
+ // backticks or $ (escDq covers one level).
785
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. Confirm before anything destructive.";
658
786
  function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
659
787
  // Build the full Orca handoff, cold-start safe. `bin` is the quoted orca-ide
660
- // path; `commandPart` is e.g. ` --command "claude {prompt}"` or "" (open only).
788
+ // path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
661
789
  // open launches Orca & waits for the runtime to be REACHABLE, but on a cold
662
790
  // start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
663
791
  // `terminal create` times out — so we poll `status` until state=ready, then add
664
792
  // the repo and create the terminal (retry: the worktree can lag a beat behind).
793
+ // {prompt} needs its own (escaped) quotes: the outer shell strips the --command
794
+ // quotes and Orca re-runs the string in the tab, so an unquoted prompt reached
795
+ // the agent as just its first word ("Read").
796
+ const ORCA_CLAUDE_CMD = ` --command "claude \\"{prompt}\\""`;
665
797
  function orcaHandoffCmd(bin, commandPart) {
666
798
  const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
667
799
  const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
@@ -673,11 +805,13 @@ function orcaHandoffCmd(bin, commandPart) {
673
805
  function migrateOrcaCmd(cmd) {
674
806
  if (!cmd || typeof cmd !== "string") return cmd;
675
807
  if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
676
- if (/status --json/.test(cmd) && /grep -q/.test(cmd)) return cmd; // already the wait-for-ready form
808
+ const cm = cmd.match(/--command\s+"((?:[^"\\]|\\.)*)"/); // preserve a custom agent command
809
+ // ≤0.26 saved a bare {prompt} here (truncated to one word) — quote it
810
+ const inner = cm ? cm[1].replace(/(^|\s)\{prompt\}(?=\s|$)/g, '$1\\"{prompt}\\"') : "";
811
+ if (/status --json/.test(cmd) && /grep -q/.test(cmd) && (!cm || inner === cm[1])) return cmd; // already current
677
812
  const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
678
813
  const bin = bm ? bm[1] : "";
679
- const cm = cmd.match(/--command\s+"([^"]*)"/); // preserve a custom agent command
680
- const commandPart = cm ? ` --command "${cm[1]}"` : "";
814
+ const commandPart = cm ? ` --command "${inner}"` : "";
681
815
  const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
682
816
  if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
683
817
  return rebuilt;
@@ -700,10 +834,26 @@ function track(name, cmd, cwd, onExit) {
700
834
  return entry;
701
835
  } catch { return null; }
702
836
  }
837
+ // Agent-agnostic "what did it do": read it straight from git, so it works the
838
+ // same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
839
+ // current working-tree changes + any commits the agent made since it started.
840
+ function agentChanges(path, startedAt) {
841
+ try {
842
+ const q = JSON.stringify(path);
843
+ const porcelain = sh(`git -C ${q} status --porcelain`).trim();
844
+ // Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
845
+ const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
846
+ const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
847
+ const since = new Date(startedAt || Date.now()).toISOString();
848
+ const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
849
+ const commits = raw ? raw.split("\n").slice(0, 8).map((l) => { const i = l.indexOf("\u0001"); return { hash: l.slice(0, i), msg: l.slice(i + 1) }; }) : [];
850
+ return { dirty, stat, commits };
851
+ } catch { return { dirty: 0, stat: "", commits: [] }; }
852
+ }
853
+ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
703
854
  function runHandoff(repoPath) {
704
855
  const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
705
- const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
706
- return track(repoPath.split("/").pop(), cmd, repoPath);
856
+ return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
707
857
  }
708
858
  // ---- local model one-command setup (Ollama), OS-aware ---------------------
709
859
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -871,9 +1021,17 @@ function driftRepo(p, opts = {}) {
871
1021
  return { name, path: p, def, flags, fetchAgeDays: fa };
872
1022
  }
873
1023
  function computeDrift(opts = {}) {
874
- const deploys = loadDeploys();
875
- const repos = findAllRepos().slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
876
- return { repos, ci: !!opts.ci };
1024
+ const own = scanBegin();
1025
+ try {
1026
+ const deploys = loadDeploys();
1027
+ const found = findAllRepos().slice(0, 20), repos = [];
1028
+ scanPhase("checking drift", found.length);
1029
+ for (const r of found) {
1030
+ if (scanExpired()) break;
1031
+ repos.push(driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch })); scanTick(r.name);
1032
+ }
1033
+ return { repos, ci: !!opts.ci, partial: SCAN.partial };
1034
+ } finally { scanEnd(own); }
877
1035
  }
878
1036
  function cmdDrift() {
879
1037
  const d = computeDrift({ ci: has("ci"), fetch: has("fetch") });
@@ -905,21 +1063,33 @@ const MANIFEST_TOOL = {
905
1063
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
906
1064
  };
907
1065
  function findAllRepos(base) {
908
- const roots = base ? [base] : scanRoots();
909
- const repos = []; const seen = new Set();
910
- for (const root of roots) for (const g of findGitDirs(root, 300)) {
911
- const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
912
- const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
913
- if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
914
- }
915
- // Group worktrees by shared git dir; keep only the freshest checkout of each.
916
- const byCommon = {};
917
- for (const r of repos) {
918
- const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
919
- const key = cd.startsWith("/") ? cd : join(r.path, cd);
920
- if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
921
- }
922
- return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
1066
+ const own = scanBegin();
1067
+ try {
1068
+ const roots = base ? [base] : scanRoots();
1069
+ const repos = []; const seen = new Set();
1070
+ for (const root of roots) {
1071
+ if (scanExpired()) break;
1072
+ scanPhase("finding repos in " + root.replace(homedir(), "~"));
1073
+ const gits = findGitDirs(root, 300);
1074
+ scanPhase("reading repos", gits.length);
1075
+ for (const g of gits) {
1076
+ if (scanExpired()) break;
1077
+ const repo = g.replace(/\/\.git$/, ""); scanTick(repo.split("/").pop());
1078
+ if (seen.has(repo)) continue; seen.add(repo);
1079
+ const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
1080
+ if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
1081
+ }
1082
+ }
1083
+ // Group worktrees by shared git dir; keep only the freshest checkout of each.
1084
+ // (Not deadline-bound: it's what makes the result correct, and it's cheap.)
1085
+ const byCommon = {};
1086
+ for (const r of repos) {
1087
+ const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
1088
+ const key = cd.startsWith("/") ? cd : join(r.path, cd);
1089
+ if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
1090
+ }
1091
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
1092
+ } finally { scanEnd(own); }
923
1093
  }
924
1094
  function detectRepo(r) {
925
1095
  const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
@@ -940,11 +1110,8 @@ function detectRepo(r) {
940
1110
  // Non-git PROJECT folders inside your scan roots (you added them = consent):
941
1111
  // a directory with a manifest but no .git — "not everything is a repo".
942
1112
  function findProjectFolders(root) {
943
- const manifests = sh(
944
- `find ${JSON.stringify(root)} -maxdepth 3 ` +
945
- `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git \\) -prune ` +
946
- `-o -type f \\( -name package.json -o -name requirements.txt -o -name pyproject.toml -o -name go.mod -o -name Cargo.toml -o -name pom.xml -o -name build.gradle -o -name Gemfile -o -name composer.json -o -name Dockerfile -o -name pubspec.yaml -o -name CMakeLists.txt \\) -print 2>/dev/null | head -200`,
947
- ).split("\n").filter(Boolean);
1113
+ const manifests = findPaths([root, "-maxdepth", "3", ...anyName([...PRUNE, ".git"]), "-prune", "-o", "-type", "f",
1114
+ ...anyName(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "Gemfile", "composer.json", "Dockerfile", "pubspec.yaml", "CMakeLists.txt"]), "-print"], 200);
948
1115
  const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
949
1116
  const out = [];
950
1117
  for (const d of dirs) {
@@ -959,9 +1126,29 @@ function detectFolder(path) {
959
1126
  for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
960
1127
  return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
961
1128
  }
1129
+ // Async only to YIELD between repos, so the app server can answer /api/scan
1130
+ // (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
1131
+ // callers (e.g. Rescan while a scan runs) share the one in-flight build.
1132
+ let MAP_BUILD = null;
962
1133
  function buildMap() {
1134
+ if (!MAP_BUILD) MAP_BUILD = buildMapNow().finally(() => { MAP_BUILD = null; });
1135
+ return MAP_BUILD;
1136
+ }
1137
+ const yieldTick = () => new Promise((r) => setImmediate(r));
1138
+ async function buildMapNow() {
1139
+ const own = scanBegin();
1140
+ try { return await buildMapScan(); } finally { scanEnd(own); }
1141
+ }
1142
+ async function buildMapScan() {
963
1143
  const who = me();
964
- const repos = findAllRepos().map(detectRepo);
1144
+ const found = findAllRepos();
1145
+ const repos = [];
1146
+ scanPhase("reading repo details", found.length);
1147
+ for (const r of found) {
1148
+ if (scanExpired()) break;
1149
+ await yieldTick();
1150
+ repos.push(detectRepo(r)); scanTick(r.name);
1151
+ }
965
1152
  const nodes = []; const edges = []; const have = new Set();
966
1153
  const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
967
1154
  add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
@@ -976,9 +1163,11 @@ function buildMap() {
976
1163
  // Non-git project folders in the scan roots (capped; deduped against repos).
977
1164
  try {
978
1165
  const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
979
- for (const root of scanRoots()) { for (const d of findProjectFolders(root)) {
1166
+ for (const root of scanRoots()) { if (scanExpired()) break; scanPhase("finding project folders"); for (const d of findProjectFolders(root)) {
980
1167
  if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
981
- const det = detectFolder(d); folders++;
1168
+ if (scanExpired()) break;
1169
+ await yieldTick();
1170
+ const det = detectFolder(d); folders++; scanTick(det.name);
982
1171
  add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files } });
983
1172
  edges.push({ source: "me", target: "folder:" + d });
984
1173
  for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
@@ -1006,13 +1195,14 @@ function buildMap() {
1006
1195
  commits: repos.reduce((s, r) => s + r.mine, 0),
1007
1196
  files: repos.reduce((s, r) => s + (r.files || 0), 0),
1008
1197
  base: BASE,
1198
+ partial: SCAN.partial, // the scan hit its deadline — this is what it found so far
1009
1199
  } };
1010
1200
  LAST_MAP = out;
1011
1201
  return out;
1012
1202
  }
1013
1203
  // Local detail for a clicked node (no AI).
1014
- function nodeDetail(id) {
1015
- const map = LAST_MAP || buildMap();
1204
+ async function nodeDetail(id) {
1205
+ const map = LAST_MAP || await buildMap();
1016
1206
  if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
1017
1207
  if (id.startsWith("repo:")) {
1018
1208
  const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
@@ -1344,6 +1534,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1344
1534
  .fchip{font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--ink3);color:var(--text);cursor:pointer;font:inherit}
1345
1535
  .fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
1346
1536
  .task .rm.restore{color:var(--green);font-size:15px}
1537
+ .rcard{border:1px solid var(--amber);border-radius:11px;padding:10px 12px;margin-top:8px}
1538
+ .rcard .rhead{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}.rcard .rhead .muted{font-size:12px}
1539
+ .rcard .row{margin-top:8px}.rcard .task{margin-top:6px}
1540
+ .rdiff{max-height:360px;overflow:auto;font-size:11px;line-height:1.45;background:var(--ink2);border:1px solid var(--line);border-radius:8px;padding:8px;margin-top:8px;white-space:pre}
1347
1541
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1348
1542
  .drift .dh{display:flex;gap:8px;align-items:center}
1349
1543
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1358,6 +1552,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1358
1552
  .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
1359
1553
  .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
1360
1554
  .adot.ok{background:var(--green)} .adot.fail{background:#E5695B}
1555
+ .changed{margin-top:8px;font-size:12.5px;color:var(--bone);background:var(--green-dim);border:1px solid #2a6b52;border-radius:8px;padding:7px 10px}
1556
+ .changed.muted{color:var(--faint);background:var(--ink3);border-color:var(--line)}
1557
+ .changed b{color:var(--green)}
1558
+ .commits{margin-top:6px;font-size:12px;color:var(--text);font-family:ui-monospace,Menlo,Consolas,monospace}
1559
+ .commits code{color:var(--amber);margin-right:6px}
1361
1560
  @keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(242,165,65,.5)}50%{opacity:.4;box-shadow:0 0 0 5px rgba(242,165,65,0)}}
1362
1561
  .alogout{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;color:var(--text);max-height:260px;overflow:auto}
1363
1562
  .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
@@ -1411,8 +1610,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1411
1610
  </section>
1412
1611
  <section id="panel-tasks" class="hidden">
1413
1612
  <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button><button class="ghost" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></div>
1414
- <div class="note muted" style="margin-top:2px">The checkbox marks a task <b>done</b> (it auto-archives). To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which.</div>
1613
+ <div class="note muted" style="margin-top:2px">To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>&#8630;</b> sends it back. Ticking a task yourself marks it done (it auto-archives).</div>
1415
1614
  <div id="pushout"></div>
1615
+ <div id="reviewout"></div>
1616
+ <div id="reviewlist"></div>
1416
1617
  <div id="taskfilter" class="taskfilter"></div>
1417
1618
  <div id="tasklist"></div>
1418
1619
  </section>
@@ -1569,13 +1770,34 @@ box.innerHTML=fb;
1569
1770
  box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
1570
1771
  document.getElementById('archtoggle').addEventListener('click',loadArchived);}
1571
1772
  function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
1572
- var list=ALLTASKS.filter(function(t){return (!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
1773
+ var list=ALLTASKS.filter(function(t){return !t.review&&(!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
1573
1774
  if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>"+(ALLTASKS.length?"Nothing matches this filter.":"No tasks yet. Tick ideas in a repo review, or add one above.")+"</div>";return;}
1574
1775
  var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
1575
1776
  var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
1576
1777
  var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t,false);});});
1577
1778
  el.innerHTML=h;wireTaskRows(el);}
1578
- function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});});}
1779
+ function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});loadPending();});}
1780
+ function loadPending(){api('/api/pending').then(function(list){var el=document.getElementById('reviewlist');if(!list||!list.length){el.innerHTML='';return;}
1781
+ var n=0;list.forEach(function(r){n+=r.tasks.length;});
1782
+ var h="<div class='tgroup' style='color:var(--amber)'>Awaiting your review <span class='tcount'>"+n+"</span></div>";
1783
+ list.forEach(function(r){var ch=r.files.length?(r.files.length+" file"+(r.files.length>1?"s":"")+" changed"+(r.stat?" &middot; "+esc(r.stat):"")):"no uncommitted changes";
1784
+ h+="<div class='rcard' data-repo='"+esc(r.repo)+"'><div class='rhead'><b>"+esc(r.repo)+"</b><span class='muted'>"+(r.path?"on "+esc(r.branch||'?')+" &middot; "+ch:"repo not found on disk")+"</span></div>";
1785
+ r.tasks.forEach(function(t){h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span><button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
1786
+ h+="<div class='row'><button class='act approve'"+(r.path?"":" disabled")+" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive")+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"</div><div class='rdiff hidden'></div></div>";});
1787
+ el.innerHTML=h;
1788
+ el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
1789
+ card.querySelectorAll('.sendback').forEach(function(b){b.addEventListener('click',function(){api('/api/pending/sendback',{id:b.closest('.task').getAttribute('data-id')}).then(loadTasks);});});
1790
+ var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
1791
+ if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
1792
+ pre.textContent='Loading...';pre.classList.remove('hidden');sd.textContent='Hide diff';api('/api/pending/diff?repo='+encodeURIComponent(repo)).then(function(d){pre.textContent=(d&&d.diff)||'(no changes)';});});
1793
+ var ap=card.querySelector('.approve');if(ap)ap.addEventListener('click',function(){ap.disabled=true;ap.textContent='Committing & pushing...';
1794
+ api('/api/pending/approve',{repo:repo}).then(function(r){var o=document.getElementById('reviewout');
1795
+ if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="<div class='note err'>"+esc(repo)+": "+esc((r&&r.error)||'failed')+"</div>";return;}
1796
+ var m="&#10003; <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
1797
+ if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
1798
+ if(r.pr)m+=" &middot; <a href='"+esc(r.pr)+"' target='_blank' rel='noopener'>open PR</a>";
1799
+ if(r.note)m+="<div class='muted'>"+esc(r.note)+"</div>";
1800
+ o.innerHTML="<div class='note ok'>"+m+"</div>";loadTasks();});});});});}
1579
1801
  function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
1580
1802
  if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
1581
1803
  var h="<div class='tgroup'>Archived <span class='tcount'>"+list.length+"</span></div>";list.forEach(function(t){h+=taskRow(t,true);});el.innerHTML=h;wireTaskRows(el);});}
@@ -1588,6 +1810,16 @@ el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==
1588
1810
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
1589
1811
  var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
1590
1812
  if(a.status==='running')b+="<div class='bar'><i></i></div>";
1813
+ var ch=a.changed;
1814
+ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
1815
+ var parts=[];
1816
+ if(ch.dirty)parts.push('<b>'+ch.dirty+'</b> file'+(ch.dirty>1?'s':'')+' changed');
1817
+ if(ch.stat)parts.push(esc(ch.stat));
1818
+ if(parts.length)b+="<div class='changed'>What it did &middot; "+parts.join(' &middot; ')+"</div>";
1819
+ if(ch.commits&&ch.commits.length)b+="<div class='commits'>"+ch.commits.map(function(c){return "<div><code>"+esc(c.hash)+"</code> "+esc(c.msg)+"</div>";}).join("")+"</div>";
1820
+ }else if(a.status==='done'){
1821
+ b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
1822
+ }
1591
1823
  b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
1592
1824
  return "<div class='agent'>"+b+"</div>";}).join("");
1593
1825
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
@@ -1596,7 +1828,7 @@ stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAg
1596
1828
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1597
1829
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
1598
1830
  api('/api/drift?ci='+ci+'&fetch='+ft).then(function(d){driftLoaded=true;var repos=d.repos||[];var risky=repos.filter(function(r){return r.flags.some(function(f){return f.level==='warn';});});
1599
- var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos &middot; <b>"+risky.length+"</b> with risks</div>";
1831
+ var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos &middot; <b>"+risky.length+"</b> with risks"+(d.partial?" &middot; <span class='err'>partial &mdash; the scan hit its time limit</span>":"")+"</div>";
1600
1832
  repos.forEach(function(r){if(!r.flags.length)return;var warn=r.flags.some(function(f){return f.level==='warn';});
1601
1833
  h+="<div class='drift'><div class='dh'><span class='"+(warn?'dot-w':'dot-c')+"'></span><span class='dn'>"+esc(r.name)+"</span><span class='dd'>"+esc(r.def)+(r.fetchAgeDays!=null&&r.fetchAgeDays>3?" &middot; fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
1602
1834
  r.flags.forEach(function(f){h+="<li class='"+esc(f.level)+"'>"+(f.level==='warn'?'&#9888; ':'&middot; ')+esc(f.text)+(f.evidence?" <span class='ev'>["+esc(f.evidence)+"]</span>":"")+"</li>";});
@@ -1647,7 +1879,11 @@ else if(mode==='pan'){var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW
1647
1879
  el.addEventListener('pointerup',function(ev){var id=downId,wasClick=moved<6;mode=null;dnode=null;downId=null;
1648
1880
  if(id){selectNode(id);}else if(wasClick){sel=null;hideDetail();hideReview();render();updateBar(null);}});
1649
1881
  el.addEventListener('mouseleave',function(){if(!mode)updateBar(null);}); }
1650
- function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();api("/api/map").then(function(g){mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g);render();});}
1882
+ function scanLine(s){return 'Mapping your work… '+s.phase+(s.total?' '+s.done+'/'+s.total:'')+(s.item?' · '+s.item:'')+' · '+Math.round((s.elapsed||0)/1000)+'s';}
1883
+ function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();var done=false;
1884
+ function poll(){if(done)return;api('/api/scan').then(function(s){if(done)return;if(s&&s.active&&s.phase)p.textContent=scanLine(s);setTimeout(poll,600);}).catch(function(){});}
1885
+ setTimeout(poll,400);
1886
+ api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g)+(g.stats&&g.stats.partial?" &middot; <span class='err'>partial &mdash; the scan hit its time limit</span>":"");render();});}
1651
1887
  function fitBadge(m){return m.fits?"<span class='tag'>fits your RAM</span>":"<span class='tag' style='background:#3a2a12;border-color:#6b4a1f;color:#F2A541'>needs more RAM</span>";}
1652
1888
  function loadRec(){var out=document.getElementById('recout');out.innerHTML="<div class='muted'>Reading your hardware...</div>";
1653
1889
  api('/api/models').then(function(d){var hw=d.hardware,rec=d.rec;
@@ -1679,7 +1915,7 @@ function heartbeat(fresh){
1679
1915
  else if(p.started!==SRV_STARTED){location.reload();return;}
1680
1916
  if(srvDown){location.reload();return;}
1681
1917
  if(updBusy)return;
1682
- if(p.latest&&p.version&&p.latest!==p.version){
1918
+ if(p.latest&&p.newer){
1683
1919
  b.className='updatebar show';
1684
1920
  b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available &mdash; you're on "+esc(p.version)+". <button id='doupd'>Update &amp; restart</button>";
1685
1921
  var btn=document.getElementById('doupd');if(btn)btn.onclick=doUpdate;
@@ -1748,10 +1984,11 @@ async function cmdApp() {
1748
1984
  }
1749
1985
  try {
1750
1986
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
1751
- if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
1987
+ if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
1988
+ if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
1752
1989
  if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
1753
1990
  if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
1754
- if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1991
+ if (u.pathname === "/api/node") return json(res, await nodeDetail(u.searchParams.get("id") || "")); // local
1755
1992
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1756
1993
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1757
1994
  if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
@@ -1760,6 +1997,10 @@ async function cmdApp() {
1760
1997
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1761
1998
  if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1762
1999
  if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
2000
+ if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
2001
+ if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
2002
+ if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
2003
+ if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1763
2004
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1764
2005
  if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
1765
2006
  if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
@@ -1775,10 +2016,10 @@ async function cmdApp() {
1775
2016
  const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1776
2017
  return json(res, { started: true, model, id: e ? e.id : "" });
1777
2018
  }
1778
- if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail }; }));
2019
+ if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt) }; }));
1779
2020
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
1780
2021
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1781
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION }); }
2022
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION) }); }
1782
2023
  if (u.pathname === "/api/update" && req.method === "POST") {
1783
2024
  // Install the newest symbiot, then relaunch this same app (same port +
1784
2025
  // token => same URL) and exit. The open page's heartbeat reconnects and
@@ -1872,4 +2113,4 @@ const isMain = (() => {
1872
2113
  })();
1873
2114
  if (isMain) main();
1874
2115
 
1875
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI };
2116
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,6 @@
42
42
  "@anthropic-ai/sdk": "^0.128.0"
43
43
  },
44
44
  "scripts": {
45
- "test": "node test/fixtures.mjs && node test/smoke.mjs"
45
+ "test": "node test/fixtures.mjs && node test/smoke.mjs && node test/install.mjs"
46
46
  }
47
47
  }