@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 CHANGED
@@ -1,47 +1,68 @@
1
1
  # @andrejvysny/symphony
2
2
 
3
- Agent-agnostic coding-agent orchestrator delegate tickets to a **local coding agent** (Claude Code
4
- by default; Codex CLI / opencode also supported) running against a **local file-based task store**. A
5
- single-user local app: no SaaS, no Docker, no database, no auth state is plain JSON under
6
- `~/.symphony`. TypeScript reimplementation of [Symphony](https://github.com/andrejvysny/symphony-ts).
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 i -g @andrejvysny/symphony # or: pnpm add -g @andrejvysny/symphony
12
- symphony --help
11
+ npm install -g @andrejvysny/symphony
13
12
  ```
14
13
 
15
- **Prerequisites**
14
+ > Requires **Node ≥ 22**. The default agent uses your local **`claude` login** (`~/.claude`).
16
15
 
17
- - **Node 22**.
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
- ## Use
18
+ **1. (Optional) scaffold a config:**
25
19
 
26
20
  ```bash
27
- # 1. Drop a WORKFLOW.md in the current dir (see WORKFLOW.md.example), then:
28
- symphony WORKFLOW.md --port 4500 # run the orchestrator + dashboard at http://127.0.0.1:4500/
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
- # Create tickets from the CLI (or the dashboard's "+ New ticket"):
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
- | Command | What it does |
37
- | -------------------------------------------------------------- | ------------------------------------------------- |
38
- | `symphony [WORKFLOW.md] [--port <n>] [--json-logs]` | Run the orchestrator (and dashboard if `--port`). |
39
- | `symphony ticket create "<title>" [--desc --state --priority]` | Create a ticket in the active project's store. |
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
- > The dashboard has **no authentication** — keep `server.host` on loopback (`127.0.0.1`).
62
+ ## Links
43
63
 
44
- Full docs, configuration reference, and architecture: **https://github.com/andrejvysny/symphony-ts**.
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 path12 from "path";
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 mkdir2 } from "fs/promises";
37
+ import { mkdir as mkdir3 } from "fs/promises";
36
38
  import { execa } from "execa";
37
- import { access as access2, mkdir as mkdir3 } from "fs/promises";
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.writeIfAbsent(
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
- await writeFileAtomic(this.issueFile(issue.id), JSON.stringify(issue, null, 2));
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 mkdir32 } from "fs/promises";
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 mkdir4 } from "fs/promises";
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 path10 from "path";
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, logger = noopLogger, pollMs = 1e3) {
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
- /** Initial load — throws (fatal at startup) if the file is missing/invalid. */
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 raw = structuredClone(this.rawFrontMatter);
2477
- mutate(raw);
2478
- resolveConfig(parseConfig(raw), path42.dirname(path42.resolve(this.filePath)));
2479
- const content = serializeWorkflowFile(raw, this.body);
2480
- await writeFile2(path42.resolve(this.filePath), content, "utf8");
2481
- const { snapshot, stamp, raw: freshRaw, body } = await this.read();
2482
- this.current = snapshot;
2483
- this.stamp = stamp;
2484
- this.rawFrontMatter = freshRaw;
2485
- this.body = body;
2486
- return snapshot;
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 mkdir3(root, { recursive: true });
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 mkdir2(this.workspace.root, { recursive: true });
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 mkdir32(path8.dirname(auditPath), { recursive: true }).catch(() => void 0);
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 mkdir4(repo, { recursive: true });
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
- return path9.join(dataRootOf(config), "tracker.sock");
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
- await fs2.rm(socketPath, { force: true });
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
- server.listen(socketPath);
4359
- await once(server, "listening");
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 path10.join(os3.homedir(), p.slice(2));
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 snap = await st.persist((raw) => {
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 : path10.join(wcfg.root, sanitizeIdentifier(issue.identifier));
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 = path10.join(t.data_root, "projects", projectKey, "uploads");
4815
- const abs = path10.resolve(base, rest);
4816
- const rel = path10.relative(base, abs);
4817
- if (rel.startsWith("..") || path10.isAbsolute(rel)) return null;
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.0";
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 path11 from "path";
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 (isEffort(fields["effort"])) input.effort = fields["effort"];
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 === null || isEffort(b.effort)) edit.effort = 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
- req.raw.on("close", unsubscribe);
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[path11.extname(abs).toLowerCase()] ?? "application/octet-stream";
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 path12.resolve(explicit);
5538
+ if (typeof explicit === "string") return path13.resolve(explicit);
5333
5539
  const firstPositional = args.positionals[0];
5334
- if (firstPositional && firstPositional.endsWith(".md")) return path12.resolve(firstPositional);
5335
- return path12.resolve("WORKFLOW.md");
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 { config } = await loadConfig(workflowPath(args));
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 store = new WorkflowStore(workflowPath(args), logger);
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 = path12.resolve(logsRootFlag);
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.isFinite(port)) {
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
- Workflow file: see WORKFLOW.md.example. Agent auth uses your local \`claude\` login.
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.0",
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": "./dist/main.js"
27
+ "symphony": "dist/cli.js"
28
28
  },
29
29
  "files": [
30
30
  "dist"