@andrejvysny/symphony 0.1.0 → 0.1.2
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 +46 -25
- package/dist/WORKFLOW.md.example +105 -0
- package/dist/cli.js +13 -0
- package/dist/main.js +325 -72
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,47 +1,68 @@
|
|
|
1
1
|
# @andrejvysny/symphony
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A local **web dashboard** for delegating coding tasks to AI agents. Create tickets on a kanban
|
|
4
|
+
board, and Symphony runs a local coding agent (Claude Code by default) on each one in the background
|
|
5
|
+
— you watch progress live in the browser. Single-user, runs entirely on your machine: no SaaS, no
|
|
6
|
+
Docker, no database, no login. State is plain JSON under `~/.symphony`.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
npm
|
|
12
|
-
symphony --help
|
|
11
|
+
npm install -g @andrejvysny/symphony
|
|
13
12
|
```
|
|
14
13
|
|
|
15
|
-
**
|
|
14
|
+
> Requires **Node ≥ 22**. The default agent uses your local **`claude` login** (`~/.claude`).
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
- A local **`claude` login** (`~/.claude`) — the default backend reuses it. (Codex/opencode backends
|
|
19
|
-
use their own CLIs instead.)
|
|
20
|
-
- A **`WORKFLOW.md`** config in your working directory — copy
|
|
21
|
-
[`WORKFLOW.md.example`](https://github.com/andrejvysny/symphony-ts/blob/master/WORKFLOW.md.example)
|
|
22
|
-
and set `workspace.repo` to your local git repo.
|
|
16
|
+
## Launch the dashboard
|
|
23
17
|
|
|
24
|
-
|
|
18
|
+
**1. (Optional) scaffold a config:**
|
|
25
19
|
|
|
26
20
|
```bash
|
|
27
|
-
#
|
|
28
|
-
|
|
21
|
+
symphony init # writes a WORKFLOW.md you can edit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**2. Start it:**
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
symphony --port 4500
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> Zero-config is fine: `symphony --port 4500` runs with sensible defaults even without a
|
|
31
|
+
> `WORKFLOW.md`, and the dashboard prompts you to create a project. `symphony init` just gives you a
|
|
32
|
+
> file to customize.
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
symphony ticket create "Add dark mode" --desc "..." --state Todo
|
|
34
|
+
**3. Open the dashboard:** **http://127.0.0.1:4500**
|
|
32
35
|
|
|
36
|
+
From there you can create a project (point it at a local git repo), add tickets, and watch the agent
|
|
37
|
+
work them in real time.
|
|
38
|
+
|
|
39
|
+
## In the dashboard
|
|
40
|
+
|
|
41
|
+
- **Kanban board** — Backlog · Todo · In Progress · Human Review · Done, updating live as the agent works.
|
|
42
|
+
- **Create tickets** — title, description, priority; the agent picks them up automatically.
|
|
43
|
+
- **Live agent view** — the agent's plan (TodoWrite checklist) and activity stream per ticket.
|
|
44
|
+
- **Projects** — switch between repos, or create a new one, without restarting.
|
|
45
|
+
- **Settings** — backend, concurrency, timeouts, poll interval — applied live.
|
|
46
|
+
|
|
47
|
+
## CLI
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
symphony init # write a starter WORKFLOW.md (optional)
|
|
51
|
+
symphony --port 4500 # run the orchestrator + dashboard (zero-config OK)
|
|
52
|
+
symphony ticket create "Add dark mode" --state Todo # create a ticket from the terminal
|
|
53
|
+
symphony --help
|
|
33
54
|
symphony --version
|
|
34
55
|
```
|
|
35
56
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
| `symphony --version` / `--help` | Version / usage. |
|
|
57
|
+
## Security
|
|
58
|
+
|
|
59
|
+
The dashboard has **no authentication** — keep it on loopback (`127.0.0.1`, the default). Binding to
|
|
60
|
+
a public host exposes the API to your network.
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
## Links
|
|
43
63
|
|
|
44
|
-
Full docs, configuration reference, and
|
|
64
|
+
- Full docs, configuration reference, and source: **https://github.com/andrejvysny/symphony-ts**
|
|
65
|
+
- Annotated config: [`WORKFLOW.md.example`](https://github.com/andrejvysny/symphony-ts/blob/master/WORKFLOW.md.example)
|
|
45
66
|
|
|
46
67
|
## License
|
|
47
68
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Symphony-TS workflow contract. Copy to WORKFLOW.md and customize.
|
|
3
|
+
tracker:
|
|
4
|
+
kind: file
|
|
5
|
+
# Local file store root — every project + its issues live under here as plain JSON.
|
|
6
|
+
# Defaults to ~/.symphony when omitted. No database, no Docker, no external services.
|
|
7
|
+
data_root: ~/.symphony
|
|
8
|
+
# Active project key: a slug naming its directory under <data_root>/projects/. Leave unset to start
|
|
9
|
+
# with NO active project — the dashboard then prompts you to create or open one (managed from the
|
|
10
|
+
# project switcher; the chosen project is written back here). There is no implicit "default" project.
|
|
11
|
+
# project_id: my-project
|
|
12
|
+
# Symphony custom flow. The agent may set active_states + review_state (never terminal).
|
|
13
|
+
# Board lanes: Backlog · Todo · In Progress · Human Review · Done. "Rework" is no longer a state —
|
|
14
|
+
# the review "Rework" action sends a ticket back to In Progress with a `rework` badge. Cancelled is
|
|
15
|
+
# a terminal state for classification but is hidden from the board.
|
|
16
|
+
active_states: [Todo, In Progress]
|
|
17
|
+
terminal_states: [Done, Closed, Canceled, Cancelled, Duplicate]
|
|
18
|
+
# Non-active, non-terminal "park" state: the agent moves a finished issue here for a human.
|
|
19
|
+
review_state: Human Review
|
|
20
|
+
# Leftmost, non-active "not yet ready" lane (human-only — the orchestrator never dispatches it).
|
|
21
|
+
# Seeded into new projects and additively added to existing ones. Set to '' to disable.
|
|
22
|
+
backlog_state: Backlog
|
|
23
|
+
# The orchestrator moves an issue here the instant an agent picks it up from the entry lane (the
|
|
24
|
+
# first active state), so the board shows work-in-progress immediately. Must be active; '' disables.
|
|
25
|
+
in_progress_state: In Progress
|
|
26
|
+
|
|
27
|
+
# Switchable projects for the dashboard's project switcher. The ACTIVE project is whichever
|
|
28
|
+
# project_id/repo sit in `tracker`/`workspace` above; this registry is the set you can switch to
|
|
29
|
+
# (and what "+ New project" appends to). Each entry = a project key + its own repo folder.
|
|
30
|
+
# Switching live re-points the orchestrator (no restart). Managed from the dashboard — listed here
|
|
31
|
+
# so it survives restarts.
|
|
32
|
+
projects:
|
|
33
|
+
# - name: Backend
|
|
34
|
+
# project_id: backend # the project's dir key under <data_root>/projects/
|
|
35
|
+
# repo: ~/code/backend
|
|
36
|
+
# identifier: BE # issue id prefix → BE-1, BE-2, …
|
|
37
|
+
|
|
38
|
+
polling:
|
|
39
|
+
interval_ms: 5000
|
|
40
|
+
|
|
41
|
+
workspace:
|
|
42
|
+
# single_dir (default): the agent works DIRECTLY in `repo` on its current branch, ONE task at a
|
|
43
|
+
# time, so tasks build on each other (commits land on e.g. master). `repo` must be a LOCAL path.
|
|
44
|
+
# worktree: each ticket gets an isolated worktree branched off `base_branch`, merged back on accept.
|
|
45
|
+
mode: single_dir
|
|
46
|
+
# The project repo. single_dir: a local path the agent edits. worktree: local path or git URL.
|
|
47
|
+
repo: ~/code/your-repo
|
|
48
|
+
# worktree mode only: where shared clone + per-issue worktrees live; and the branch naming prefix.
|
|
49
|
+
root: ~/code/symphony-workspaces
|
|
50
|
+
branch_prefix: "symphony/"
|
|
51
|
+
# worktree mode only: branch new worktrees off this branch (defaults to the clone's default branch).
|
|
52
|
+
# base_branch: main
|
|
53
|
+
# worktree mode only: on accept (review → Done) merge the issue branch into base_branch so the next
|
|
54
|
+
# worktree builds on top. On conflict the branch is preserved and a banner surfaces it. Default true.
|
|
55
|
+
merge_on_accept: true
|
|
56
|
+
|
|
57
|
+
hooks:
|
|
58
|
+
# Runs once per new worktree. Install deps here.
|
|
59
|
+
after_create: |
|
|
60
|
+
if [ -f package.json ]; then npm ci || npm install; fi
|
|
61
|
+
timeout_ms: 120000
|
|
62
|
+
|
|
63
|
+
agent:
|
|
64
|
+
backend: claude-sdk # claude-sdk | claude-cli | codex-cli | opencode-cli
|
|
65
|
+
permission_mode: bypassPermissions # full autonomy (high-trust)
|
|
66
|
+
max_concurrent_agents: 5
|
|
67
|
+
# Symphony's per-task RE-PROMPT budget (not the agent's own steps). 2 = one full delegation + at
|
|
68
|
+
# most one finish-up nudge if the agent stops before parking the issue for review.
|
|
69
|
+
max_turns: 2
|
|
70
|
+
# Cap consecutive continuation re-dispatches before blocking the issue (0 = unlimited). 1 = surface
|
|
71
|
+
# an unfinished task to the operator after the single nudge instead of looping.
|
|
72
|
+
max_continuations: 1
|
|
73
|
+
# max_agent_steps: 200 # optional: cap the AGENT's own internal step budget (SDK maxTurns).
|
|
74
|
+
# # Omit to leave uncapped so one delegation runs to completion.
|
|
75
|
+
# model: claude-opus-4-8 # optional model override
|
|
76
|
+
# effort: high # low | medium | high | xhigh | max (reasoning depth; default high)
|
|
77
|
+
# thinking: adaptive # adaptive (Claude decides) | disabled
|
|
78
|
+
# max_budget_usd: 5 # optional per-turn budget cap (claude-sdk)
|
|
79
|
+
# system_prompt: | # override the built-in Claude-optimized operating contract
|
|
80
|
+
# <role>You are ...</role>
|
|
81
|
+
# tmux: true # CLI backends only: run each turn in a tmux session you can
|
|
82
|
+
# `tmux attach -t symphony-<id>`; raw stdout is logged to logs_root.
|
|
83
|
+
# No effect on the in-process claude-sdk backend.
|
|
84
|
+
|
|
85
|
+
# Root for raw tmux session logs (run.jsonl/err.log per turn). Default: <tmpdir>/symphony_logs.
|
|
86
|
+
# Override at launch with `--logs-root <dir>`.
|
|
87
|
+
# logs_root: ~/code/symphony-logs
|
|
88
|
+
|
|
89
|
+
server:
|
|
90
|
+
port: 4500 # enables the dashboard at http://127.0.0.1:4500/
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
You have been assigned issue {{ issue.identifier }}, working in an isolated git worktree on the branch you were started on.
|
|
94
|
+
|
|
95
|
+
<issue>
|
|
96
|
+
Identifier: {{ issue.identifier }}
|
|
97
|
+
Issue id (pass as task_id to the tracker tools): {{ issue.id }}
|
|
98
|
+
Title: {{ issue.title }}
|
|
99
|
+
{% if issue.priority %}Priority: {{ issue.priority }}
|
|
100
|
+
{% endif %}{% if issue.labels.size > 0 %}Labels: {{ issue.labels | join: ", " }}
|
|
101
|
+
{% endif %}Description:
|
|
102
|
+
{% if issue.description %}{{ issue.description }}{% else %}(No description was provided. Treat the title as the specification; if it is too vague to implement safely, follow the blocked protocol.){% endif %}
|
|
103
|
+
</issue>
|
|
104
|
+
|
|
105
|
+
Implement this issue end to end, following your operating protocol: read it with tracker_get_task, move it to "In Progress" with a short plan comment, make the change, confirm the project's build/tests/lint pass, commit locally, then post an evidence-backed summary comment (with the verification output and commit SHA) and move the issue to "Human Review". Keep every change scoped to this issue.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
var major = Number(process.versions.node.split(".")[0]);
|
|
5
|
+
if (Number.isFinite(major) && major < 22) {
|
|
6
|
+
process.stderr.write(
|
|
7
|
+
`symphony requires Node >= 22 (you are on ${process.versions.node}). Please upgrade Node and re-run.
|
|
8
|
+
`
|
|
9
|
+
);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
await import(new URL("./main.js", import.meta.url).href);
|
|
13
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
|
-
import
|
|
4
|
+
import { existsSync as existsSync4 } from "fs";
|
|
5
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
6
|
+
import path13 from "path";
|
|
5
7
|
|
|
6
8
|
// ../../packages/core/dist/index.js
|
|
7
9
|
import { z as z5 } from "zod";
|
|
@@ -28,13 +30,13 @@ import { readFile as readFile3 } from "fs/promises";
|
|
|
28
30
|
import path22 from "path";
|
|
29
31
|
import YAML from "yaml";
|
|
30
32
|
import { createHash } from "crypto";
|
|
31
|
-
import { readFile as readFile22, stat, writeFile as writeFile2 } from "fs/promises";
|
|
33
|
+
import { mkdir as mkdir2, readFile as readFile22, stat, writeFile as writeFile2 } from "fs/promises";
|
|
32
34
|
import path42 from "path";
|
|
33
35
|
import { Liquid } from "liquidjs";
|
|
34
36
|
import path7 from "path";
|
|
35
|
-
import { mkdir as
|
|
37
|
+
import { mkdir as mkdir3 } from "fs/promises";
|
|
36
38
|
import { execa } from "execa";
|
|
37
|
-
import { access as access2, mkdir as
|
|
39
|
+
import { access as access2, mkdir as mkdir22 } from "fs/promises";
|
|
38
40
|
import path5 from "path";
|
|
39
41
|
import { simpleGit } from "simple-git";
|
|
40
42
|
import { realpathSync } from "fs";
|
|
@@ -1597,18 +1599,53 @@ var FileStore = class {
|
|
|
1597
1599
|
async doEnsure() {
|
|
1598
1600
|
await fs.mkdir(this.issuesDir, { recursive: true });
|
|
1599
1601
|
await fs.mkdir(this.uploadsDir, { recursive: true });
|
|
1600
|
-
await this.
|
|
1601
|
-
this.metaFile,
|
|
1602
|
-
JSON.stringify(
|
|
1603
|
-
{ identifier: this.seed.identifier, next_seq: 1 },
|
|
1604
|
-
null,
|
|
1605
|
-
2
|
|
1606
|
-
)
|
|
1607
|
-
);
|
|
1602
|
+
await this.ensureMeta();
|
|
1608
1603
|
await this.writeIfAbsent(this.statesFile, JSON.stringify(this.seed.states, null, 2));
|
|
1609
1604
|
await this.writeIfAbsent(this.labelsFile, JSON.stringify(this.seed.labels ?? [], null, 2));
|
|
1610
1605
|
await this.ensureSeedStates();
|
|
1611
1606
|
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Ensure meta.json exists and is valid. A fresh project seeds `{ identifier, next_seq: 1 }`. If
|
|
1609
|
+
* meta.json is MISSING or CORRUPT while issue files already exist (deleted/corrupted out from under
|
|
1610
|
+
* a populated project), recover instead of resetting ids to 1 — a reset would reissue existing ids
|
|
1611
|
+
* and let a later create overwrite a live ticket. Recovery scans `issues/<ID>.json` for the max
|
|
1612
|
+
* trailing `-<seq>` and the shared id prefix. Runs under the meta lock; the atomic write makes
|
|
1613
|
+
* concurrent recovery converge (the recovered values are deterministic from the files on disk).
|
|
1614
|
+
*/
|
|
1615
|
+
async ensureMeta() {
|
|
1616
|
+
await withFileLock(this.metaFile, async () => {
|
|
1617
|
+
if (await this.readJson(this.metaFile, metaSchema)) return;
|
|
1618
|
+
const recovered = await this.recoverMeta();
|
|
1619
|
+
await writeFileAtomic(this.metaFile, JSON.stringify(recovered, null, 2));
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
/** Reconstruct meta from existing issue filenames (max trailing `-<seq>` + their shared prefix). */
|
|
1623
|
+
async recoverMeta() {
|
|
1624
|
+
let names = [];
|
|
1625
|
+
try {
|
|
1626
|
+
names = await fs.readdir(this.issuesDir);
|
|
1627
|
+
} catch (e) {
|
|
1628
|
+
if (!isErrno(e, "ENOENT")) throw e;
|
|
1629
|
+
}
|
|
1630
|
+
let maxSeq = 0;
|
|
1631
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
1632
|
+
for (const name of names) {
|
|
1633
|
+
const m = /^(.+)-(\d+)\.json$/.exec(name);
|
|
1634
|
+
if (!m) continue;
|
|
1635
|
+
prefixes.add(m[1]);
|
|
1636
|
+
const seq2 = Number(m[2]);
|
|
1637
|
+
if (Number.isFinite(seq2) && seq2 > maxSeq) maxSeq = seq2;
|
|
1638
|
+
}
|
|
1639
|
+
if (prefixes.size > 1) {
|
|
1640
|
+
throw new Error(
|
|
1641
|
+
`file store: cannot recover meta.json for project ${this.projectKey} \u2014 conflicting issue id prefixes: ${[...prefixes].sort().join(", ")}`
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
const identifier = prefixes.size === 1 ? [...prefixes][0] : this.seed.identifier;
|
|
1645
|
+
if (maxSeq > 0)
|
|
1646
|
+
this.warn(`file store: recovered meta.json for ${this.projectKey} (next_seq=${maxSeq + 1})`);
|
|
1647
|
+
return { identifier, next_seq: maxSeq + 1 };
|
|
1648
|
+
}
|
|
1612
1649
|
/**
|
|
1613
1650
|
* Additively reconcile states.json with the seeded state set so a newly-added lane (e.g. Backlog)
|
|
1614
1651
|
* appears in already-created projects too. Preserves every existing entry, its data, and order;
|
|
@@ -1737,7 +1774,13 @@ var FileStore = class {
|
|
|
1737
1774
|
/** Write a brand-new issue. Its id is unique (minted from reserveId) so no lock is needed. */
|
|
1738
1775
|
async putNewIssue(issue) {
|
|
1739
1776
|
await this.ensureProject();
|
|
1740
|
-
|
|
1777
|
+
const file = this.issueFile(issue.id);
|
|
1778
|
+
if (await fs.access(file).then(
|
|
1779
|
+
() => true,
|
|
1780
|
+
() => false
|
|
1781
|
+
))
|
|
1782
|
+
throw new Error(`file store: refusing to overwrite existing issue ${issue.id}`);
|
|
1783
|
+
await writeFileAtomic(file, JSON.stringify(issue, null, 2));
|
|
1741
1784
|
}
|
|
1742
1785
|
/** Read-modify-write one issue under its file lock. Throws if the issue does not exist. */
|
|
1743
1786
|
async mutateIssue(id, fn) {
|
|
@@ -2096,24 +2139,26 @@ function str(v) {
|
|
|
2096
2139
|
|
|
2097
2140
|
// ../../packages/core/dist/index.js
|
|
2098
2141
|
import { execFile as execFile4 } from "child_process";
|
|
2099
|
-
import { appendFile, mkdir as
|
|
2142
|
+
import { appendFile, mkdir as mkdir4 } from "fs/promises";
|
|
2100
2143
|
import path8 from "path";
|
|
2101
2144
|
import { promisify as promisify4 } from "util";
|
|
2102
2145
|
import { pino } from "pino";
|
|
2146
|
+
import { createHash as createHash2 } from "crypto";
|
|
2103
2147
|
import { existsSync } from "fs";
|
|
2104
2148
|
import { createRequire } from "module";
|
|
2105
2149
|
import os22 from "os";
|
|
2106
2150
|
import path9 from "path";
|
|
2107
2151
|
import { fileURLToPath } from "url";
|
|
2108
2152
|
import { execFile as execFile22 } from "child_process";
|
|
2109
|
-
import { mkdir as
|
|
2153
|
+
import { mkdir as mkdir5 } from "fs/promises";
|
|
2110
2154
|
import { promisify as promisify22 } from "util";
|
|
2111
|
-
import { once } from "events";
|
|
2112
2155
|
import fs2 from "fs/promises";
|
|
2113
2156
|
import net from "net";
|
|
2157
|
+
import path10 from "path";
|
|
2114
2158
|
import { existsSync as existsSync2 } from "fs";
|
|
2159
|
+
import { rm as rm2 } from "fs/promises";
|
|
2115
2160
|
import os3 from "os";
|
|
2116
|
-
import
|
|
2161
|
+
import path11 from "path";
|
|
2117
2162
|
var DEFAULT_ACTIVE_STATES = ["Todo", "In Progress"];
|
|
2118
2163
|
var DEFAULT_TERMINAL_STATES = ["Done", "Closed", "Canceled", "Cancelled", "Duplicate"];
|
|
2119
2164
|
var trackerSchema = z5.object({
|
|
@@ -2405,6 +2450,50 @@ async function loadConfig(filePath) {
|
|
|
2405
2450
|
}
|
|
2406
2451
|
return { config, promptBody: wf.promptBody, filePath: wf.filePath };
|
|
2407
2452
|
}
|
|
2453
|
+
var WORKFLOW_TEMPLATE = `---
|
|
2454
|
+
# Symphony workflow config. Edit as needed, then run: symphony --port 4500
|
|
2455
|
+
# Full annotated reference: WORKFLOW.md.example (shipped next to this CLI) or
|
|
2456
|
+
# https://github.com/andrejvysny/symphony-ts/blob/master/WORKFLOW.md.example
|
|
2457
|
+
|
|
2458
|
+
tracker:
|
|
2459
|
+
kind: file
|
|
2460
|
+
# Local JSON task store root (no database, no services). Defaults to ~/.symphony.
|
|
2461
|
+
data_root: ~/.symphony
|
|
2462
|
+
# Leave project_id unset to start with NO active project \u2014 open the dashboard and use
|
|
2463
|
+
# "+ New project" to create or select one (the choice is written back here). There is no
|
|
2464
|
+
# implicit default project.
|
|
2465
|
+
# project_id: my-project
|
|
2466
|
+
|
|
2467
|
+
workspace:
|
|
2468
|
+
# single_dir (default): the agent works DIRECTLY in \`repo\` on its current branch, ONE task at a
|
|
2469
|
+
# time, so tasks build on each other. worktree: isolate each ticket in its own git worktree.
|
|
2470
|
+
mode: single_dir
|
|
2471
|
+
# The project repo \u2014 a LOCAL git path. You can also set this per-project from the dashboard.
|
|
2472
|
+
# repo: ~/code/your-repo
|
|
2473
|
+
|
|
2474
|
+
agent:
|
|
2475
|
+
backend: claude-sdk # claude-sdk | claude-cli | codex-cli | opencode-cli
|
|
2476
|
+
permission_mode: bypassPermissions
|
|
2477
|
+
max_concurrent_agents: 5
|
|
2478
|
+
|
|
2479
|
+
server:
|
|
2480
|
+
port: 4500 # dashboard at http://127.0.0.1:4500/
|
|
2481
|
+
---
|
|
2482
|
+
|
|
2483
|
+
You have been assigned issue {{ issue.identifier }}: "{{ issue.title }}".
|
|
2484
|
+
|
|
2485
|
+
<issue>
|
|
2486
|
+
Identifier: {{ issue.identifier }}
|
|
2487
|
+
Issue id (pass as task_id to the tracker tools): {{ issue.id }}
|
|
2488
|
+
Title: {{ issue.title }}
|
|
2489
|
+
{% if issue.priority %}Priority: {{ issue.priority }}
|
|
2490
|
+
{% endif %}{% if issue.labels.size > 0 %}Labels: {{ issue.labels | join: ", " }}
|
|
2491
|
+
{% endif %}Description:
|
|
2492
|
+
{% if issue.description %}{{ issue.description }}{% else %}(No description was provided. Treat the title as the specification; if it is too vague to implement safely, follow the blocked protocol.){% endif %}
|
|
2493
|
+
</issue>
|
|
2494
|
+
|
|
2495
|
+
Implement this issue end to end: read it with tracker_get_task, move it to "In Progress" with a short plan comment, make the change, confirm the project's build/tests/lint pass, commit locally, then post an evidence-backed summary comment (with the verification output and commit SHA) and move the issue to "Human Review". Keep every change scoped to this issue.
|
|
2496
|
+
`;
|
|
2408
2497
|
var noopLogger = {
|
|
2409
2498
|
info: () => {
|
|
2410
2499
|
},
|
|
@@ -2414,23 +2503,31 @@ var noopLogger = {
|
|
|
2414
2503
|
},
|
|
2415
2504
|
child: () => noopLogger
|
|
2416
2505
|
};
|
|
2506
|
+
var MISSING_STAMP = { mtimeMs: 0, size: 0, hash: "" };
|
|
2417
2507
|
var WorkflowStore = class {
|
|
2418
|
-
constructor(filePath,
|
|
2508
|
+
constructor(filePath, opts = {}) {
|
|
2419
2509
|
this.filePath = filePath;
|
|
2420
|
-
this.logger = logger;
|
|
2421
|
-
this.pollMs = pollMs;
|
|
2510
|
+
this.logger = opts.logger ?? noopLogger;
|
|
2511
|
+
this.pollMs = opts.pollMs ?? 1e3;
|
|
2512
|
+
this.allowMissing = opts.allowMissing ?? false;
|
|
2422
2513
|
}
|
|
2423
2514
|
filePath;
|
|
2424
|
-
logger;
|
|
2425
|
-
pollMs;
|
|
2426
2515
|
current;
|
|
2427
2516
|
stamp;
|
|
2428
2517
|
timer;
|
|
2429
2518
|
/** Raw (pre-resolution) front-matter from the last read — the basis for write-back. */
|
|
2430
2519
|
rawFrontMatter = {};
|
|
2431
2520
|
body = "";
|
|
2432
|
-
|
|
2521
|
+
logger;
|
|
2522
|
+
pollMs;
|
|
2523
|
+
allowMissing;
|
|
2524
|
+
/** Initial load. Throws (fatal at startup) if the file is missing/invalid — unless `allowMissing`,
|
|
2525
|
+
* where a missing file loads defaults. */
|
|
2433
2526
|
async load() {
|
|
2527
|
+
return this.refresh();
|
|
2528
|
+
}
|
|
2529
|
+
/** Re-read from disk and replace the in-memory snapshot + stamp; returns the fresh snapshot. */
|
|
2530
|
+
async refresh() {
|
|
2434
2531
|
const { snapshot, stamp, raw, body } = await this.read();
|
|
2435
2532
|
this.current = snapshot;
|
|
2436
2533
|
this.stamp = stamp;
|
|
@@ -2438,6 +2535,10 @@ var WorkflowStore = class {
|
|
|
2438
2535
|
this.body = body;
|
|
2439
2536
|
return snapshot;
|
|
2440
2537
|
}
|
|
2538
|
+
/** Whether we hold in-memory defaults for a not-yet-created file (zero-config pre-create state). */
|
|
2539
|
+
isMissingStamp() {
|
|
2540
|
+
return this.stamp?.mtimeMs === 0 && this.stamp.size === 0;
|
|
2541
|
+
}
|
|
2441
2542
|
start() {
|
|
2442
2543
|
if (this.timer) return;
|
|
2443
2544
|
this.timer = setInterval(() => {
|
|
@@ -2473,17 +2574,32 @@ var WorkflowStore = class {
|
|
|
2473
2574
|
* double-reload. Returns the new snapshot.
|
|
2474
2575
|
*/
|
|
2475
2576
|
async persist(mutate) {
|
|
2476
|
-
const
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2577
|
+
const abs = path42.resolve(this.filePath);
|
|
2578
|
+
await mkdir2(path42.dirname(abs), { recursive: true });
|
|
2579
|
+
if (this.isMissingStamp()) {
|
|
2580
|
+
try {
|
|
2581
|
+
await this.refresh();
|
|
2582
|
+
} catch {
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
const apply = () => {
|
|
2586
|
+
const raw = structuredClone(this.rawFrontMatter);
|
|
2587
|
+
mutate(raw);
|
|
2588
|
+
resolveConfig(parseConfig(raw), path42.dirname(abs));
|
|
2589
|
+
return serializeWorkflowFile(raw, this.body);
|
|
2590
|
+
};
|
|
2591
|
+
if (this.isMissingStamp()) {
|
|
2592
|
+
try {
|
|
2593
|
+
await writeFile2(abs, apply(), { encoding: "utf8", flag: "wx" });
|
|
2594
|
+
} catch (e) {
|
|
2595
|
+
if (e.code !== "EEXIST") throw e;
|
|
2596
|
+
await this.refresh();
|
|
2597
|
+
await writeFile2(abs, apply(), "utf8");
|
|
2598
|
+
}
|
|
2599
|
+
} else {
|
|
2600
|
+
await writeFile2(abs, apply(), "utf8");
|
|
2601
|
+
}
|
|
2602
|
+
return this.refresh();
|
|
2487
2603
|
}
|
|
2488
2604
|
async read() {
|
|
2489
2605
|
const abs = path42.resolve(this.filePath);
|
|
@@ -2493,6 +2609,10 @@ var WorkflowStore = class {
|
|
|
2493
2609
|
st = await stat(abs);
|
|
2494
2610
|
content = await readFile22(abs, "utf8");
|
|
2495
2611
|
} catch (e) {
|
|
2612
|
+
if (this.allowMissing && e.code === "ENOENT") {
|
|
2613
|
+
const config2 = resolveConfig(parseConfig({}), path42.dirname(abs));
|
|
2614
|
+
return { snapshot: { config: config2, promptBody: "" }, stamp: MISSING_STAMP, raw: {}, body: "" };
|
|
2615
|
+
}
|
|
2496
2616
|
throw new ConfigError(`cannot read workflow file ${abs}: ${e.message}`);
|
|
2497
2617
|
}
|
|
2498
2618
|
const hash = createHash("sha1").update(content).digest("hex");
|
|
@@ -2511,12 +2631,14 @@ var WorkflowStore = class {
|
|
|
2511
2631
|
try {
|
|
2512
2632
|
st = await stat(abs);
|
|
2513
2633
|
} catch (e) {
|
|
2634
|
+
if (this.allowMissing && e.code === "ENOENT") return;
|
|
2514
2635
|
this.logger.error({ error: String(e) }, "workflow file stat failed; keeping last config");
|
|
2515
2636
|
return;
|
|
2516
2637
|
}
|
|
2517
2638
|
if (this.stamp && st.mtimeMs === this.stamp.mtimeMs && st.size === this.stamp.size) return;
|
|
2518
2639
|
try {
|
|
2519
2640
|
const { snapshot, stamp, raw, body } = await this.read();
|
|
2641
|
+
if (stamp === MISSING_STAMP) return;
|
|
2520
2642
|
if (this.stamp && stamp.hash === this.stamp.hash) {
|
|
2521
2643
|
this.stamp = stamp;
|
|
2522
2644
|
return;
|
|
@@ -2626,7 +2748,7 @@ async function exists(p) {
|
|
|
2626
2748
|
}
|
|
2627
2749
|
}
|
|
2628
2750
|
async function ensureSharedClone(repo, root) {
|
|
2629
|
-
await
|
|
2751
|
+
await mkdir22(root, { recursive: true });
|
|
2630
2752
|
const dir = path5.join(root, SHARED_DIR_NAME);
|
|
2631
2753
|
return withLock(dir, async () => {
|
|
2632
2754
|
if (!await exists(path5.join(dir, ".git"))) {
|
|
@@ -2723,7 +2845,7 @@ var WorkspaceManager = class {
|
|
|
2723
2845
|
/** Clone the shared repo once. Must be called before createForIssue. */
|
|
2724
2846
|
async init() {
|
|
2725
2847
|
if (!this.workspace.repo) throw new ConfigError("workspace.repo is required");
|
|
2726
|
-
await
|
|
2848
|
+
await mkdir3(this.workspace.root, { recursive: true });
|
|
2727
2849
|
this.shared = await ensureSharedClone(this.workspace.repo, this.workspace.root);
|
|
2728
2850
|
}
|
|
2729
2851
|
hookEnv(issue, wsPath, branch) {
|
|
@@ -2990,7 +3112,7 @@ async function runWorker(deps, ctx) {
|
|
|
2990
3112
|
const persistLog = config.agent.persist_run_log !== false;
|
|
2991
3113
|
const auditPath = path8.join(config.logs_root, issue.identifier, String(turn), "events.jsonl");
|
|
2992
3114
|
if (persistLog)
|
|
2993
|
-
await
|
|
3115
|
+
await mkdir4(path8.dirname(auditPath), { recursive: true }).catch(() => void 0);
|
|
2994
3116
|
const runOpts = {
|
|
2995
3117
|
prompt,
|
|
2996
3118
|
cwd: ws.path,
|
|
@@ -4158,7 +4280,7 @@ async function runGit(args, cwd) {
|
|
|
4158
4280
|
return stdout;
|
|
4159
4281
|
}
|
|
4160
4282
|
async function ensureGitRepo(repo) {
|
|
4161
|
-
await
|
|
4283
|
+
await mkdir5(repo, { recursive: true });
|
|
4162
4284
|
let top;
|
|
4163
4285
|
try {
|
|
4164
4286
|
top = (await runGit(["rev-parse", "--show-toplevel"], repo)).trim();
|
|
@@ -4239,8 +4361,20 @@ var SingleDirWorkspaceManager = class {
|
|
|
4239
4361
|
function dataRootOf(config) {
|
|
4240
4362
|
return config.tracker.data_root ?? path9.join(os22.homedir(), ".symphony");
|
|
4241
4363
|
}
|
|
4364
|
+
var SUN_PATH_MAX = process.platform === "darwin" ? 103 : 107;
|
|
4365
|
+
function dataRootKey(config) {
|
|
4366
|
+
return createHash2("sha256").update(path9.resolve(dataRootOf(config))).digest("hex").slice(0, 16);
|
|
4367
|
+
}
|
|
4242
4368
|
function trackerSocketPath(config) {
|
|
4243
|
-
|
|
4369
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\symphony-tracker-${dataRootKey(config)}`;
|
|
4370
|
+
const preferred = path9.join(dataRootOf(config), "tracker.sock");
|
|
4371
|
+
if (Buffer.byteLength(preferred) <= SUN_PATH_MAX) return preferred;
|
|
4372
|
+
const fallback = path9.join(os22.tmpdir(), `symphony-${dataRootKey(config)}.sock`);
|
|
4373
|
+
if (Buffer.byteLength(fallback) > SUN_PATH_MAX)
|
|
4374
|
+
throw new ConfigError(
|
|
4375
|
+
`tracker socket path too long for this platform: even the ${os22.tmpdir()} fallback exceeds ${SUN_PATH_MAX} bytes. Set a shorter $TMPDIR or tracker.data_root.`
|
|
4376
|
+
);
|
|
4377
|
+
return fallback;
|
|
4244
4378
|
}
|
|
4245
4379
|
function hasActiveProject(config) {
|
|
4246
4380
|
return config.tracker.kind !== "file" || !!config.tracker.project_id;
|
|
@@ -4317,9 +4451,40 @@ function buildMcpConfig(config) {
|
|
|
4317
4451
|
}
|
|
4318
4452
|
};
|
|
4319
4453
|
}
|
|
4454
|
+
function errno(e) {
|
|
4455
|
+
return typeof e === "object" && e !== null ? e.code : void 0;
|
|
4456
|
+
}
|
|
4457
|
+
function listen(server, socketPath) {
|
|
4458
|
+
return new Promise((resolve, reject) => {
|
|
4459
|
+
const onError = (e) => {
|
|
4460
|
+
server.removeListener("listening", onListening);
|
|
4461
|
+
reject(e);
|
|
4462
|
+
};
|
|
4463
|
+
const onListening = () => {
|
|
4464
|
+
server.removeListener("error", onError);
|
|
4465
|
+
resolve();
|
|
4466
|
+
};
|
|
4467
|
+
server.once("error", onError);
|
|
4468
|
+
server.once("listening", onListening);
|
|
4469
|
+
server.listen(socketPath);
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
function isSocketLive(socketPath) {
|
|
4473
|
+
return new Promise((resolve) => {
|
|
4474
|
+
const client = net.connect(socketPath);
|
|
4475
|
+
const settle = (live) => {
|
|
4476
|
+
client.destroy();
|
|
4477
|
+
resolve(live);
|
|
4478
|
+
};
|
|
4479
|
+
client.once("connect", () => settle(true));
|
|
4480
|
+
client.once("error", () => settle(false));
|
|
4481
|
+
});
|
|
4482
|
+
}
|
|
4320
4483
|
async function startTrackerBridge(opts) {
|
|
4321
4484
|
const { socketPath, resolveTools } = opts;
|
|
4322
|
-
|
|
4485
|
+
const isPipe = process.platform === "win32";
|
|
4486
|
+
if (!isPipe)
|
|
4487
|
+
await fs2.mkdir(path10.dirname(socketPath), { recursive: true });
|
|
4323
4488
|
const dispatch = async (line, sock) => {
|
|
4324
4489
|
let req;
|
|
4325
4490
|
try {
|
|
@@ -4355,20 +4520,33 @@ async function startTrackerBridge(opts) {
|
|
|
4355
4520
|
sock.on("error", () => {
|
|
4356
4521
|
});
|
|
4357
4522
|
});
|
|
4358
|
-
|
|
4359
|
-
|
|
4523
|
+
try {
|
|
4524
|
+
await listen(server, socketPath);
|
|
4525
|
+
} catch (e) {
|
|
4526
|
+
if (errno(e) !== "EADDRINUSE") {
|
|
4527
|
+
throw new Error(`tracker bridge: cannot listen on ${socketPath}: ${e.message}`);
|
|
4528
|
+
}
|
|
4529
|
+
if (!isPipe && !await isSocketLive(socketPath)) {
|
|
4530
|
+
await fs2.rm(socketPath, { force: true });
|
|
4531
|
+
await listen(server, socketPath);
|
|
4532
|
+
} else {
|
|
4533
|
+
throw new Error(
|
|
4534
|
+
`tracker bridge: another Symphony instance is already running on ${socketPath} (same data_root). Stop it first, or use a different tracker.data_root.`
|
|
4535
|
+
);
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4360
4538
|
return {
|
|
4361
4539
|
socketPath,
|
|
4362
4540
|
async close() {
|
|
4363
4541
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
4364
|
-
await fs2.rm(socketPath, { force: true });
|
|
4542
|
+
if (!isPipe) await fs2.rm(socketPath, { force: true });
|
|
4365
4543
|
}
|
|
4366
4544
|
};
|
|
4367
4545
|
}
|
|
4368
4546
|
function expandHome(p) {
|
|
4369
4547
|
if (!p) return null;
|
|
4370
4548
|
if (p === "~") return os3.homedir();
|
|
4371
|
-
if (p.startsWith("~/")) return
|
|
4549
|
+
if (p.startsWith("~/")) return path11.join(os3.homedir(), p.slice(2));
|
|
4372
4550
|
return p;
|
|
4373
4551
|
}
|
|
4374
4552
|
function activeProjectId(cfg) {
|
|
@@ -4503,25 +4681,35 @@ function buildDashboardSource(orchestrator, store, opts = {}) {
|
|
|
4503
4681
|
const base = slugify(input.name);
|
|
4504
4682
|
let key = base;
|
|
4505
4683
|
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
|
|
4506
|
-
await scaffoldProject({
|
|
4507
|
-
dataRoot,
|
|
4508
|
-
projectKey: key,
|
|
4509
|
-
seed: {
|
|
4510
|
-
identifier: input.identifier,
|
|
4511
|
-
states: seedStates(t.backlog_state, t.active_states, t.review_state, t.terminal_states)
|
|
4512
|
-
}
|
|
4513
|
-
});
|
|
4514
4684
|
const entry = {
|
|
4515
4685
|
name: input.name,
|
|
4516
4686
|
project_id: key,
|
|
4517
4687
|
repo: input.repo,
|
|
4518
4688
|
identifier: input.identifier
|
|
4519
4689
|
};
|
|
4520
|
-
const
|
|
4690
|
+
const mutate = (raw) => {
|
|
4521
4691
|
const list = Array.isArray(raw["projects"]) ? raw["projects"] : [];
|
|
4522
4692
|
list.push(entry);
|
|
4523
4693
|
raw["projects"] = list;
|
|
4694
|
+
};
|
|
4695
|
+
st.composeConfig(mutate);
|
|
4696
|
+
await scaffoldProject({
|
|
4697
|
+
dataRoot,
|
|
4698
|
+
projectKey: key,
|
|
4699
|
+
seed: {
|
|
4700
|
+
identifier: input.identifier,
|
|
4701
|
+
states: seedStates(t.backlog_state, t.active_states, t.review_state, t.terminal_states)
|
|
4702
|
+
}
|
|
4524
4703
|
});
|
|
4704
|
+
let snap;
|
|
4705
|
+
try {
|
|
4706
|
+
snap = await st.persist(mutate);
|
|
4707
|
+
} catch (e) {
|
|
4708
|
+
await rm2(path11.join(dataRoot, "projects", key), { recursive: true, force: true }).catch(
|
|
4709
|
+
() => void 0
|
|
4710
|
+
);
|
|
4711
|
+
throw e;
|
|
4712
|
+
}
|
|
4525
4713
|
orchestrator.applyConfig(snap.config);
|
|
4526
4714
|
return {
|
|
4527
4715
|
project_id: key,
|
|
@@ -4695,7 +4883,7 @@ function buildDashboardSource(orchestrator, store, opts = {}) {
|
|
|
4695
4883
|
if (!issue) return null;
|
|
4696
4884
|
const [activity2, comments] = supportsActivity(tracker) ? await Promise.all([tracker.fetchActivity(id), tracker.fetchComments(id)]) : [[], []];
|
|
4697
4885
|
const wcfg = orchestrator.currentConfig().workspace;
|
|
4698
|
-
const worktree = wcfg.mode === "single_dir" ? wcfg.repo ?? null :
|
|
4886
|
+
const worktree = wcfg.mode === "single_dir" ? wcfg.repo ?? null : path11.join(wcfg.root, sanitizeIdentifier(issue.identifier));
|
|
4699
4887
|
const snap = orchestrator.snapshot();
|
|
4700
4888
|
const live = snap.running.find((r) => r.issue_id === issue.id)?.tokens;
|
|
4701
4889
|
const persisted = issue.usage;
|
|
@@ -4811,19 +4999,19 @@ function buildDashboardSource(orchestrator, store, opts = {}) {
|
|
|
4811
4999
|
if (t.kind !== "file" || !t.data_root) return null;
|
|
4812
5000
|
if (projectKey.includes("/") || projectKey.includes("\\") || projectKey.includes(".."))
|
|
4813
5001
|
return null;
|
|
4814
|
-
const base =
|
|
4815
|
-
const abs =
|
|
4816
|
-
const rel =
|
|
4817
|
-
if (rel.startsWith("..") ||
|
|
5002
|
+
const base = path11.join(t.data_root, "projects", projectKey, "uploads");
|
|
5003
|
+
const abs = path11.resolve(base, rest);
|
|
5004
|
+
const rel = path11.relative(base, abs);
|
|
5005
|
+
if (rel.startsWith("..") || path11.isAbsolute(rel)) return null;
|
|
4818
5006
|
return abs;
|
|
4819
5007
|
}
|
|
4820
5008
|
};
|
|
4821
5009
|
}
|
|
4822
|
-
var CORE_VERSION = "0.1.
|
|
5010
|
+
var CORE_VERSION = "0.1.2";
|
|
4823
5011
|
|
|
4824
5012
|
// ../dashboard/dist/index.js
|
|
4825
5013
|
import { createReadStream, existsSync as existsSync3 } from "fs";
|
|
4826
|
-
import
|
|
5014
|
+
import path12 from "path";
|
|
4827
5015
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4828
5016
|
import multipart from "@fastify/multipart";
|
|
4829
5017
|
import fastifyStatic from "@fastify/static";
|
|
@@ -5106,7 +5294,11 @@ function createDashboardServer(source) {
|
|
|
5106
5294
|
if (fields["description"]) input.description = fields["description"];
|
|
5107
5295
|
if (fields["stateId"]) input.stateId = fields["stateId"];
|
|
5108
5296
|
if (fields["model"]) input.model = fields["model"];
|
|
5109
|
-
if (
|
|
5297
|
+
if (fields["effort"] !== void 0 && fields["effort"] !== "") {
|
|
5298
|
+
if (!isEffort(fields["effort"]))
|
|
5299
|
+
return reply.code(400).send({ error: { code: "invalid_effort" } });
|
|
5300
|
+
input.effort = fields["effort"];
|
|
5301
|
+
}
|
|
5110
5302
|
try {
|
|
5111
5303
|
const created = await source.createTicket(input);
|
|
5112
5304
|
return reply.code(201).send(created);
|
|
@@ -5142,7 +5334,11 @@ function createDashboardServer(source) {
|
|
|
5142
5334
|
if (b.priority === null || typeof b.priority === "number") edit.priority = b.priority;
|
|
5143
5335
|
if (Array.isArray(b.labels)) edit.labels = b.labels.filter((l) => typeof l === "string");
|
|
5144
5336
|
if (b.model === null || typeof b.model === "string") edit.model = b.model;
|
|
5145
|
-
if (b.effort
|
|
5337
|
+
if (b.effort !== void 0) {
|
|
5338
|
+
if (b.effort !== null && !isEffort(b.effort))
|
|
5339
|
+
return reply.code(400).send({ error: { code: "invalid_effort" } });
|
|
5340
|
+
edit.effort = b.effort;
|
|
5341
|
+
}
|
|
5146
5342
|
if (Object.keys(edit).length === 0) {
|
|
5147
5343
|
return reply.code(400).send({ error: { code: "empty_edit" } });
|
|
5148
5344
|
}
|
|
@@ -5239,7 +5435,15 @@ function createDashboardServer(source) {
|
|
|
5239
5435
|
|
|
5240
5436
|
`);
|
|
5241
5437
|
});
|
|
5242
|
-
|
|
5438
|
+
const ping = setInterval(() => reply.raw.write(": ping\n\n"), 2e4);
|
|
5439
|
+
let cleaned = false;
|
|
5440
|
+
const cleanup = () => {
|
|
5441
|
+
if (cleaned) return;
|
|
5442
|
+
cleaned = true;
|
|
5443
|
+
clearInterval(ping);
|
|
5444
|
+
unsubscribe();
|
|
5445
|
+
};
|
|
5446
|
+
req.raw.on("close", cleanup);
|
|
5243
5447
|
});
|
|
5244
5448
|
app.get("/api/v1/events", (req, reply) => {
|
|
5245
5449
|
reply.hijack();
|
|
@@ -5284,7 +5488,7 @@ function createDashboardServer(source) {
|
|
|
5284
5488
|
const abs = source.resolveUpload(req.params.projectKey, req.params["*"]);
|
|
5285
5489
|
if (!abs || !existsSync3(abs))
|
|
5286
5490
|
return reply.code(404).send({ error: { code: "upload_not_found" } });
|
|
5287
|
-
const type = UPLOAD_CONTENT_TYPES[
|
|
5491
|
+
const type = UPLOAD_CONTENT_TYPES[path12.extname(abs).toLowerCase()] ?? "application/octet-stream";
|
|
5288
5492
|
return reply.type(type).send(createReadStream(abs));
|
|
5289
5493
|
}
|
|
5290
5494
|
);
|
|
@@ -5321,6 +5525,8 @@ function parseArgs(argv) {
|
|
|
5321
5525
|
} else {
|
|
5322
5526
|
flags.set(key, true);
|
|
5323
5527
|
}
|
|
5528
|
+
} else if (/^-[a-zA-Z]$/.test(a)) {
|
|
5529
|
+
flags.set(a.slice(1), true);
|
|
5324
5530
|
} else {
|
|
5325
5531
|
positionals.push(a);
|
|
5326
5532
|
}
|
|
@@ -5329,10 +5535,32 @@ function parseArgs(argv) {
|
|
|
5329
5535
|
}
|
|
5330
5536
|
function workflowPath(args) {
|
|
5331
5537
|
const explicit = args.flags.get("workflow");
|
|
5332
|
-
if (typeof explicit === "string") return
|
|
5538
|
+
if (typeof explicit === "string") return path13.resolve(explicit);
|
|
5333
5539
|
const firstPositional = args.positionals[0];
|
|
5334
|
-
if (firstPositional && firstPositional.endsWith(".md")) return
|
|
5335
|
-
return
|
|
5540
|
+
if (firstPositional && firstPositional.endsWith(".md")) return path13.resolve(firstPositional);
|
|
5541
|
+
return path13.resolve("WORKFLOW.md");
|
|
5542
|
+
}
|
|
5543
|
+
async function runInit(args) {
|
|
5544
|
+
const explicit = args.flags.get("workflow");
|
|
5545
|
+
const positional = args.positionals[1];
|
|
5546
|
+
const target = path13.resolve(
|
|
5547
|
+
typeof explicit === "string" ? explicit : positional ?? "WORKFLOW.md"
|
|
5548
|
+
);
|
|
5549
|
+
if (existsSync4(target) && !args.flags.has("force")) {
|
|
5550
|
+
process.stderr.write(`symphony: ${target} already exists (use --force to overwrite)
|
|
5551
|
+
`);
|
|
5552
|
+
process.exitCode = 1;
|
|
5553
|
+
return;
|
|
5554
|
+
}
|
|
5555
|
+
await writeFile3(target, WORKFLOW_TEMPLATE, "utf8");
|
|
5556
|
+
process.stdout.write(
|
|
5557
|
+
`created ${target}
|
|
5558
|
+
|
|
5559
|
+
Next: edit it if you like, then run:
|
|
5560
|
+
symphony --port 4500
|
|
5561
|
+
Open http://127.0.0.1:4500/ and use "+ New project" to point Symphony at a git repo.
|
|
5562
|
+
`
|
|
5563
|
+
);
|
|
5336
5564
|
}
|
|
5337
5565
|
async function runTicketCreate(args) {
|
|
5338
5566
|
const title = args.positionals[2];
|
|
@@ -5343,7 +5571,17 @@ async function runTicketCreate(args) {
|
|
|
5343
5571
|
process.exitCode = 1;
|
|
5344
5572
|
return;
|
|
5345
5573
|
}
|
|
5346
|
-
const
|
|
5574
|
+
const wf = workflowPath(args);
|
|
5575
|
+
if (!existsSync4(wf)) {
|
|
5576
|
+
process.stderr.write(
|
|
5577
|
+
`symphony: no WORKFLOW.md at ${wf}
|
|
5578
|
+
Run \`symphony init\` to create one, then \`symphony --port 4500\` and create a project in the dashboard before adding tickets.
|
|
5579
|
+
`
|
|
5580
|
+
);
|
|
5581
|
+
process.exitCode = 1;
|
|
5582
|
+
return;
|
|
5583
|
+
}
|
|
5584
|
+
const { config } = await loadConfig(wf);
|
|
5347
5585
|
const tracker = buildTracker(config);
|
|
5348
5586
|
if (!supportsIssueCreation(tracker)) {
|
|
5349
5587
|
process.stderr.write(`tracker "${tracker.kind}" does not support issue creation
|
|
@@ -5367,10 +5605,16 @@ var activeLogger;
|
|
|
5367
5605
|
async function runOrchestrator(args) {
|
|
5368
5606
|
const logger = createLogger({ pretty: !args.flags.has("json-logs") });
|
|
5369
5607
|
activeLogger = logger;
|
|
5370
|
-
const
|
|
5608
|
+
const wf = workflowPath(args);
|
|
5609
|
+
const store = new WorkflowStore(wf, { logger, allowMissing: true });
|
|
5610
|
+
if (!existsSync4(wf))
|
|
5611
|
+
logger.info(
|
|
5612
|
+
{},
|
|
5613
|
+
"no WORKFLOW.md found \u2014 running with defaults; create a project in the dashboard, or run `symphony init`"
|
|
5614
|
+
);
|
|
5371
5615
|
const { config, promptBody } = await store.load();
|
|
5372
5616
|
const logsRootFlag = args.flags.get("logs-root");
|
|
5373
|
-
if (typeof logsRootFlag === "string") config.logs_root =
|
|
5617
|
+
if (typeof logsRootFlag === "string") config.logs_root = path13.resolve(logsRootFlag);
|
|
5374
5618
|
store.start();
|
|
5375
5619
|
logger.info(
|
|
5376
5620
|
{ tracker: config.tracker.kind, backend: config.agent.backend },
|
|
@@ -5413,8 +5657,10 @@ async function runOrchestrator(args) {
|
|
|
5413
5657
|
}
|
|
5414
5658
|
const portFlag = args.flags.get("port");
|
|
5415
5659
|
const port = typeof portFlag === "string" ? Number(portFlag) : config.server?.port ?? void 0;
|
|
5660
|
+
if (typeof portFlag === "string" && !(Number.isInteger(port) && port >= 0 && port <= 65535))
|
|
5661
|
+
logger.warn({ port: portFlag }, "ignoring invalid --port (expected an integer 0\u201365535)");
|
|
5416
5662
|
let dashboard;
|
|
5417
|
-
if (port !== void 0 && Number.
|
|
5663
|
+
if (port !== void 0 && Number.isInteger(port) && port >= 0 && port <= 65535) {
|
|
5418
5664
|
dashboard = await startDashboard(buildDashboardSource(orchestrator, store), {
|
|
5419
5665
|
port,
|
|
5420
5666
|
host: config.server?.host ?? "127.0.0.1",
|
|
@@ -5449,6 +5695,7 @@ async function runOrchestrator(args) {
|
|
|
5449
5695
|
var HELP = `symphony ${CORE_VERSION} \u2014 agent-agnostic coding-agent orchestrator
|
|
5450
5696
|
|
|
5451
5697
|
Usage:
|
|
5698
|
+
symphony init [path] [--force] Write a starter WORKFLOW.md
|
|
5452
5699
|
symphony [WORKFLOW.md] [--port <n>] [--json-logs] Run the orchestrator
|
|
5453
5700
|
symphony ticket create "<title>" [--desc <t>] [--state <s>] [--priority <n>]
|
|
5454
5701
|
symphony --version
|
|
@@ -5461,7 +5708,9 @@ Options:
|
|
|
5461
5708
|
--version, -v Print version
|
|
5462
5709
|
--help, -h Show this help
|
|
5463
5710
|
|
|
5464
|
-
|
|
5711
|
+
Quick start: \`symphony init\` then \`symphony --port 4500\`. With no WORKFLOW.md, the
|
|
5712
|
+
orchestrator runs with defaults and the dashboard prompts you to create a project.
|
|
5713
|
+
Agent auth uses your local \`claude\` login.
|
|
5465
5714
|
`;
|
|
5466
5715
|
async function main(argv) {
|
|
5467
5716
|
const args = parseArgs(argv);
|
|
@@ -5474,6 +5723,10 @@ async function main(argv) {
|
|
|
5474
5723
|
`);
|
|
5475
5724
|
return;
|
|
5476
5725
|
}
|
|
5726
|
+
if (args.positionals[0] === "init") {
|
|
5727
|
+
await runInit(args);
|
|
5728
|
+
return;
|
|
5729
|
+
}
|
|
5477
5730
|
if (args.positionals[0] === "ticket" && args.positionals[1] === "create") {
|
|
5478
5731
|
await runTicketCreate(args);
|
|
5479
5732
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrejvysny/symphony",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Agent-agnostic coding-agent orchestrator — delegate tickets to local coding agents (Claude Code, codex, opencode) in isolated git worktrees, driven by a local file task store.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"type": "module",
|
|
26
26
|
"bin": {
|
|
27
|
-
"symphony": "
|
|
27
|
+
"symphony": "dist/cli.js"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"dist"
|