symbiot 0.34.0 → 0.36.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 +25 -61
- package/agents.mjs +328 -0
- package/core.mjs +53 -0
- package/drift.mjs +124 -0
- package/index.mjs +48 -419
- package/package.json +4 -1
- package/ui.mjs +24 -10
package/README.md
CHANGED
|
@@ -125,6 +125,12 @@ size of the uncommitted change and a **Show diff** button. For each repo:
|
|
|
125
125
|
|
|
126
126
|
Tasks waiting for review aren't re-sent to the agent.
|
|
127
127
|
|
|
128
|
+
If a repo you sent tasks to has uncommitted changes but no ticked task (say the
|
|
129
|
+
agent made a fix and didn't tick anything), it still shows up here, with
|
|
130
|
+
**Approve changes without a task → PR**. That ships the changes the same way, on
|
|
131
|
+
a `symbiot/changes-<date>` branch, and leaves your tasks as they are. It also
|
|
132
|
+
lists your own uncommitted work in those repos, so check the diff first.
|
|
133
|
+
|
|
128
134
|
### Run your agent automatically
|
|
129
135
|
|
|
130
136
|
Set an **agent command** in Settings (*Hand off to your agent*), and every
|
|
@@ -145,9 +151,14 @@ preset only fills in the command box; the saved command is what runs.
|
|
|
145
151
|
(OpenAI), Aider, Cursor agent, Gemini CLI. The Claude preset may also run
|
|
146
152
|
`npm test` and `node`, so it can check its own work; a command saved from the
|
|
147
153
|
older preset is upgraded automatically, and an edited one is left alone.
|
|
154
|
+
The others run as-is, with their own limits: Codex (`--full-auto`) can run
|
|
155
|
+
tests but has no network in its sandbox, so tests that download packages
|
|
156
|
+
fail; Gemini (`--yolo`) approves every shell command, tests included; Aider
|
|
157
|
+
doesn't run shell commands unattended, so it can't check its own work.
|
|
148
158
|
- **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
|
|
149
|
-
Orca's own agent, or with Claude running in a new tab
|
|
150
|
-
|
|
159
|
+
Orca's own agent, or with Claude running in a new tab (allowed `npm test` and
|
|
160
|
+
`node` like the Claude preset, and upgraded the same way). It launches Orca if
|
|
161
|
+
it's closed and waits for it to be ready first.
|
|
151
162
|
- **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
|
|
152
163
|
Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
|
|
153
164
|
isn't on your PATH). They just open the repo. Nothing comes back for review unless
|
|
@@ -158,6 +169,15 @@ Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
|
|
|
158
169
|
`~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
|
|
159
170
|
`{dir}` and `{prompt}`. A command saved from an older preset keeps working as-is.
|
|
160
171
|
|
|
172
|
+
One agent per repo: while a run is still going (tracked in
|
|
173
|
+
`.symbiot/agent.pid`, so the app and `symbiot push --open` both see it), sending
|
|
174
|
+
again doesn't start a second agent there, and doesn't rewrite the `TASKS.md` it's
|
|
175
|
+
working from either: the new tasks wait in `.symbiot/TASKS.next.md` and replace
|
|
176
|
+
`TASKS.md` when it finishes, keeping anything it ticked. Send again then to start
|
|
177
|
+
an agent on them. Orca and editor presets only open a tab and exit, so Symbiot
|
|
178
|
+
can't see the agent you run in them. Send to repos stays disabled until its
|
|
179
|
+
handoffs have started, so a double click can't send twice.
|
|
180
|
+
|
|
161
181
|
### Questions from your agent
|
|
162
182
|
|
|
163
183
|
An agent working in your terminal or IDE stops to ask you things: which way to go,
|
|
@@ -353,66 +373,10 @@ the one-line install for your OS and you re-run it after. Both are buttons in
|
|
|
353
373
|
Settings, where the download shows in the Agents tab. You can always pick a model
|
|
354
374
|
yourself with `symbiot login`.
|
|
355
375
|
|
|
356
|
-
## Config
|
|
357
|
-
|
|
358
|
-
Everything lives in `~/.config/symbiot/`: `config.json` (your AI, `scanRoots`,
|
|
359
|
-
`agentCmd`, `mail`; readable only by you), `tasks.json` (your tasks),
|
|
360
|
-
`deploys.json` (optional, for drift's production check) and `rules.md` (optional
|
|
361
|
-
conventions every repo review must respect, alongside each repo's own
|
|
362
|
-
`CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md`).
|
|
363
|
-
|
|
364
|
-
- `SYMBIOT_MODEL` — override the model for any provider (e.g. `gpt-4o`,
|
|
365
|
-
`claude-haiku-4-5`, `gemini-1.5-pro`).
|
|
366
|
-
- `SYMBIOT_SCAN_TIMEOUT` — the scan time limit, in seconds (default 60).
|
|
367
|
-
- `SYMBIOT_PORT` — the app's port (default 7391).
|
|
368
|
-
- `SYMBIOT_FORCE_NEW=1` — start a second app even if one is already running.
|
|
369
|
-
- `symbiot whoami` shows the active provider, model, and where the credential
|
|
370
|
-
came from. `symbiot logout` forgets saved credentials.
|
|
371
|
-
|
|
372
|
-
## Development
|
|
373
|
-
|
|
374
|
-
```bash
|
|
375
|
-
npm test
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
Runs five suites, all against throwaway repos, an isolated `HOME` and (for the
|
|
379
|
-
update check) a fake npm registry:
|
|
380
|
-
|
|
381
|
-
- `test/load.mjs` — the shipped files parse and load: `node --check` on each
|
|
382
|
-
module, `ui.mjs` imported on its own with its page's JavaScript parsed, every
|
|
383
|
-
local import listed in package.json `"files"`, and the `bin` entry point shipped.
|
|
384
|
-
- `test/fixtures.mjs` — accuracy fixtures: the facts Symbiot collects (identity
|
|
385
|
-
matching, stale checkouts, worktrees, drift) and the `symbiot drift` report as
|
|
386
|
-
printed, the scan time limit, the agent handoff, review → send back → approve →
|
|
387
|
-
ship (including a repo that gitignores `.symbiot/`), agent questions, mail
|
|
388
|
-
ingestion (`symbiot mail` end to end), and the update command.
|
|
389
|
-
- `test/smoke.mjs` — boots `symbiot app`, runs the page's own JavaScript against
|
|
390
|
-
a fake DOM (every tab, every button), hits every endpoint the UI calls with the
|
|
391
|
-
method the UI uses, checks POST-only endpoints refuse GET and `/api` needs the
|
|
392
|
-
token, and checks the update banner can't loop.
|
|
393
|
-
- `test/app.mjs` — the app's lifecycle: a second `symbiot app` reuses the running
|
|
394
|
-
one (and `SYMBIOT_FORCE_NEW` / a foreign server on the port don't), and the
|
|
395
|
-
update check only offers a higher npm version.
|
|
396
|
-
- `test/install.mjs` — runs the CLI through a bin symlink, then `npm pack` +
|
|
397
|
-
global install into a temp prefix: `symbiot help`, every shipped file present,
|
|
398
|
-
and the installed `symbiot app` serving its page. Needs npm registry access for
|
|
399
|
-
dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
|
|
400
|
-
|
|
401
|
-
`SYMBIOT_NO_OPEN=1` stops `symbiot app` opening a window (the tests set it), and
|
|
402
|
-
`SYMBIOT_REGISTRY` points its update check at another registry. CI
|
|
403
|
-
(`.github/workflows/ci.yml`) runs the whole suite on every pull request.
|
|
404
|
-
|
|
405
|
-
**Releasing.** Bump the version in the pull request, merge it into `main`, then tag
|
|
406
|
-
`main`'s commit:
|
|
407
|
-
|
|
408
|
-
```bash
|
|
409
|
-
git checkout main && git pull
|
|
410
|
-
git tag v$(node -p "require('./package.json').version") && git push origin --tags
|
|
411
|
-
```
|
|
376
|
+
## Config, development and releasing
|
|
412
377
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
refused, so what's on npm is always what's on `main`.
|
|
378
|
+
Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
|
|
379
|
+
suites and how releases are cut are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
416
380
|
|
|
417
381
|
---
|
|
418
382
|
|
package/agents.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Handing a repo to the user's agent: the saved command template, the one-click
|
|
2
|
+
// presets (and the Orca IDE one), the background-job registry behind the Agents
|
|
3
|
+
// tab, and the questions an unattended agent leaves for the user.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { loadConfig, saveConfig, loadTasks, sh, hasCmd } from "./core.mjs";
|
|
10
|
+
|
|
11
|
+
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
12
|
+
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
13
|
+
// ONE code path, whatever the agent. The handoff is a command TEMPLATE the user
|
|
14
|
+
// saves (`agentCmd`), with {dir} (repo path) and {prompt} (the task
|
|
15
|
+
// instruction), and it is the only thing that ever runs:
|
|
16
|
+
// handoffCmd() -> fillHandoff() -> runHandoff() -> track()
|
|
17
|
+
// Everything else in this section only helps pick a template: one-click presets
|
|
18
|
+
// for what's installed (detectHandoffs), the Orca preset (a template too, kept
|
|
19
|
+
// current by migrateOrcaCmd) and the cross-platform lookups behind them.
|
|
20
|
+
// claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
|
|
21
|
+
// gnome-terminal --working-directory={dir} -- claude "{prompt}"
|
|
22
|
+
// Not tied to any one tool — you decide what runs.
|
|
23
|
+
const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
24
|
+
const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
|
|
25
|
+
// Plain words only: in the Orca preset this passes through two shells, so no
|
|
26
|
+
// backticks or $ (escDq covers one level).
|
|
27
|
+
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. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, and read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
|
|
28
|
+
function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateClaudeCmd(cfg.agentCmd)) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
|
|
29
|
+
// Save the template ("" clears it). Either way the legacy `ide` key goes.
|
|
30
|
+
function setHandoffCmd(cmd) {
|
|
31
|
+
const cfg = loadConfig(); const v = String(cmd || "").trim();
|
|
32
|
+
if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
|
|
33
|
+
delete cfg.ide; saveConfig(cfg);
|
|
34
|
+
return { ok: true, cmd: cfg.agentCmd || "" };
|
|
35
|
+
}
|
|
36
|
+
// Grant a blocked agent what it asked for, instead of hand-editing the command:
|
|
37
|
+
// `tool` (a command like "python3" → Bash(python3:*), or a full "Tool(spec)")
|
|
38
|
+
// joins --allowedTools; `dir` joins --add-dir. Claude only (that's where these
|
|
39
|
+
// flags live); for another agent we say to edit the command directly.
|
|
40
|
+
function grantAgent({ tool, dir } = {}) {
|
|
41
|
+
const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
|
|
42
|
+
if (!/^\s*claude\b/.test(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
|
|
43
|
+
if (tool) {
|
|
44
|
+
let t = String(tool).trim();
|
|
45
|
+
if (t && !/^[A-Za-z]+\(/.test(t)) t = `Bash(${t.replace(/[()"]/g, "")}:*)`; // bare word -> Bash(word:*)
|
|
46
|
+
if (t && !cmd.includes(`"${t}"`)) {
|
|
47
|
+
if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
|
|
48
|
+
else cmd += ` --allowedTools "${t}"`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
|
|
52
|
+
cmd = cmd.replace(/\s+/g, " ").trim();
|
|
53
|
+
cfg.agentCmd = cmd; saveConfig(cfg);
|
|
54
|
+
return { ok: true, cmd };
|
|
55
|
+
}
|
|
56
|
+
const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
|
|
57
|
+
// One agent per folder: two identical runs once started on the same repo 6s
|
|
58
|
+
// apart and raced each other. The registry catches a second click in this
|
|
59
|
+
// process; .symbiot/agent.pid catches another one (`symbiot push --open` while
|
|
60
|
+
// the app is up). Returns the job, null (no command set) or { busy, id, pid }.
|
|
61
|
+
// A preset that only opens a tab (Orca, editors) exits at once, so it holds
|
|
62
|
+
// the folder only that long.
|
|
63
|
+
function runHandoff(repoPath) {
|
|
64
|
+
const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
65
|
+
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid };
|
|
66
|
+
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
67
|
+
const lock = join(repoPath, ".symbiot", LOCK);
|
|
68
|
+
const e = track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
|
|
69
|
+
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
70
|
+
releaseHeldTasks(repoPath);
|
|
71
|
+
});
|
|
72
|
+
if (!e) return null;
|
|
73
|
+
e.handoff = true;
|
|
74
|
+
if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt })); } catch {}
|
|
75
|
+
return e;
|
|
76
|
+
}
|
|
77
|
+
const LOCK = "agent.pid";
|
|
78
|
+
const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
|
|
79
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
|
|
80
|
+
// The agent still running in this folder, if any.
|
|
81
|
+
function runningHandoff(path) {
|
|
82
|
+
const e = HANDOFFS.find((x) => x.handoff && x.path === path && x.status === "running");
|
|
83
|
+
if (e) return { id: e.id, pid: e.pid, startedAt: e.startedAt };
|
|
84
|
+
try {
|
|
85
|
+
const l = JSON.parse(readSymbiot(path, LOCK));
|
|
86
|
+
if (l && Number.isInteger(l.pid) && Date.now() - l.startedAt < LOCK_MAX_AGE && pidAlive(l.pid)) return l;
|
|
87
|
+
} catch {}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// Send to repos while an agent is still running in the folder doesn't rewrite
|
|
91
|
+
// the TASKS.md it's working from: the new brief waits in TASKS.next.md and
|
|
92
|
+
// replaces TASKS.md once that agent exits. Returns true if it was held.
|
|
93
|
+
const HELD = "TASKS.next.md";
|
|
94
|
+
function writeTasks(path, md) {
|
|
95
|
+
const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
|
|
96
|
+
if (runningHandoff(path)) { writeFileSync(join(dir, HELD), md); return true; }
|
|
97
|
+
writeFileSync(join(dir, "TASKS.md"), md);
|
|
98
|
+
try { unlinkSync(join(dir, HELD)); } catch {} // superseded by this brief
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
// Swap the held brief in, keeping the ticks the agent made meanwhile: a tick
|
|
102
|
+
// is how a task reaches review, so dropping one would lose that task's work.
|
|
103
|
+
function releaseHeldTasks(path) {
|
|
104
|
+
const held = readSymbiot(path, HELD); if (!held || runningHandoff(path)) return false;
|
|
105
|
+
const isTick = /^\s*-\s*\[x\]\s*/i, key = (l) => l.replace(/^\s*-\s*\[[ x]\]\s*/i, "").trim().toLowerCase();
|
|
106
|
+
const ticked = new Set(readSymbiot(path, "TASKS.md").split("\n").filter((l) => isTick.test(l)).map(key));
|
|
107
|
+
const md = held.split("\n").map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
|
|
108
|
+
try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); return true; } catch { return false; }
|
|
109
|
+
}
|
|
110
|
+
// Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
|
|
111
|
+
// where the CLI isn't on PATH (they're .app bundles).
|
|
112
|
+
const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor", "Cursor"], ["windsurf", "Windsurf", "Windsurf"], ["zed", "Zed", "Zed"], ["subl", "Sublime Text", "Sublime Text"], ["idea", "IntelliJ IDEA", "IntelliJ IDEA"], ["nvim", "Neovim", ""]];
|
|
113
|
+
// [cmd, label, template]. Only agents that leave changes to review: a handoff
|
|
114
|
+
// runs unattended, so a "plan only" run can't ask anything and leaves nothing to
|
|
115
|
+
// approve — that preset was dropped (a saved one still runs as-is).
|
|
116
|
+
// Claude: acceptEdits alone still blocks every shell command, so a run could
|
|
117
|
+
// never run its own tests and had to leave test work unticked. Allow just the
|
|
118
|
+
// test runner and node.
|
|
119
|
+
const CLAUDE_CMD = 'claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"';
|
|
120
|
+
const CLAUDE_CMD_OLD = 'claude -p "{prompt}" --permission-mode acceptEdits'; // ≤0.33
|
|
121
|
+
const AGENT_LIST = [
|
|
122
|
+
["claude", "Claude Code — make changes", CLAUDE_CMD],
|
|
123
|
+
["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
|
|
124
|
+
["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
|
|
125
|
+
["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
|
|
126
|
+
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
127
|
+
];
|
|
128
|
+
function detectHandoffs() {
|
|
129
|
+
const editors = [];
|
|
130
|
+
for (const [cmd, label, app] of IDE_LIST) {
|
|
131
|
+
if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
|
|
132
|
+
else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
|
|
133
|
+
}
|
|
134
|
+
const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
|
|
135
|
+
// Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
|
|
136
|
+
// one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
|
|
137
|
+
// runs Claude in the tab for Claude users. Not Claude-only.
|
|
138
|
+
// `<orca> open` first: it launches Orca AND blocks until the runtime is
|
|
139
|
+
// reachable, so this works even when Orca is closed (its CLI can't talk to a
|
|
140
|
+
// dead app). Use the full orca-ide path, never bare `orca` (that's a different
|
|
141
|
+
// tool on PATH). If Orca is already up, `open` returns fast.
|
|
142
|
+
const orca = findOrcaCli();
|
|
143
|
+
if (orca) {
|
|
144
|
+
const q = JSON.stringify(orca);
|
|
145
|
+
agents.unshift(
|
|
146
|
+
{ label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), kind: "agent" },
|
|
147
|
+
{ label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ORCA_CLAUDE_CMD), kind: "agent" },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return { agents, editors };
|
|
151
|
+
}
|
|
152
|
+
// The Orca preset. Build the full Orca handoff, cold-start safe. `bin` is the
|
|
153
|
+
// quoted orca-ide path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
|
|
154
|
+
// open launches Orca & waits for the runtime to be REACHABLE, but on a cold
|
|
155
|
+
// start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
|
|
156
|
+
// `terminal create` times out — so we poll `status` until state=ready, then add
|
|
157
|
+
// the repo and create the terminal (retry: the worktree can lag a beat behind).
|
|
158
|
+
// {prompt} needs its own (escaped) quotes: the outer shell strips the --command
|
|
159
|
+
// quotes and Orca re-runs the string in the tab, so an unquoted prompt reached
|
|
160
|
+
// the agent as just its first word ("Read"). Same test-runner rules as
|
|
161
|
+
// CLAUDE_CMD, so a run in the tab can test its own work too.
|
|
162
|
+
const ORCA_CLAUDE_INNER = `claude \\"{prompt}\\" --allowedTools \\"Bash(npm test:*)\\" \\"Bash(node:*)\\"`;
|
|
163
|
+
const ORCA_CLAUDE_INNER_OLD = `claude \\"{prompt}\\"`; // ≤0.34
|
|
164
|
+
const ORCA_CLAUDE_CMD = ` --command "${ORCA_CLAUDE_INNER}"`;
|
|
165
|
+
function orcaHandoffCmd(bin, commandPart) {
|
|
166
|
+
const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
|
|
167
|
+
const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
|
|
168
|
+
return `${bin} open; ${waitReady}; ${bin} repo add --path {dir}; ${mkTerm}`;
|
|
169
|
+
}
|
|
170
|
+
// Normalise an Orca command saved by an older version (no launch / no wait-for-
|
|
171
|
+
// ready) to the current cold-start-safe form, preserving its binary path and any
|
|
172
|
+
// custom `--command`. Idempotent: already-current commands are left untouched.
|
|
173
|
+
function migrateOrcaCmd(cmd) {
|
|
174
|
+
if (!cmd || typeof cmd !== "string") return cmd;
|
|
175
|
+
if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
|
|
176
|
+
const cm = cmd.match(/--command\s+"((?:[^"\\]|\\.)*)"/); // preserve a custom agent command
|
|
177
|
+
// ≤0.26 saved a bare {prompt} here (truncated to one word) — quote it; the
|
|
178
|
+
// ≤0.34 Claude preset (exact match only) gains the test-runner rules
|
|
179
|
+
const quoted = cm ? cm[1].replace(/(^|\s)\{prompt\}(?=\s|$)/g, '$1\\"{prompt}\\"') : "";
|
|
180
|
+
const inner = quoted === ORCA_CLAUDE_INNER_OLD ? ORCA_CLAUDE_INNER : quoted;
|
|
181
|
+
if (/status --json/.test(cmd) && /grep -q/.test(cmd) && (!cm || inner === cm[1])) return cmd; // already current
|
|
182
|
+
const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
|
|
183
|
+
const bin = bm ? bm[1] : "";
|
|
184
|
+
const commandPart = cm ? ` --command "${inner}"` : "";
|
|
185
|
+
const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
|
|
186
|
+
if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
|
|
187
|
+
return rebuilt;
|
|
188
|
+
}
|
|
189
|
+
// Upgrade a saved Claude preset from ≤0.33 to the current one. Exact match only:
|
|
190
|
+
// a command the user edited is theirs and runs as-is.
|
|
191
|
+
function migrateClaudeCmd(cmd) {
|
|
192
|
+
if (typeof cmd !== "string" || cmd.trim() !== CLAUDE_CMD_OLD) return cmd;
|
|
193
|
+
try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = CLAUDE_CMD; saveConfig(cfg); } } catch {}
|
|
194
|
+
return CLAUDE_CMD;
|
|
195
|
+
}
|
|
196
|
+
// Cross-platform detection for the presets (hasCmd lives in core.mjs).
|
|
197
|
+
function macApp(name) { if (process.platform !== "darwin" || !name) return ""; for (const base of ["/Applications", join(homedir(), "Applications")]) { try { if (existsSync(join(base, name + ".app"))) return name; } catch {} } return ""; }
|
|
198
|
+
// Find the Orca IDE CLI across OSes (known locations, then a bounded search).
|
|
199
|
+
let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
|
|
200
|
+
function findOrcaCli() {
|
|
201
|
+
if (ORCA_CLI !== undefined) return ORCA_CLI;
|
|
202
|
+
ORCA_CLI = _findOrcaCli();
|
|
203
|
+
return ORCA_CLI;
|
|
204
|
+
}
|
|
205
|
+
function _findOrcaCli() {
|
|
206
|
+
const home = homedir(); const cands = [];
|
|
207
|
+
if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
|
|
208
|
+
if (process.platform === "darwin") { cands.push(join(home, "Library/Application Support/orca-ide/app/resources/bin/orca-ide"), "/Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide", join(home, "Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide")); }
|
|
209
|
+
if (process.platform === "win32") { const la = process.env.LOCALAPPDATA || ""; cands.push(join(la, "orca-ide", "app", "resources", "bin", "orca-ide"), join(la, "Programs", "orca-ide", "resources", "app", "resources", "bin", "orca-ide")); }
|
|
210
|
+
for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
|
|
211
|
+
const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
|
|
212
|
+
: process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
|
|
213
|
+
: [join(home, ".local/share"), "/opt", join(home, ".config")];
|
|
214
|
+
for (const r of roots) { if (!r) continue; const hit = sh(`find ${JSON.stringify(r)} -maxdepth 6 -name orca-ide -type f 2>/dev/null | head -1`).trim(); if (hit) return hit; }
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
// ---- background jobs --------------------------------------------------------
|
|
218
|
+
// Run a shell command as a tracked, logged background job that shows up live in
|
|
219
|
+
// the Agents tab. Shared by the agent handoff and the local-model setup.
|
|
220
|
+
function track(name, cmd, cwd, onExit) {
|
|
221
|
+
try {
|
|
222
|
+
const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
|
|
223
|
+
const logp = join(dir, "agent.log");
|
|
224
|
+
let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
|
|
225
|
+
const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
|
|
226
|
+
const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
|
|
227
|
+
entry.pid = child.pid;
|
|
228
|
+
child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
|
|
229
|
+
child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
|
|
230
|
+
child.unref();
|
|
231
|
+
HANDOFFS.unshift(entry);
|
|
232
|
+
if (HANDOFFS.length > 30) HANDOFFS.length = 30;
|
|
233
|
+
return entry;
|
|
234
|
+
} catch { return null; }
|
|
235
|
+
}
|
|
236
|
+
// Agent-agnostic "what did it do": read it straight from git, so it works the
|
|
237
|
+
// same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
|
|
238
|
+
// current working-tree changes + any commits the agent made since it started.
|
|
239
|
+
function agentChanges(path, startedAt) {
|
|
240
|
+
try {
|
|
241
|
+
const q = JSON.stringify(path);
|
|
242
|
+
const porcelain = sh(`git -C ${q} status --porcelain`).trim();
|
|
243
|
+
// Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
|
|
244
|
+
const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
|
|
245
|
+
const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
|
|
246
|
+
const since = new Date(startedAt || Date.now()).toISOString();
|
|
247
|
+
const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
|
|
248
|
+
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) }; }) : [];
|
|
249
|
+
return { dirty, stat, commits };
|
|
250
|
+
} catch { return { dirty: 0, stat: "", commits: [] }; }
|
|
251
|
+
}
|
|
252
|
+
// ---- agent questions: decisions, options and ideas, from ANY agent ---------
|
|
253
|
+
// A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
|
|
254
|
+
// agent can't stop and ask in chat. TASKS.md tells it to write
|
|
255
|
+
// .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
|
|
256
|
+
// the Agents tab shows on that agent's block. The user's answers are appended to
|
|
257
|
+
// .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
|
|
258
|
+
// same whichever model or tool the agent is.
|
|
259
|
+
const QUESTIONS_MAX = 5;
|
|
260
|
+
const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
261
|
+
const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
|
|
262
|
+
// "## Questions" → "### question", context lines, "- option" bullets;
|
|
263
|
+
// "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
|
|
264
|
+
// bullet under Questions is a question with no options.
|
|
265
|
+
function parseQuestions(md) {
|
|
266
|
+
const questions = [], suggestions = []; let sec = "q", cur = null;
|
|
267
|
+
const clip = (s) => String(s).trim().slice(0, 300);
|
|
268
|
+
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
269
|
+
const l = raw.trim(); if (!l) continue;
|
|
270
|
+
let m;
|
|
271
|
+
if ((m = l.match(/^###\s+(.+)$/))) {
|
|
272
|
+
if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
|
|
273
|
+
else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
|
|
277
|
+
if (/^#\s/.test(l)) continue;
|
|
278
|
+
const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
|
|
279
|
+
if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
|
|
280
|
+
if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
|
|
281
|
+
if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
|
|
282
|
+
}
|
|
283
|
+
return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
|
|
284
|
+
}
|
|
285
|
+
// The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
|
|
286
|
+
// if it's already on the task list for this repo.
|
|
287
|
+
function agentQuestions(path, repo) {
|
|
288
|
+
const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
|
|
289
|
+
const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
|
|
290
|
+
const open = p.questions.filter((x) => !done.has(qKey(x.q)));
|
|
291
|
+
const tasks = p.suggestions.length ? loadTasks() : [];
|
|
292
|
+
return {
|
|
293
|
+
questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
|
|
294
|
+
suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
|
|
298
|
+
// agent (same saved command) so it carries on with them.
|
|
299
|
+
function answerQuestions(path, answers, opts = {}) {
|
|
300
|
+
path = String(path || "");
|
|
301
|
+
if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
|
|
302
|
+
const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
|
|
303
|
+
const rows = (Array.isArray(answers) ? answers : [])
|
|
304
|
+
.map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
|
|
305
|
+
.filter((x) => x.q && x.a);
|
|
306
|
+
if (!rows.length) return { error: "Pick or type at least one answer." };
|
|
307
|
+
const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
|
|
308
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
309
|
+
try {
|
|
310
|
+
mkdirSync(join(path, ".symbiot"), { recursive: true });
|
|
311
|
+
writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
|
|
312
|
+
} catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
|
|
313
|
+
const out = { ok: true, saved: rows.length };
|
|
314
|
+
if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
// The Agents tab: every tracked job, its log tail, what it changed, and — on the
|
|
318
|
+
// newest job per folder — the questions/ideas it left.
|
|
319
|
+
function agentsList() {
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
return HANDOFFS.map((e) => {
|
|
322
|
+
let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
|
|
323
|
+
const first = !seen.has(e.path); seen.add(e.path);
|
|
324
|
+
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), ask: first ? agentQuestions(e.path, e.name) : null };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
|
package/core.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared basics for every module: Symbiot's config and task files, the one
|
|
2
|
+
// shell helper (never throws, never hangs) and a repo's working-tree state.
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// ---- config ---------------------------------------------------------------
|
|
9
|
+
const CONFIG_DIR = join(homedir(), ".config", "symbiot");
|
|
10
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
11
|
+
function loadConfig() {
|
|
12
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
13
|
+
}
|
|
14
|
+
function saveConfig(cfg) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
18
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
19
|
+
return true;
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
// ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
|
|
23
|
+
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
24
|
+
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
25
|
+
function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
|
|
26
|
+
|
|
27
|
+
// ---- shell + git ----------------------------------------------------------
|
|
28
|
+
function sh(cmd) {
|
|
29
|
+
try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
|
|
30
|
+
catch { return ""; } // timeout or error -> empty, never hang the scan
|
|
31
|
+
}
|
|
32
|
+
// "Is this command available?": command -v on posix, where on win.
|
|
33
|
+
function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
|
|
34
|
+
// Working-tree state, incl. detecting a stale/old checkout (not new work).
|
|
35
|
+
function repoState(repoPath) {
|
|
36
|
+
const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
|
|
37
|
+
const porcelain = sh(`git -C ${JSON.stringify(repoPath)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
|
|
38
|
+
let del = 0, mod = 0, add = 0;
|
|
39
|
+
for (const l of porcelain) { const x = l.slice(0, 2); if (/\?\?/.test(x)) add++; else if (x.includes("D")) del++; else if (x.includes("A")) add++; else mod++; }
|
|
40
|
+
const dirty = porcelain.length;
|
|
41
|
+
let stale = false, staleBy = 0;
|
|
42
|
+
if (dirty) {
|
|
43
|
+
for (const k of [3, 5, 10, 20, 40, 80, 160, 320]) {
|
|
44
|
+
if (!sh(`git -C ${JSON.stringify(repoPath)} rev-parse HEAD~${k} 2>/dev/null`).trim()) break;
|
|
45
|
+
if (sh(`git -C ${JSON.stringify(repoPath)} diff --quiet HEAD~${k} 2>/dev/null && echo EQ`).trim() === "EQ") { stale = true; staleBy = k; break; }
|
|
46
|
+
}
|
|
47
|
+
if (!stale && del >= 20 && del > mod && add === 0) stale = true; // mostly deletions = an old snapshot
|
|
48
|
+
}
|
|
49
|
+
const behind = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD..@{u} 2>/dev/null`).trim()) || 0;
|
|
50
|
+
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState };
|
package/drift.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Drift facts for one repo: what's out of sync, stuck or at risk, from git
|
|
2
|
+
// alone (plus gh for --ci and the user's own deploy commands).
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { CONFIG_DIR, sh, hasCmd, repoState } from "./core.mjs";
|
|
6
|
+
|
|
7
|
+
// ---- drift: what's out of sync / stuck / at risk (deterministic git facts) -
|
|
8
|
+
function gitDefaultBranch(repo) {
|
|
9
|
+
const d = sh(`git -C ${JSON.stringify(repo)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`).trim();
|
|
10
|
+
if (d) return d.replace(/^origin\//, "");
|
|
11
|
+
for (const b of ["main", "master", "develop"]) if (sh(`git -C ${JSON.stringify(repo)} rev-parse --verify -q refs/remotes/origin/${b} 2>/dev/null`).trim()) return b;
|
|
12
|
+
return sh(`git -C ${JSON.stringify(repo)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim() || "main";
|
|
13
|
+
}
|
|
14
|
+
function fetchAgeDays(repo) {
|
|
15
|
+
const gd = sh(`git -C ${JSON.stringify(repo)} rev-parse --git-common-dir 2>/dev/null`).trim();
|
|
16
|
+
if (!gd) return null;
|
|
17
|
+
const p = gd.startsWith("/") ? join(gd, "FETCH_HEAD") : join(repo, gd, "FETCH_HEAD");
|
|
18
|
+
try { return Math.floor((Date.now() - statSync(p).mtimeMs) / 86400000); } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktree list 2>/dev/null`).split("\n").filter(Boolean).length; }
|
|
21
|
+
// Deployed-sha commands are read ONLY from the user's own config (never from a
|
|
22
|
+
// repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
|
|
23
|
+
function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
|
|
24
|
+
function ciState(repo, def) {
|
|
25
|
+
if (!hasCmd("gh")) return null;
|
|
26
|
+
if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
|
|
27
|
+
const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
|
|
28
|
+
if (!j) return null;
|
|
29
|
+
try {
|
|
30
|
+
const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
|
|
31
|
+
if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
|
|
32
|
+
if (r.conclusion === "success") return { level: "info", text: `CI on ${def}: passing` };
|
|
33
|
+
if (r.conclusion === "failure") {
|
|
34
|
+
// Deterministic "not running" signal: every job has 0 steps (never started).
|
|
35
|
+
let notRun = false, reason = "";
|
|
36
|
+
try {
|
|
37
|
+
const jobs = (JSON.parse(sh(`cd ${JSON.stringify(repo)} && gh run view ${r.databaseId} --json jobs 2>/dev/null`)).jobs) || [];
|
|
38
|
+
notRun = jobs.length > 0 && jobs.every((x) => ((x.steps || []).length === 0));
|
|
39
|
+
if (notRun && jobs[0] && jobs[0].databaseId) {
|
|
40
|
+
// Only on the not-running path: fetch the one job's annotation to quote
|
|
41
|
+
// GitHub's exact reason (billing / spending limit / runner / disabled).
|
|
42
|
+
const cru = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/actions/jobs/${jobs[0].databaseId} --jq .check_run_url 2>/dev/null`).trim();
|
|
43
|
+
const crid = (cru.match(/check-runs\/(\d+)/) || [])[1];
|
|
44
|
+
if (crid) { const m = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/check-runs/${crid}/annotations --jq '.[0].message' 2>/dev/null`).trim(); if (m) reason = m.slice(0, 240); }
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
if (notRun) return { level: "warn", text: reason ? `CI is NOT running on ${def}: "${reason}"` : `CI is NOT running on ${def} — jobs never started (reason unavailable; usually a billing/spending-limit stop), not failing tests` };
|
|
48
|
+
return { level: "warn", text: `CI's last run on ${def} failed (a real failure — jobs ran)` };
|
|
49
|
+
}
|
|
50
|
+
if (r.conclusion) return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
|
|
51
|
+
return null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
// For merges that aren't on the default branch, tell "likely never landed"
|
|
55
|
+
// (added files missing from default) from "merged another way" (all present).
|
|
56
|
+
function mergedOffDefault(p, defRef) {
|
|
57
|
+
const raw = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%H|%s' 2>/dev/null`)
|
|
58
|
+
.split("\n").filter((l) => /merge pull request/i.test(l));
|
|
59
|
+
if (!raw.length) return [];
|
|
60
|
+
const defFiles = new Set(sh(`git -C ${JSON.stringify(p)} ls-tree -r --name-only ${defRef} 2>/dev/null`).split("\n").filter(Boolean));
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (const line of raw.slice(0, 12)) {
|
|
63
|
+
const [sha, subj] = line.split("|");
|
|
64
|
+
const prNum = (subj.match(/#(\d+)/) || [])[1] || "?";
|
|
65
|
+
const parents = sh(`git -C ${JSON.stringify(p)} rev-list --parents -n1 ${sha} 2>/dev/null`).trim().split(/\s+/);
|
|
66
|
+
const head = parents[2]; // 2nd parent = the merged branch head
|
|
67
|
+
let added = 0, missing = 0;
|
|
68
|
+
if (head) {
|
|
69
|
+
const mb = sh(`git -C ${JSON.stringify(p)} merge-base ${head} ${defRef} 2>/dev/null`).trim();
|
|
70
|
+
const files = sh(`git -C ${JSON.stringify(p)} diff --name-status ${mb || defRef} ${head} 2>/dev/null`)
|
|
71
|
+
.split("\n").map((l) => l.trim()).filter((l) => /^A\b|^A\t/.test(l)).map((l) => l.split(/\s+/).pop());
|
|
72
|
+
added = files.length;
|
|
73
|
+
for (const f of files) if (f && !defFiles.has(f)) missing++;
|
|
74
|
+
}
|
|
75
|
+
hits.push({ prNum, sha: sha.slice(0, 9), added, missing });
|
|
76
|
+
}
|
|
77
|
+
return hits;
|
|
78
|
+
}
|
|
79
|
+
// Drift facts for ONE repo. Compares against origin/<default> when a remote
|
|
80
|
+
// exists, else the local default branch (so it also works on local-only repos).
|
|
81
|
+
function driftRepo(p, opts = {}) {
|
|
82
|
+
const name = p.split("/").pop();
|
|
83
|
+
const def = gitDefaultBranch(p), st = repoState(p), flags = [];
|
|
84
|
+
const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
|
|
85
|
+
const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
|
|
86
|
+
const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
|
|
87
|
+
const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
|
|
88
|
+
// Fetch only when asked, or for a deploy-configured repo (comparing a live
|
|
89
|
+
// deployed sha against a stale origin gives a confidently wrong answer).
|
|
90
|
+
if (originDef && (opts.fetch || cmd)) sh(`git -C ${JSON.stringify(p)} fetch -q origin 2>/dev/null`);
|
|
91
|
+
const fa = fetchAgeDays(p);
|
|
92
|
+
const stale = fa != null && fa > 1 ? ` (as of ${fa}d ago)` : "";
|
|
93
|
+
|
|
94
|
+
if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
|
|
95
|
+
else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
|
|
96
|
+
if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
|
|
97
|
+
const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
|
|
98
|
+
if (originDef) {
|
|
99
|
+
const unmerged = sh(`git -C ${JSON.stringify(p)} branch -r --no-merged origin/${def} 2>/dev/null`).split("\n").map((s) => s.trim()).filter((b) => b && !b.startsWith("origin/HEAD"));
|
|
100
|
+
if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
|
|
101
|
+
}
|
|
102
|
+
if (defRef) {
|
|
103
|
+
const hits = mergedOffDefault(p, defRef);
|
|
104
|
+
const gone = hits.filter((h) => h.added > 0 && h.missing > 0);
|
|
105
|
+
const other = hits.length - gone.length;
|
|
106
|
+
if (gone.length) flags.push({ level: "warn", text: `${gone.length} PR(s) merged off ${def} with added files MISSING from ${def} — likely never landed`, evidence: gone.slice(0, 5).map((h) => `#${h.prNum} (${h.missing}/${h.added} files missing)`).join(" | ") });
|
|
107
|
+
if (other > 0) flags.push({ level: "info", text: `${other} other PR merge(s) off ${def} (files present — probably re-done/squashed)` });
|
|
108
|
+
}
|
|
109
|
+
if (cmd && defRef) {
|
|
110
|
+
const sha = sh(cmd).trim().split(/\s+/)[0];
|
|
111
|
+
if (sha) {
|
|
112
|
+
const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
|
|
113
|
+
const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
|
|
114
|
+
const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
|
|
115
|
+
if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
|
|
116
|
+
else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
|
|
117
|
+
else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
|
|
121
|
+
return { name, path: p, def, flags, fetchAgeDays: fa };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { gitDefaultBranch, loadDeploys, driftRepo };
|