symbiot 0.24.0 → 0.27.0
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/README.md +186 -27
- package/index.mjs +331 -74
- 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
|
|
28
|
-
|
|
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)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
- **
|
|
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 #
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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),
|
|
84
|
-
**Drift** tab
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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"
|
|
122
|
-
//
|
|
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
|
|
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.
|
|
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 {
|
|
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("
|
|
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
|
|
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; } }
|
|
@@ -646,28 +772,49 @@ function detectHandoffs() {
|
|
|
646
772
|
if (orca) {
|
|
647
773
|
const q = JSON.stringify(orca);
|
|
648
774
|
agents.unshift(
|
|
649
|
-
{ label: "Orca IDE — open repo (use your Orca agent)", tmpl:
|
|
650
|
-
{ label: "Orca IDE — run Claude in a tab", tmpl:
|
|
775
|
+
{ label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), 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
|
-
|
|
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
|
-
//
|
|
660
|
-
// `
|
|
661
|
-
//
|
|
787
|
+
// Build the full Orca handoff, cold-start safe. `bin` is the quoted orca-ide
|
|
788
|
+
// path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
|
|
789
|
+
// open launches Orca & waits for the runtime to be REACHABLE, but on a cold
|
|
790
|
+
// start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
|
|
791
|
+
// `terminal create` times out — so we poll `status` until state=ready, then add
|
|
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}\\""`;
|
|
797
|
+
function orcaHandoffCmd(bin, commandPart) {
|
|
798
|
+
const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
|
|
799
|
+
const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
|
|
800
|
+
return `${bin} open; ${waitReady}; ${bin} repo add --path {dir}; ${mkTerm}`;
|
|
801
|
+
}
|
|
802
|
+
// Normalise an Orca command saved by an older version (no launch / no wait-for-
|
|
803
|
+
// ready) to the current cold-start-safe form, preserving its binary path and any
|
|
804
|
+
// custom `--command`. Idempotent: already-current commands are left untouched.
|
|
662
805
|
function migrateOrcaCmd(cmd) {
|
|
663
806
|
if (!cmd || typeof cmd !== "string") return cmd;
|
|
664
807
|
if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
812
|
+
const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
|
|
813
|
+
const bin = bm ? bm[1] : "";
|
|
814
|
+
const commandPart = cm ? ` --command "${inner}"` : "";
|
|
815
|
+
const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
|
|
816
|
+
if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
|
|
817
|
+
return rebuilt;
|
|
671
818
|
}
|
|
672
819
|
// Run a shell command as a tracked, logged background job that shows up live in
|
|
673
820
|
// the Agents tab. Shared by the agent handoff and the local-model setup.
|
|
@@ -687,10 +834,26 @@ function track(name, cmd, cwd, onExit) {
|
|
|
687
834
|
return entry;
|
|
688
835
|
} catch { return null; }
|
|
689
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));
|
|
690
854
|
function runHandoff(repoPath) {
|
|
691
855
|
const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
692
|
-
|
|
693
|
-
return track(repoPath.split("/").pop(), cmd, repoPath);
|
|
856
|
+
return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
|
|
694
857
|
}
|
|
695
858
|
// ---- local model one-command setup (Ollama), OS-aware ---------------------
|
|
696
859
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -858,9 +1021,17 @@ function driftRepo(p, opts = {}) {
|
|
|
858
1021
|
return { name, path: p, def, flags, fetchAgeDays: fa };
|
|
859
1022
|
}
|
|
860
1023
|
function computeDrift(opts = {}) {
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
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); }
|
|
864
1035
|
}
|
|
865
1036
|
function cmdDrift() {
|
|
866
1037
|
const d = computeDrift({ ci: has("ci"), fetch: has("fetch") });
|
|
@@ -892,21 +1063,33 @@ const MANIFEST_TOOL = {
|
|
|
892
1063
|
"terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
|
|
893
1064
|
};
|
|
894
1065
|
function findAllRepos(base) {
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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); }
|
|
910
1093
|
}
|
|
911
1094
|
function detectRepo(r) {
|
|
912
1095
|
const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
|
|
@@ -927,11 +1110,8 @@ function detectRepo(r) {
|
|
|
927
1110
|
// Non-git PROJECT folders inside your scan roots (you added them = consent):
|
|
928
1111
|
// a directory with a manifest but no .git — "not everything is a repo".
|
|
929
1112
|
function findProjectFolders(root) {
|
|
930
|
-
const manifests =
|
|
931
|
-
|
|
932
|
-
`\\( -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 ` +
|
|
933
|
-
`-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`,
|
|
934
|
-
).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);
|
|
935
1115
|
const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
|
|
936
1116
|
const out = [];
|
|
937
1117
|
for (const d of dirs) {
|
|
@@ -946,9 +1126,29 @@ function detectFolder(path) {
|
|
|
946
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; }
|
|
947
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 };
|
|
948
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;
|
|
949
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() {
|
|
950
1143
|
const who = me();
|
|
951
|
-
const
|
|
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
|
+
}
|
|
952
1152
|
const nodes = []; const edges = []; const have = new Set();
|
|
953
1153
|
const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
|
|
954
1154
|
add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
|
|
@@ -963,9 +1163,11 @@ function buildMap() {
|
|
|
963
1163
|
// Non-git project folders in the scan roots (capped; deduped against repos).
|
|
964
1164
|
try {
|
|
965
1165
|
const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
|
|
966
|
-
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)) {
|
|
967
1167
|
if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
|
|
968
|
-
|
|
1168
|
+
if (scanExpired()) break;
|
|
1169
|
+
await yieldTick();
|
|
1170
|
+
const det = detectFolder(d); folders++; scanTick(det.name);
|
|
969
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 } });
|
|
970
1172
|
edges.push({ source: "me", target: "folder:" + d });
|
|
971
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 }); }
|
|
@@ -993,13 +1195,14 @@ function buildMap() {
|
|
|
993
1195
|
commits: repos.reduce((s, r) => s + r.mine, 0),
|
|
994
1196
|
files: repos.reduce((s, r) => s + (r.files || 0), 0),
|
|
995
1197
|
base: BASE,
|
|
1198
|
+
partial: SCAN.partial, // the scan hit its deadline — this is what it found so far
|
|
996
1199
|
} };
|
|
997
1200
|
LAST_MAP = out;
|
|
998
1201
|
return out;
|
|
999
1202
|
}
|
|
1000
1203
|
// Local detail for a clicked node (no AI).
|
|
1001
|
-
function nodeDetail(id) {
|
|
1002
|
-
const map = LAST_MAP || buildMap();
|
|
1204
|
+
async function nodeDetail(id) {
|
|
1205
|
+
const map = LAST_MAP || await buildMap();
|
|
1003
1206
|
if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
|
|
1004
1207
|
if (id.startsWith("repo:")) {
|
|
1005
1208
|
const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
|
|
@@ -1291,6 +1494,7 @@ a{color:var(--green);cursor:pointer}.hidden{display:none}
|
|
|
1291
1494
|
.updatebar.show{display:flex}
|
|
1292
1495
|
.updatebar.reconnect{background:var(--amber)}
|
|
1293
1496
|
.updatebar button{font:inherit;font-weight:700;border:0;border-radius:8px;padding:5px 12px;background:var(--ink);color:var(--bone);cursor:pointer}
|
|
1497
|
+
.ver{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px;margin-left:2px;font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
1294
1498
|
footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
1295
1499
|
.profile{font-size:13px;margin-bottom:8px;line-height:1.5}
|
|
1296
1500
|
.profile b{color:var(--bone)}
|
|
@@ -1330,6 +1534,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1330
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}
|
|
1331
1535
|
.fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
|
|
1332
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}
|
|
1333
1541
|
.drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
|
|
1334
1542
|
.drift .dh{display:flex;gap:8px;align-items:center}
|
|
1335
1543
|
.drift .dn{color:var(--bone);font-weight:600}
|
|
@@ -1344,6 +1552,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1344
1552
|
.adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
|
|
1345
1553
|
.adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
|
|
1346
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}
|
|
1347
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)}}
|
|
1348
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}
|
|
1349
1562
|
.bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
|
|
@@ -1356,7 +1569,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1356
1569
|
@media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
|
|
1357
1570
|
</style></head><body>
|
|
1358
1571
|
<div id="updatebar" class="updatebar"></div>
|
|
1359
|
-
<header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
|
|
1572
|
+
<header><span class="dot"></span><span class="brand">Symbiot</span><span class="ver" id="ver"></span><span class="status" id="status">...</span></header>
|
|
1360
1573
|
<div class="tabs">
|
|
1361
1574
|
<button class="tab active" data-tab="map">Map</button>
|
|
1362
1575
|
<button class="tab" data-tab="drift">Drift</button>
|
|
@@ -1397,8 +1610,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1397
1610
|
</section>
|
|
1398
1611
|
<section id="panel-tasks" class="hidden">
|
|
1399
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>
|
|
1400
|
-
<div class="note muted" style="margin-top:2px">
|
|
1613
|
+
<div class="note muted" style="margin-top:2px">To give tasks to your agent, use <b>Send to repos</b> — 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>↶</b> sends it back. Ticking a task yourself marks it done (it auto-archives).</div>
|
|
1401
1614
|
<div id="pushout"></div>
|
|
1615
|
+
<div id="reviewout"></div>
|
|
1616
|
+
<div id="reviewlist"></div>
|
|
1402
1617
|
<div id="taskfilter" class="taskfilter"></div>
|
|
1403
1618
|
<div id="tasklist"></div>
|
|
1404
1619
|
</section>
|
|
@@ -1555,13 +1770,34 @@ box.innerHTML=fb;
|
|
|
1555
1770
|
box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
|
|
1556
1771
|
document.getElementById('archtoggle').addEventListener('click',loadArchived);}
|
|
1557
1772
|
function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
|
|
1558
|
-
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);});
|
|
1559
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;}
|
|
1560
1775
|
var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
|
|
1561
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);});
|
|
1562
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);});});
|
|
1563
1778
|
el.innerHTML=h;wireTaskRows(el);}
|
|
1564
|
-
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();});loadReview();});}
|
|
1780
|
+
function loadReview(){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?" · "+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||'?')+" · "+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)'>↶</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 → "+(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="✓ <b>"+esc(repo)+"</b>: approved "+r.approved+" task"+(r.approved>1?"s":"");
|
|
1797
|
+
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
1798
|
+
if(r.pr)m+=" · <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();});});});});}
|
|
1565
1801
|
function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
|
|
1566
1802
|
if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
|
|
1567
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);});}
|
|
@@ -1574,6 +1810,16 @@ el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==
|
|
|
1574
1810
|
var st=a.status==='running'?('working · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
1575
1811
|
var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
|
|
1576
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 · "+parts.join(' · ')+"</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
|
+
}
|
|
1577
1823
|
b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
|
|
1578
1824
|
return "<div class='agent'>"+b+"</div>";}).join("");
|
|
1579
1825
|
el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
|
|
@@ -1582,7 +1828,7 @@ stopAgentsPoll();if(anyRunning&¤t==='agents')agentsTimer=setTimeout(loadAg
|
|
|
1582
1828
|
function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos…</div>";
|
|
1583
1829
|
var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
|
|
1584
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';});});
|
|
1585
|
-
var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos · <b>"+risky.length+"</b> with risks</div>";
|
|
1831
|
+
var h="<div class='k' style='margin:10px 0'><b>"+repos.length+"</b> repos · <b>"+risky.length+"</b> with risks"+(d.partial?" · <span class='err'>partial — the scan hit its time limit</span>":"")+"</div>";
|
|
1586
1832
|
repos.forEach(function(r){if(!r.flags.length)return;var warn=r.flags.some(function(f){return f.level==='warn';});
|
|
1587
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?" · fetch "+r.fetchAgeDays+"d old":"")+"</span></div><ul>";
|
|
1588
1834
|
r.flags.forEach(function(f){h+="<li class='"+esc(f.level)+"'>"+(f.level==='warn'?'⚠ ':'· ')+esc(f.text)+(f.evidence?" <span class='ev'>["+esc(f.evidence)+"]</span>":"")+"</li>";});
|
|
@@ -1633,7 +1879,11 @@ else if(mode==='pan'){var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW
|
|
|
1633
1879
|
el.addEventListener('pointerup',function(ev){var id=downId,wasClick=moved<6;mode=null;dnode=null;downId=null;
|
|
1634
1880
|
if(id){selectNode(id);}else if(wasClick){sel=null;hideDetail();hideReview();render();updateBar(null);}});
|
|
1635
1881
|
el.addEventListener('mouseleave',function(){if(!mode)updateBar(null);}); }
|
|
1636
|
-
function
|
|
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?" · <span class='err'>partial — the scan hit its time limit</span>":"");render();});}
|
|
1637
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>";}
|
|
1638
1888
|
function loadRec(){var out=document.getElementById('recout');out.innerHTML="<div class='muted'>Reading your hardware...</div>";
|
|
1639
1889
|
api('/api/models').then(function(d){var hw=d.hardware,rec=d.rec;
|
|
@@ -1657,14 +1907,15 @@ document.getElementById('agentcmd').addEventListener('change',saveAgent);
|
|
|
1657
1907
|
document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
|
|
1658
1908
|
var SRV_STARTED=null,srvDown=false,updBusy=false;
|
|
1659
1909
|
function ubar(){return document.getElementById('updatebar');}
|
|
1660
|
-
function heartbeat(){
|
|
1661
|
-
api('/api/ping').then(function(p){
|
|
1910
|
+
function heartbeat(fresh){
|
|
1911
|
+
api('/api/ping'+(fresh?'?fresh=1':'')).then(function(p){
|
|
1662
1912
|
var b=ubar();
|
|
1913
|
+
var ve=document.getElementById('ver');if(ve&&p.version)ve.textContent='v'+p.version;
|
|
1663
1914
|
if(SRV_STARTED===null){SRV_STARTED=p.started;}
|
|
1664
1915
|
else if(p.started!==SRV_STARTED){location.reload();return;}
|
|
1665
1916
|
if(srvDown){location.reload();return;}
|
|
1666
1917
|
if(updBusy)return;
|
|
1667
|
-
if(p.latest&&p.
|
|
1918
|
+
if(p.latest&&p.newer){
|
|
1668
1919
|
b.className='updatebar show';
|
|
1669
1920
|
b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available — you're on "+esc(p.version)+". <button id='doupd'>Update & restart</button>";
|
|
1670
1921
|
var btn=document.getElementById('doupd');if(btn)btn.onclick=doUpdate;
|
|
@@ -1672,7 +1923,8 @@ function heartbeat(){
|
|
|
1672
1923
|
}).catch(function(){srvDown=true;var b=ubar();b.className='updatebar reconnect show';b.textContent='Reconnecting to Symbiot…';});
|
|
1673
1924
|
}
|
|
1674
1925
|
function doUpdate(){updBusy=true;var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
|
|
1675
|
-
setInterval(heartbeat,4000);heartbeat();
|
|
1926
|
+
setInterval(heartbeat,4000);heartbeat(true);
|
|
1927
|
+
window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
|
|
1676
1928
|
initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
|
|
1677
1929
|
</script></body></html>`;
|
|
1678
1930
|
|
|
@@ -1732,10 +1984,11 @@ async function cmdApp() {
|
|
|
1732
1984
|
}
|
|
1733
1985
|
try {
|
|
1734
1986
|
if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
|
|
1735
|
-
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 });
|
|
1736
1989
|
if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
|
|
1737
1990
|
if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
|
|
1738
|
-
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
|
|
1739
1992
|
if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
|
|
1740
1993
|
if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
|
|
1741
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) }))); }
|
|
@@ -1744,6 +1997,10 @@ async function cmdApp() {
|
|
|
1744
1997
|
if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
|
|
1745
1998
|
if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
|
|
1746
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 || ""))); }
|
|
1747
2004
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
1748
2005
|
if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
|
|
1749
2006
|
if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
|
|
@@ -1759,10 +2016,10 @@ async function cmdApp() {
|
|
|
1759
2016
|
const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
|
|
1760
2017
|
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
1761
2018
|
}
|
|
1762
|
-
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) }; }));
|
|
1763
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)); }
|
|
1764
2021
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
1765
|
-
if (u.pathname === "/api/ping") 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) }); }
|
|
1766
2023
|
if (u.pathname === "/api/update" && req.method === "POST") {
|
|
1767
2024
|
// Install the newest symbiot, then relaunch this same app (same port +
|
|
1768
2025
|
// token => same URL) and exit. The open page's heartbeat reconnects and
|
|
@@ -1795,7 +2052,7 @@ async function cmdApp() {
|
|
|
1795
2052
|
else { try { server.listen(0, "127.0.0.1"); } catch {} }
|
|
1796
2053
|
});
|
|
1797
2054
|
server.listen(PORT, "127.0.0.1");
|
|
1798
|
-
checkLatest(); setInterval(checkLatest,
|
|
2055
|
+
checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
|
|
1799
2056
|
}
|
|
1800
2057
|
|
|
1801
2058
|
const HELP = `${c.b("symbiot")} — your week, written from your real work.
|
|
@@ -1856,4 +2113,4 @@ const isMain = (() => {
|
|
|
1856
2113
|
})();
|
|
1857
2114
|
if (isMain) main();
|
|
1858
2115
|
|
|
1859
|
-
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.
|
|
3
|
+
"version": "0.27.0",
|
|
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
|
}
|