smol-symphony 0.1.0 → 0.2.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.
Files changed (140) hide show
  1. package/AGENTS.md +105 -38
  2. package/PRODUCT.md +2 -1
  3. package/README.md +195 -98
  4. package/SPEC.md +543 -1915
  5. package/WORKFLOW.md +654 -179
  6. package/WORKFLOW.template.md +761 -121
  7. package/dist/acp-bridge.js +324 -0
  8. package/dist/acp-bridge.js.map +1 -0
  9. package/dist/actions/cache.js +191 -0
  10. package/dist/actions/cache.js.map +1 -0
  11. package/dist/actions/effects.js +41 -0
  12. package/dist/actions/effects.js.map +1 -0
  13. package/dist/actions/executor.js +570 -0
  14. package/dist/actions/executor.js.map +1 -0
  15. package/dist/actions/index.js +13 -0
  16. package/dist/actions/index.js.map +1 -0
  17. package/dist/actions/parsing.js +273 -0
  18. package/dist/actions/parsing.js.map +1 -0
  19. package/dist/actions/predicate-env.js +27 -0
  20. package/dist/actions/predicate-env.js.map +1 -0
  21. package/dist/actions/predicates.js +49 -0
  22. package/dist/actions/predicates.js.map +1 -0
  23. package/dist/actions/templating.js +66 -0
  24. package/dist/actions/templating.js.map +1 -0
  25. package/dist/actions/types.js +15 -0
  26. package/dist/actions/types.js.map +1 -0
  27. package/dist/agent/acp.js +232 -63
  28. package/dist/agent/acp.js.map +1 -1
  29. package/dist/agent/adapter-names.js +159 -0
  30. package/dist/agent/adapter-names.js.map +1 -0
  31. package/dist/agent/adapters.js +338 -102
  32. package/dist/agent/adapters.js.map +1 -1
  33. package/dist/agent/credential-extractors.js +342 -0
  34. package/dist/agent/credential-extractors.js.map +1 -0
  35. package/dist/agent/credential-secrets.js +628 -0
  36. package/dist/agent/credential-secrets.js.map +1 -0
  37. package/dist/agent/credential-ticker.js +57 -0
  38. package/dist/agent/credential-ticker.js.map +1 -0
  39. package/dist/agent/gondolin-creds-staging.js +356 -0
  40. package/dist/agent/gondolin-creds-staging.js.map +1 -0
  41. package/dist/agent/gondolin-dispatch.js +375 -0
  42. package/dist/agent/gondolin-dispatch.js.map +1 -0
  43. package/dist/agent/gondolin.js +124 -0
  44. package/dist/agent/gondolin.js.map +1 -0
  45. package/dist/agent/runner-decisions.js +134 -0
  46. package/dist/agent/runner-decisions.js.map +1 -0
  47. package/dist/agent/runner.js +1352 -290
  48. package/dist/agent/runner.js.map +1 -1
  49. package/dist/agent/tool-call-summary.js +102 -0
  50. package/dist/agent/tool-call-summary.js.map +1 -0
  51. package/dist/agent/vm-acp-mapping.js +73 -0
  52. package/dist/agent/vm-acp-mapping.js.map +1 -0
  53. package/dist/agent/vm-guards.js +262 -0
  54. package/dist/agent/vm-guards.js.map +1 -0
  55. package/dist/agent/vm-port.js +22 -0
  56. package/dist/agent/vm-port.js.map +1 -0
  57. package/dist/agent/vm-process-registry.js +79 -0
  58. package/dist/agent/vm-process-registry.js.map +1 -0
  59. package/dist/bin/cli-args.js +105 -0
  60. package/dist/bin/cli-args.js.map +1 -0
  61. package/dist/bin/symphony.js +719 -130
  62. package/dist/bin/symphony.js.map +1 -1
  63. package/dist/errors.js +15 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/http-disk.js +135 -0
  66. package/dist/http-disk.js.map +1 -0
  67. package/dist/http-handlers.js +180 -0
  68. package/dist/http-handlers.js.map +1 -0
  69. package/dist/http.js +1476 -764
  70. package/dist/http.js.map +1 -1
  71. package/dist/issues.js +178 -0
  72. package/dist/issues.js.map +1 -0
  73. package/dist/logging.js +163 -5
  74. package/dist/logging.js.map +1 -1
  75. package/dist/mcp.js +391 -163
  76. package/dist/mcp.js.map +1 -1
  77. package/dist/memory.js +85 -0
  78. package/dist/memory.js.map +1 -0
  79. package/dist/orchestrator-decisions.js +331 -0
  80. package/dist/orchestrator-decisions.js.map +1 -0
  81. package/dist/orchestrator.js +1189 -303
  82. package/dist/orchestrator.js.map +1 -1
  83. package/dist/prompt.js +5 -5
  84. package/dist/prompt.js.map +1 -1
  85. package/dist/reconciler/cache.js +65 -0
  86. package/dist/reconciler/cache.js.map +1 -0
  87. package/dist/reconciler/index.js +448 -0
  88. package/dist/reconciler/index.js.map +1 -0
  89. package/dist/reconciler/ledger.js +131 -0
  90. package/dist/reconciler/ledger.js.map +1 -0
  91. package/dist/reconciler/pr-adapters.js +174 -0
  92. package/dist/reconciler/pr-adapters.js.map +1 -0
  93. package/dist/reconciler/pr-decide.js +167 -0
  94. package/dist/reconciler/pr-decide.js.map +1 -0
  95. package/dist/reconciler/pr.js +422 -0
  96. package/dist/reconciler/pr.js.map +1 -0
  97. package/dist/reconciler/types.js +12 -0
  98. package/dist/reconciler/types.js.map +1 -0
  99. package/dist/reconciler/vm.js +243 -0
  100. package/dist/reconciler/vm.js.map +1 -0
  101. package/dist/reconciler/workspace-defaults.js +83 -0
  102. package/dist/reconciler/workspace-defaults.js.map +1 -0
  103. package/dist/reconciler/workspace.js +272 -0
  104. package/dist/reconciler/workspace.js.map +1 -0
  105. package/dist/runlog.js +403 -0
  106. package/dist/runlog.js.map +1 -0
  107. package/dist/scaffold.js +165 -0
  108. package/dist/scaffold.js.map +1 -0
  109. package/dist/trackers/local.js +234 -133
  110. package/dist/trackers/local.js.map +1 -1
  111. package/dist/trackers/types.js +1 -1
  112. package/dist/trackers/types.js.map +1 -1
  113. package/dist/types.js +1 -1
  114. package/dist/util/clock.js +12 -0
  115. package/dist/util/clock.js.map +1 -0
  116. package/dist/util/crypto.js +25 -0
  117. package/dist/util/crypto.js.map +1 -0
  118. package/dist/util/frontmatter.js +70 -0
  119. package/dist/util/frontmatter.js.map +1 -0
  120. package/dist/util/fs-issues.js +22 -0
  121. package/dist/util/fs-issues.js.map +1 -0
  122. package/dist/util/process.js +152 -0
  123. package/dist/util/process.js.map +1 -0
  124. package/dist/util/workspace-key.js +10 -0
  125. package/dist/util/workspace-key.js.map +1 -0
  126. package/dist/workflow-loader.js +147 -0
  127. package/dist/workflow-loader.js.map +1 -0
  128. package/dist/workflow.js +656 -219
  129. package/dist/workflow.js.map +1 -1
  130. package/dist/workspace-types.js +8 -0
  131. package/dist/workspace-types.js.map +1 -0
  132. package/dist/workspace.js +367 -120
  133. package/dist/workspace.js.map +1 -1
  134. package/package.json +14 -6
  135. package/scripts/vm-agent.mjs +211 -0
  136. package/dist/agent/codex.js +0 -439
  137. package/dist/agent/codex.js.map +0 -1
  138. package/dist/agent/smolvm.js +0 -174
  139. package/dist/agent/smolvm.js.map +0 -1
  140. package/scripts/build-vm.sh +0 -67
@@ -22,39 +22,385 @@ Notation:
22
22
  # tracker — where issues come from.
23
23
  # ─────────────────────────────────────────────────────────────────────────────
24
24
  tracker:
25
- # kind (required): 'local' or 'linear'.
26
- # local — markdown files under `root`, one per issue, organized by state.
27
- # linear — Linear API (workspace + project_slug).
25
+ # kind (required): currently the only supported value is 'local' (markdown
26
+ # files under `root`, one per issue, organized by state subdirectory).
28
27
  kind: local
29
28
 
30
- # root (path): local-tracker only. Directory containing `<state>/<id>.md`
31
- # files. Required when kind=local. Resolved relative to the workflow file
32
- # if not absolute.
29
+ # root (path): directory containing `<state>/<id>.md` files. Required.
30
+ # Resolved relative to the workflow file if not absolute.
33
31
  root: ./issues
34
32
 
35
- # endpoint (string): linear-tracker only. API endpoint.
36
- # Default: Linear's public GraphQL endpoint.
37
- endpoint: https://api.linear.app/graphql
38
-
39
- # api_key (string): linear-tracker only. Personal API key. Required for
40
- # kind=linear. Pulled from $LINEAR_API_KEY if you use `$LINEAR_API_KEY`.
41
- api_key: $LINEAR_API_KEY
42
-
43
- # project_slug (string): linear-tracker only. Required for kind=linear.
44
- project_slug: my-team/my-project
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+ # states per-state configuration map. REQUIRED. Every workflow must declare
35
+ # at least one `active`, one `terminal`, and one `holding` state; a workflow
36
+ # missing the `states:` block (or missing any of those roles) is rejected at
37
+ # parse time. This map is the only place state names and roles are configured;
38
+ # there are no separate active/terminal lists to keep in sync.
39
+ #
40
+ # Keys are state names; values are config objects with these fields:
41
+ # role (required, enum):
42
+ # active — orchestrator dispatches issues in this state.
43
+ # terminal — orchestrator treats issues in this state as complete; the
44
+ # workspace is removed after the run unwinds.
45
+ # holding — directory exists on disk, but the orchestrator never
46
+ # dispatches issues from it. Triage is the canonical example
47
+ # and the landing directory for `symphony.propose_issue`.
48
+ # adapter (string, optional): override the workflow-level `acp.adapter` for
49
+ # agents dispatched in this state. Must be a known profile (claude,
50
+ # codex, opencode). All use host-side credential substitution at
51
+ # Gondolin egress and are startup-probed so a missing credential
52
+ # fails fast. claude has a
53
+ # single host credential file (~/.claude/.credentials.json) that is
54
+ # probed for readability; codex passes when either ~/.codex/auth.json
55
+ # holds a token (ChatGPT-OAuth tokens.access_token or a top-level
56
+ # OPENAI_API_KEY) or the host OPENAI_API_KEY env var is set; opencode
57
+ # passes when either ~/.local/share/opencode/auth.json holds a
58
+ # github-copilot token (run `opencode auth login` -> GitHub Copilot
59
+ # on the host) or a COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN env
60
+ # var is set.
61
+ # model (string, optional): override `acp.model` for this state.
62
+ # Blank or whitespace-only values normalize to "use the adapter
63
+ # default" (same as the workflow-level acp.model semantics).
64
+ # effort (string, optional): override `acp.effort` for this state. Same
65
+ # undefined-vs-null semantics as `model`: omit to inherit
66
+ # `acp.effort`; blank/whitespace normalizes to null ("use the
67
+ # adapter default for this state"). Valid values are adapter- and
68
+ # model-specific (see `acp.effort`).
69
+ # max_turns (int, optional): override `agent.max_turns` for this state.
70
+ # max_concurrent (int, optional): cap on agents the orchestrator runs
71
+ # simultaneously for issues in THIS state. Symmetric with
72
+ # `max_turns` — concurrency and turn budget both live on the state.
73
+ # Omit for "no per-state cap; only the global
74
+ # `agent.max_concurrent_agents` ceiling applies". The sum of every
75
+ # state's `max_concurrent` must not exceed that global ceiling
76
+ # (validated at startup).
77
+ # allowed_transitions (string[]|null, optional): when set, restricts which
78
+ # states agents in this state may transition to via the MCP
79
+ # `transition` tool. Each entry must be a declared state. Omit (or
80
+ # explicitly set to null) for "any declared state is reachable".
81
+ # An empty list (`allowed_transitions: []`) means "no transitions
82
+ # allowed out of this state" — the agent's `transition` calls will
83
+ # always be rejected with `transition_not_allowed`. Useful for
84
+ # review-style states that should pause until a human re-routes.
85
+ # eval_mode (bool, optional): when true, the runner adds two extra read-only
86
+ # bind mounts to every per-issue VM dispatched in this state so an
87
+ # in-VM agent can inspect symphony's own state for evaluation /
88
+ # debugging:
89
+ # • `tracker.root` → `/symphony/issues` (every issue file across
90
+ # every state directory)
91
+ # • `logs.root` → `/symphony/logs` (per-issue JSONL run-log
92
+ # transcripts captured by RunLog — ACP frames, stderr,
93
+ # typed-action output, system events — plus the compact
94
+ # `<key>.summary.json` outcome records the reflector reads; see
95
+ # the `logs:` block below)
96
+ # Either mount is skipped silently if the corresponding root is
97
+ # unset. Each VFS mount has a cost, so this is opt-in per state rather
98
+ # than a workflow-wide default — flip it on for a dedicated eval
99
+ # state, not for the routine implement/review flow. Default: false.
100
+ # The canonical consumer is the "sleep cycle" reflection pattern —
101
+ # see the SLEEP CYCLE section below the states block.
102
+ # actions (list, optional): typed action DAG (issue 36, reconciler v2).
103
+ # The single glue mechanism attached to a state — there is no shell
104
+ # `hooks:` surface. When set on a `terminal` state, this list runs
105
+ # on transition INTO the state (the canonical Done-state pair is
106
+ # push_branch + create_pr_if_missing). For arbitrary in-sandbox
107
+ # commands use a `run_in_vm` action; per-VM tooling belongs in the
108
+ # agent image (`images/agents/`); first-creation workspace setup is
109
+ # owned by the orchestrator's `setupWorkspaceDir`.
110
+ # Each entry is a closed-kind record:
111
+ #
112
+ # - kind: push_branch
113
+ # remote: origin
114
+ # ref: $branch
115
+ # if: $repo
116
+ #
117
+ # Recognized kinds:
118
+ # push_branch { remote, ref }
119
+ # create_pr_if_missing { base, head, title_from, body_from }
120
+ # ensure_branch { name, seed_from? }
121
+ # checkout { ref }
122
+ # merge { source, target, on_conflict }
123
+ # delete_branch { name, scope: local|remote|both, remote? }
124
+ # run_in_vm { name, cmd: [...], env?, timeout? }
125
+ # propose_followup { title, body?, labels?, priority? }
126
+ #
127
+ # Templating: `$varname` references the fixed ActionContext
128
+ # namespace ($identifier, $workspace, $branch, $base_branch,
129
+ # $issue_title, $issue_body, $repo, $pr_title, $pr_body_file).
130
+ # Unknown $vars throw at run-time (no silent "" expansion).
131
+ #
132
+ # Conditional: optional `if:` field supports three predicates
133
+ # - `if: $repo` (env-var-truthy)
134
+ # - `if: { branch_exists: <ref> }` (workspace branch)
135
+ # - `if: { file_present: <path> }` (workspace file)
136
+ #
137
+ # Retry: optional `on_error.retry: { count, backoff_ms }`. Default
138
+ # policy is 3 retries with exponential backoff starting at 1s,
139
+ # then abort. `on_error.then: { route_to: <state> }` reroutes the
140
+ # issue to a holding state instead of aborting.
141
+ #
142
+ # merge's `on_conflict: { route_to: <state> }` is a fast-path
143
+ # reroute. Use `on_conflict: abort` to fail the action and abort
144
+ # the cleanup pass without a state move.
145
+ #
146
+ # run_in_vm has content-hash caching: identical (workspace tree
147
+ # ⊕ cmd ⊕ env) tuples skip execution and re-use the prior
148
+ # successful result. The workspace-tree hash reflects live
149
+ # contents (tracked + modified + untracked-not-gitignored), so
150
+ # an uncommitted agent edit forces a cache miss. Cache lives
151
+ # under `~/.cache/symphony/actions/run_in_vm/<name>/<sha256>/`.
152
+ # `symphony rerun --check=<name>` drops the whole `<name>/`
153
+ # namespace dir so the next dispatch re-runs that one check.
154
+ # pr (map, optional): PR autopilot routing for this state (issue 139).
155
+ # Valid only on a `terminal` state, and only acts when the
156
+ # top-level `pr:` engine block (below) has `enabled: true`. This is
157
+ # where the routing the engine used to name by string now lives —
158
+ # on the state it describes. Two shapes:
159
+ # • Merge state: pr: { auto_merge: squash|merge|rebase,
160
+ # on_conflict: { route_to: <active state> } }
161
+ # A MERGEABLE PR for an issue in this state has GitHub
162
+ # auto-merge armed with `auto_merge`; a CONFLICTING one is
163
+ # routed back to `on_conflict.route_to` (defaults to the first
164
+ # declared active state when omitted) for the dispatched agent
165
+ # to rebase. While the engine is enabled, transitions INTO this
166
+ # state do not fire the standard terminal workspace cleanup —
167
+ # the pr resource owns the workspace until the PR merges/closes.
168
+ # • Close state: pr: { close: true }
169
+ # An open PR for an issue in this state is closed without merge
170
+ # and its remote branch is best-effort-deleted. Standard
171
+ # terminal cleanup still runs (the close path needs no workspace).
172
+ # The merge/close/route targets are DERIVED by scanning states for
173
+ # this field — there is no named-string sibling block. At most one
174
+ # terminal state may declare `auto_merge`, and at most one may
175
+ # declare `close`; an `on_conflict.route_to` naming an undeclared
176
+ # state is rejected at parse time.
177
+ #
178
+ # Declaration order matters: role-filtered listings (active states, terminal
179
+ # states) follow it, and the dashboard renders state columns in the same order.
180
+ # ─────────────────────────────────────────────────────────────────────────────
181
+ states:
182
+ Todo:
183
+ role: active
184
+ adapter: claude
185
+ model: claude-opus-4-7
186
+ effort: xhigh
187
+ max_turns: 10
188
+ max_concurrent: 1 # at most one implementer agent at a time
189
+ Review:
190
+ role: active
191
+ adapter: codex
192
+ model: gpt-5-codex
193
+ max_turns: 4
194
+ allowed_transitions: [Todo, Done]
195
+ Done:
196
+ role: terminal
197
+ # PR autopilot merge state (issue 139). Acts only when the top-level `pr:`
198
+ # engine block (below) is enabled. Derived as the merge state because it
199
+ # declares `auto_merge`.
200
+ # pr:
201
+ # auto_merge: squash
202
+ # on_conflict: { route_to: Todo }
203
+ # Terminal-state handoff via typed actions (issue 36). On transition into
204
+ # Done, push the branch (if SYMPHONY_REPO is set → $repo non-empty) and
205
+ # open a PR if one does not already exist. Templates resolve against the
206
+ # fixed ActionContext namespace; the orchestrator stages $pr_title and
207
+ # $pr_body_file from the issue file before firing.
208
+ # actions:
209
+ # - { kind: push_branch, remote: origin, ref: $branch, if: $repo }
210
+ # - kind: create_pr_if_missing
211
+ # base: $base_branch
212
+ # head: $branch
213
+ # title_from: $pr_title
214
+ # body_from: $pr_body_file
215
+ # if: $repo
216
+ Cancelled:
217
+ role: terminal
218
+ # PR autopilot close state (issue 139). Derived as the close state because
219
+ # it declares `close: true` — an open PR is closed without merge.
220
+ # pr:
221
+ # close: true
222
+ Triage:
223
+ role: holding
45
224
 
46
- # active_states (string[]): states the orchestrator dispatches against.
47
- # Default: [Todo, In Progress]
48
- active_states:
49
- - Todo
50
- - In Progress
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+ # SLEEP CYCLE a reflection state that mines finished work for harness
227
+ # improvements (issue 122). Optional, opt-in pattern; layered on top of the
228
+ # states block above. The shipped smol-symphony WORKFLOW.md wires it for the
229
+ # dogfooding (symphony-on-symphony) setup.
230
+ #
231
+ # The idea: every dispatch starts from the same static prompt + config, no
232
+ # matter what the last 100 issues taught us about where agents stall, get
233
+ # rejected, burn their turn budget, or fight the harness. A periodic
234
+ # "reflection" turn closes that feedback loop — it reads completed-task history
235
+ # (the read-only mounts `eval_mode` exposes), distils *recurring* friction, and
236
+ # files improvement proposals against the HARNESS (this WORKFLOW.md's prompt
237
+ # branches and per-state model/max_turns/allowed_transitions/effort/actions, the
238
+ # gondolin image config, acceptance criteria, timeouts) — never the product code under
239
+ # review. Proposals land in Triage via `propose_issue`, so a human stays the
240
+ # gate. This is the "self-improving agent" pattern aimed at the harness rather
241
+ # than the product.
242
+ #
243
+ # Two states implement it:
244
+ #
245
+ # Reflect (role: active, eval_mode: true):
246
+ # - eval_mode binds /symphony/issues (all state dirs, incl. the Done/*.md
247
+ # handoff transcripts) + /symphony/logs (per-issue JSONL run logs)
248
+ # read-only into the VM. No extra mount plumbing — it reuses the existing
249
+ # eval_mode mounts.
250
+ # - Give it a capable adapter/model (large context helps: a reflection turn
251
+ # reads many transcripts + logs) and a higher max_turns than your
252
+ # implement/review states.
253
+ # - allowed_transitions: [Dormant] — the reflector may ONLY go dormant. It
254
+ # cannot route itself into the implement/review/done flow. Filing
255
+ # improvements goes through propose_issue (→ Triage), which is independent
256
+ # of allowed_transitions.
257
+ # - The prompt body's `when "Reflect"` branch encodes the
258
+ # read → distil → propose loop and the GUARDRAILS below.
259
+ #
260
+ # Dormant (role: holding):
261
+ # - Resting place for the single recurring "Sleep cycle" issue between runs.
262
+ # Holding → never dispatched. Declare it AFTER your Triage state so Triage
263
+ # stays the first holding state (the `propose_issue` landing + triage
264
+ # approve/discard target both resolve the FIRST declared holding state).
265
+ # - Dashboard caveat: the dashboard currently renders triage approve/discard
266
+ # buttons on every holding row, and the tracker resolves a move by issue
267
+ # id regardless of source directory — so clicking those buttons on a
268
+ # Dormant issue would mis-route it. Re-arm via cron/CLI/filesystem, not the
269
+ # dashboard buttons.
270
+ #
271
+ # GUARDRAILS (this is a self-modifying loop — keep the human in it):
272
+ # - Output is proposals into Triage (holding, never auto-dispatched). The
273
+ # operator approves/discards. Do not bypass this gate.
274
+ # - Constrain the proposal surface to harness config. Forbid any proposal that
275
+ # weakens the Review state, the test/lint gates, or the Triage gate itself.
276
+ # - Each proposal must cite the issue ids that motivated it, so the operator
277
+ # checks the lesson against the evidence rather than the reflector's summary.
278
+ #
279
+ # CADENCE (v1 — operator/scheduled-triggered, no orchestrator trigger logic):
280
+ # A single recurring issue (e.g. titled "Sleep cycle") oscillates Reflect ↔
281
+ # Dormant. The operator drops it into Reflect (dashboard, or `mv` on disk), or
282
+ # an external cron / a `symphony reflect` verb arms it. After it files
283
+ # proposals it transitions to Dormant and waits to be re-armed. Auto-arm on
284
+ # idle (no active issues) or after N transitions into Done is a deliberate
285
+ # follow-up, out of scope for v1.
286
+ #
287
+ # Example states to add (names are yours to choose):
288
+ #
289
+ # states:
290
+ # # ... your active/terminal states ...
291
+ # Reflect:
292
+ # role: active
293
+ # adapter: claude
294
+ # model: claude-opus-4-8[1m] # large context for reading transcripts
295
+ # max_turns: 20 # higher than implement/review
296
+ # eval_mode: true
297
+ # allowed_transitions: [Dormant]
298
+ # Triage:
299
+ # role: holding # declared before Dormant
300
+ # Dormant:
301
+ # role: holding
302
+ # ─────────────────────────────────────────────────────────────────────────────
51
303
 
52
- # terminal_states (string[]): states the orchestrator treats as complete;
53
- # mark_done moves issues to the first listed state.
54
- # Default: [Done]
55
- terminal_states:
56
- - Done
57
- - Cancelled
304
+ # ─────────────────────────────────────────────────────────────────────────────
305
+ # pr PR autopilot engine toggle: arm GitHub auto-merge when a terminal-state
306
+ # PR is mergeable; route non-mergeable PRs back to the implementing state.
307
+ #
308
+ # Optional. This is the SLIM host-global half only — the on/off switch and the
309
+ # per-PR `gh pr view` cache TTL. The merge/close/route targets and the
310
+ # auto-merge strategy live ON the terminal states they describe, as
311
+ # `states.<name>.pr` (see the `pr:` field doc in the states block above):
312
+ # • the MERGE state declares `pr: { auto_merge: <strategy>,
313
+ # on_conflict: { route_to: <active state> } }`
314
+ # • the CLOSE state declares `pr: { close: true }`
315
+ # The reconciler derives the targets by scanning states for that field — there
316
+ # is no named-string sibling block here.
317
+ #
318
+ # When `enabled: true` the reconciler grows a `pr` resource that, on every tick,
319
+ # looks up each issue in the merge state via `gh pr list --head agent/<id>`,
320
+ # fetches its detail with `gh pr view`, and:
321
+ #
322
+ # • Arms GitHub's auto-merge when the PR is `mergeable: MERGEABLE`
323
+ # (`gh pr merge --auto --<strategy> --delete-branch`, where `<strategy>` is
324
+ # the merge state's `pr.auto_merge`). GitHub merges as soon as required
325
+ # checks pass and review requirements are satisfied.
326
+ # • When the PR is `mergeable: CONFLICTING`, appends a structured notes block
327
+ # to the issue file and routes the issue from the merge state back to that
328
+ # state's `pr.on_conflict.route_to` (default: the first declared
329
+ # `role: active` state). The workspace + `agent/<id>` branch are preserved.
330
+ # Before the next dispatch symphony runs `git fetch origin <base>` so
331
+ # `origin/<base>` is current, and the Todo prompt's first step is
332
+ # `git rebase origin/<base>` — resolving the conflict is the agent's normal
333
+ # flow, not an out-of-band autopilot operation.
334
+ # • For an issue in the close state with an open PR, closes the PR without
335
+ # merge and best-effort-deletes the remote branch.
336
+ #
337
+ # Requires `gh` authenticated on the host (`gh auth status` clean). The token
338
+ # never enters the VM. Auto-merge ALSO requires at least one branch protection
339
+ # rule on the base branch, or `gh pr merge --auto` will error — set one in
340
+ # the repo's GitHub settings before flipping `enabled: true`.
341
+ #
342
+ # When `enabled: false` (or the block is absent) the autopilot is fully inert:
343
+ # the resource is never constructed and the orchestrator's existing Done-state
344
+ # behavior (workspace cleanup + the Done-state `actions:` block that pushes the
345
+ # branch and opens the PR + operator-merge) is unchanged.
346
+ #
347
+ # Workspace lifecycle gotcha: when `enabled: true`, transitions into the merge
348
+ # state no longer fire the standard terminal workspace cleanup. The pr resource
349
+ # owns the workspace from that point on and removes it once the PR has merged
350
+ # (or been closed). Transitions into the close state (and any other terminal
351
+ # state) keep the standard cleanup-on-transition behavior.
352
+ # ─────────────────────────────────────────────────────────────────────────────
353
+ pr:
354
+ # enabled (bool): master switch. Default false.
355
+ enabled: false
356
+
357
+ # poll_interval_ms (int): per-PR GitHub view cache TTL, milliseconds. The
358
+ # reconciler may tick more often than this; a single PR view is reused
359
+ # within the window. Default 30000.
360
+ poll_interval_ms: 30000
361
+ # ----------------------------------------------------------------------------
362
+ # SLEEP CYCLE (auto-arm) — issue 140 moved this trigger ONTO the active state it
363
+ # arms. There is NO top-level `sleep_cycle:` block anymore; declare an `arm:`
364
+ # block on the active state (e.g. Reflect) in the `states:` map above:
365
+ #
366
+ # states:
367
+ # Reflect:
368
+ # role: active
369
+ # arm:
370
+ # issue: sleep-cycle # the recurring issue to auto-enter this state
371
+ # from: Dormant # the holding state it rests in between runs
372
+ # on_idle: true # arm when idle with >=1 terminal since last run
373
+ # after_terminal: 10 # arm after N terminal transitions (0 disables)
374
+ #
375
+ # SEMANTICS (unchanged from the old sleep_cycle block):
376
+ # - on_idle: arm when the orchestrator is idle (nothing running, claimed, or
377
+ # pending retry, and no active candidate this poll) AND >=1 issue has reached
378
+ # a terminal state since the last run. The ">=1 since last run" gate is
379
+ # load-bearing: without it an idle orchestrator re-arms in a tight loop with
380
+ # nothing new to mine.
381
+ # - after_terminal: a backstop for busy stretches that never go idle — arm once
382
+ # this many issues have reached a terminal state (Done/Cancelled) since the
383
+ # last run. 0 disables the count trigger.
384
+ # The terminal-transition counter resets to 0 the moment the issue is armed and
385
+ # is held in orchestrator memory only (a restart resets it).
386
+ #
387
+ # VALIDATION (structural, from each state's own role — no dedicated re-validator):
388
+ # `arm:` is only valid on an `active` state, `arm.from` must be a declared
389
+ # `holding` state, `arm.issue` is required, and at most one active state may
390
+ # declare `arm`.
391
+ #
392
+ # GUARDRAILS: arming ONLY moves the issue into the armed state. The proposals the
393
+ # reflector files still land in Triage and still require human approve/discard —
394
+ # arming does not bypass the human gate. Requires a single issue with id
395
+ # `arm.issue` resting in `arm.from` (created by the operator); the trigger is
396
+ # inert until that issue exists.
397
+ #
398
+ # MIGRATION: a deprecated top-level `sleep_cycle:` block is still parsed for one
399
+ # release and folded onto states.<reflect_state>.arm (dormant_state -> from,
400
+ # reflect_state -> the armed state, issue_id -> issue, arm_on_idle -> on_idle,
401
+ # arm_after_done -> after_terminal) with a startup deprecation warning. Prefer
402
+ # the per-state `arm:` block.
403
+ # ----------------------------------------------------------------------------
58
404
 
59
405
  # ─────────────────────────────────────────────────────────────────────────────
60
406
  # polling — how often to poll the tracker.
@@ -72,59 +418,156 @@ workspace:
72
418
  # Default: $TMPDIR/symphony_workspaces
73
419
  root: ./.symphony/workspaces
74
420
 
421
+ # github_repo (string | null): the GitHub `owner/repo` slug symphony pushes the
422
+ # per-issue `agent/<id>` branch to and opens a PR against on the Done-state
423
+ # transition. This is the config-file home for what was previously the
424
+ # `SYMPHONY_REPO` env var only.
425
+ # • unset / null / "none" → LOCAL-ONLY mode: no `origin` is restored, nothing
426
+ # is pushed, and the branch is left in the workspace for `git log agent/<id>`.
427
+ # • "owner/repo" → PR mode: the canonical workspace setup restores an
428
+ # `origin` at https://github.com/<owner>/<repo>.git and the Done-state
429
+ # actions push + `gh pr create`. Requires `gh auth status` clean on the host
430
+ # (the token never enters the VM).
431
+ # GitHub-only by construction (the origin URL and `gh` are hardwired). There is
432
+ # NO auto-detection — set the literal slug or leave it local-only. A malformed
433
+ # value (a URL, a bare name, extra slashes/whitespace) is REJECTED at parse time
434
+ # rather than silently falling back to local-only.
435
+ #
436
+ # Precedence: the `SYMPHONY_REPO` env var, when set non-empty, OVERRIDES this
437
+ # field (so existing exports keep working); otherwise this value is used.
438
+ # Default: null.
439
+ # github_repo: owner/repo
440
+
441
+ # base_branch (string): the branch the per-issue workspace clones from and
442
+ # (in PR mode) targets as the PR base. The source repo's local
443
+ # `<base_branch>` is the canonical base ref — symphony does NOT implicitly
444
+ # fetch `origin/<base_branch>` at setup time; update the source repo before
445
+ # the next dispatch to move the base.
446
+ # Precedence: the `SYMPHONY_BASE_BRANCH` env var, when set non-empty,
447
+ # OVERRIDES this field; otherwise this value is used.
448
+ # Default: main.
449
+ base_branch: main
450
+
75
451
  # ─────────────────────────────────────────────────────────────────────────────
76
- # hooksshell scripts the orchestrator runs at workspace lifecycle points.
452
+ # logsper-issue JSONL run logs (everything to/from the VM, plus typed-action
453
+ # output) AND the orchestrator-side text log mirrored to disk for offline debugging.
454
+ #
455
+ # Per-issue: one file per issue at `<root>/<sanitized-identifier>.jsonl`,
456
+ # appended across attempts AND across symphony process restarts. Each line is
457
+ # a self-describing JSON object with `ts`, `issue_id`, `attempt`, and a
458
+ # `channel` discriminator:
459
+ #
460
+ # channel: "acp" — JSON-RPC frame between host and the in-VM adapter.
461
+ # `direction` ("host_to_vm" | "vm_to_host") and `frame`
462
+ # (parsed JSON) — or `kind: "unparseable"` + `raw`.
463
+ # channel: "stderr" — raw byte chunk from the adapter / VM stderr.
464
+ # channel: "hook" — stdout/stderr chunk from a terminal-state `actions:`
465
+ # run, plus a final `kind: "result"` line (exit_code,
466
+ # signal, timed_out). The `hook` field names the action
467
+ # group (e.g. `actions`). (The channel name is a holdover
468
+ # from the retired workflow-hook surface.)
469
+ # channel: "system" — orchestrator lifecycle events (attempt_started — which
470
+ # also carries the per-state `max_turns` budget,
471
+ # attempt_ended, transition, reconciliation_terminating,
472
+ # etc.). The `transition` event records each state move
473
+ # (from_state, to_state, notes, actor, terminal,
474
+ # rerouted) so the trajectory is reconstructable.
475
+ #
476
+ # Per-issue run summary (for the sleep-cycle reflector): alongside each
477
+ # `<root>/<key>.jsonl`, the orchestrator writes a compact, versioned
478
+ # `<root>/<key>.summary.json` at the issue's terminal unwind. It is a pure
479
+ # REDUCTION over the lifecycle (`system`) events already in the JSONL — no extra
480
+ # hot-path logging — so a reflection turn can read dozens of summaries without
481
+ # re-parsing multi-MB frame logs. Fields (schema_version 1):
482
+ # • state_path — distinct states visited, terminal appended
483
+ # (e.g. ["Todo","Review","Todo","Review","Done"]);
484
+ # • attempts — total dispatched attempts;
485
+ # • per_state[] — {state, attempts, turns_used, max_turns,
486
+ # budget_exhausted, wall_clock_ms};
487
+ # • review_rejections + rejection_notes[] — count and each reviewer kick-back's
488
+ # notes (a non-reroute transition back to the INITIAL
489
+ # implementing state, i.e. a Review→Todo rework);
490
+ # • turn_budget_exhausted, timeouts[] (stall / prompt_timeout / transport);
491
+ # • conflict_routes[] — PR-autopilot / action reroutes (rebase churn);
492
+ # • terminal_state + terminal_outcome (completed | cancelled | incomplete);
493
+ # • pr_number / pr_url (best-effort, scraped from the Done-state actions
494
+ # stdout; null when unavailable);
495
+ # • first/last_event_at, wall_clock_ms_total, generated_at.
496
+ # Graceful absence / backfill: the summary is best-effort. Issues that closed
497
+ # BEFORE this feature shipped have no `*.summary.json`; a write failure or a
498
+ # mid-issue process restart (the in-memory accumulator only sees post-restart
499
+ # attempts) can leave it missing or partial. The reflector MUST treat an absent
500
+ # or partial summary as "no signal for this issue" and fall back to the raw
501
+ # JSONL (or skip the issue) — never as an error. No backfill job is run; old
502
+ # issues simply carry no summary.
77
503
  #
78
- # All hooks run on the HOST (not inside the VM), with cwd set to the per-issue
79
- # workspace path. Each hook is a multi-line shell snippet. Available env vars:
504
+ # Orchestrator-side: a single `<root>/symphony.log` (created on demand) gets
505
+ # every structured log line symphony emits workflow loads, dispatch
506
+ # decisions, action results, reconciler ticks, shutdown — in `key=value` text
507
+ # format. Lets an agent reviewing a finished run (typically with
508
+ # `.symphony/logs/` mounted into a VM) replay orchestrator-side events
509
+ # alongside the per-issue JSONL traces in the same directory. Set the
510
+ # `SYMPHONY_LOG_FILE` env var to override the path; set it to the empty
511
+ # string to disable the file sink entirely (stderr remains).
80
512
  #
81
- # PWD — the workspace directory (cwd at hook start).
82
- # SYMPHONY_ISSUE_ID — the issue identifier.
83
- # SYMPHONY_ISSUE_STATE the issue's current state.
84
- # SYMPHONY_ATTEMPT — 1-based attempt counter.
85
- # SYMPHONY_WORKFLOW — absolute path to the workflow file.
513
+ # Console routing: while the file sink is active (the default), the structured
514
+ # stream goes to the file ONLY — the console shows just the startup banner
515
+ # (workflow, tracker root, dashboard URL, log-file path). `tail -f` the log
516
+ # file to follow the detail. Pass `--verbose` (alias `--foreground`) to mirror
517
+ # the structured stream back onto the console for interactive debugging. With
518
+ # no file sink configured, the structured stream stays on stderr.
86
519
  #
87
- # Plus any env var the operator exports before launching `symphony`. The
88
- # common pattern is to plumb tracker root / repo / base via env so the same
89
- # workflow file works against multiple repos. See WORKFLOW.md for an example.
520
+ # Intended for later evaluation typically by another agent running inside a VM
521
+ # so the schema is verbose on purpose. Writes are best-effort: a failure to
522
+ # write a log line never crashes the orchestrator.
523
+ # ─────────────────────────────────────────────────────────────────────────────
524
+ logs:
525
+ # root (path): directory holding per-issue JSONL files and symphony.log.
526
+ # Default: ./.symphony/logs
527
+ root: ./.symphony/logs
528
+
529
+ # ─────────────────────────────────────────────────────────────────────────────
530
+ # workspace lifecycle — no shell `hooks:` surface.
531
+ #
532
+ # Symphony has no `hooks:` block (workflow-level or per-state). The behaviors
533
+ # the old `after_create` / `before_run` / `after_run` / `before_remove` hooks
534
+ # covered now live in typed, testable homes:
535
+ #
536
+ # • First-creation workspace setup (clone + base checkout + `agent/<id>`
537
+ # branch cut + origin/identity) is owned by the orchestrator's TypeScript
538
+ # `setupWorkspaceDir`. The workspace arrives at the agent with: a hardlinked
539
+ # `git clone --local --no-tags` of the source repo (`SYMPHONY_SOURCE_REPO`,
540
+ # default: the dir containing WORKFLOW.md) on the base branch
541
+ # (`SYMPHONY_BASE_BRANCH`, default `main`); all network remotes stripped; an
542
+ # `origin` restored to the canonical HTTPS URL when `workspace.github_repo`
543
+ # (or the `SYMPHONY_REPO` env override) is set (so the Done-state push can
544
+ # reach a remote — `gh auth setup-git` runs host-side; the token never
545
+ # enters the VM); pinned `--local` git identity;
546
+ # and `agent/<id>` checked out off the base SHA.
547
+ # • Per-VM tooling (extra CLIs, language runtimes, dependency bootstrap) is
548
+ # baked into the agent image — see `gondolin.image` and `images/agents/`.
549
+ # • Arbitrary in-sandbox commands run from a state's `actions:` via a
550
+ # `run_in_vm` action (executes inside the per-issue VM, not on the host).
551
+ # • The post-attempt handoff (push branch, open PR) is the Done state's
552
+ # `actions:` block — see `states.Done.actions` above for the canonical pair
553
+ # (push_branch + create_pr_if_missing). The action executor exposes
554
+ # `$branch`, `$base_branch`, `$pr_title`, `$pr_body_file`, `$repo`.
555
+ #
556
+ # Workspace removal is a plain best-effort `rm -rf` of the per-issue dir once
557
+ # the run unwinds; there is no pre-removal artifact-rescue hook (rescue what you
558
+ # need via a terminal-state action before transitioning).
90
559
  # ─────────────────────────────────────────────────────────────────────────────
91
- hooks:
92
- # timeout_ms (int): max wall time for a single hook invocation.
93
- # Default: 60000
94
- timeout_ms: 120000
95
-
96
- # after_create (string | null): runs right after the workspace directory is
97
- # created, before the first dispatch. Use for git clone, dependency install,
98
- # etc. Default: null.
99
- after_create: |
100
- set -eu
101
- # ... your setup ...
102
-
103
- # before_run (string | null): runs before each turn. Default: null. Use for
104
- # cheap "make sure the workspace is sane" checks; expensive setup belongs in
105
- # after_create.
106
- before_run: |
107
- set -eu
108
- # ... pre-turn checks ...
109
-
110
- # after_run (string | null): runs after each turn, regardless of outcome.
111
- # Inspect cwd or the tracker to decide whether work is complete. Default: null.
112
- after_run: |
113
- set -eu
114
- # ... post-turn handoff (push, format-patch, …) ...
115
-
116
- # before_remove (string | null): runs before the workspace directory is
117
- # deleted. Use to extract artifacts you want to keep. Default: null.
118
- before_remove: |
119
- set -eu
120
- # ... rescue artifacts ...
121
560
 
122
561
  # ─────────────────────────────────────────────────────────────────────────────
123
562
  # agent — concurrency and turn budget.
124
563
  # ─────────────────────────────────────────────────────────────────────────────
125
564
  agent:
126
- # max_concurrent_agents (int): cap on simultaneously-running agents across
127
- # the whole workflow. Default: 10
565
+ # max_concurrent_agents (int): GLOBAL host ceiling on simultaneously-running
566
+ # agents across the whole workflow. This is the cross-state RAM bound that
567
+ # memory admission clamps (see memory_admission_enabled below); the sum of
568
+ # every state's `max_concurrent` is validated against it at startup. It stays
569
+ # top-level — not on a state — because it bounds total host memory across all
570
+ # VMs at once. Default: 10
128
571
  max_concurrent_agents: 2
129
572
 
130
573
  # max_turns (int): hard ceiling on autonomous turns per issue. Steering-reply
@@ -135,27 +578,120 @@ agent:
135
578
  # after recoverable failures. Default: 300000
136
579
  max_retry_backoff_ms: 120000
137
580
 
138
- # max_concurrent_agents_by_state (map<string, int>): optional per-state
139
- # concurrency cap. Sums must not exceed max_concurrent_agents. Default: {}.
140
- max_concurrent_agents_by_state:
141
- Todo: 1
142
- In Progress: 1
581
+ # memory_admission_enabled (bool): when true, before each dispatch the
582
+ # orchestrator reads `/proc/meminfo` (MemAvailable) and clamps the effective
583
+ # concurrency cap to what currently fits at `gondolin.mem_mib` per VM after
584
+ # subtracting `host_memory_reserve_mib`. This is a defense-in-depth backstop
585
+ # for hosts where the static `max_concurrent_agents` is set generously: when
586
+ # MemAvailable drops, new dispatches are gated so a misconfigured cap can't
587
+ # walk the host into OOM (issue 27). On hosts without `/proc/meminfo`
588
+ # (macOS, BSD) the probe degrades gracefully and the static cap is used
589
+ # unchanged. Default: true.
590
+ memory_admission_enabled: true
591
+
592
+ # host_memory_reserve_mib (int): headroom (MiB) the memory admission cap
593
+ # keeps for the orchestrator process itself, the per-VM Gondolin runners, and
594
+ # the kernel's own working set. Only consulted when
595
+ # `memory_admission_enabled` is true. Raise on hosts with heavy non-symphony
596
+ # workloads; lower on dedicated worker hosts. Default: 2048.
597
+ host_memory_reserve_mib: 2048
598
+
599
+ # circuit_breaker_threshold (int): after this many CONSECUTIVE dispatch
600
+ # attempts fail with the *same* (normalized) reason, the orchestrator stops
601
+ # retrying the issue and routes it to a holding state (the first declared
602
+ # `role: holding` state) for a human to inspect, instead of looping forever
603
+ # on a deterministically-failing dispatch (issue 128 — a persistent
604
+ # `401 invalid_api_key` once looped ~324 attempts over ~13h). The streak
605
+ # resets the moment an attempt fails with a different reason or exits
606
+ # cleanly, so transient/varied failures still retry under the normal backoff
607
+ # (`max_retry_backoff_ms`). The tripped issue's body gets a diagnostic note
608
+ # explaining the trip so the dashboard shows "stuck on identical failure"
609
+ # rather than a silent loop. Set to 0 to disable; must otherwise be >= 2
610
+ # (1 would trip on the first failure, never retrying). Default: 5.
611
+ circuit_breaker_threshold: 5
143
612
 
144
613
  # ─────────────────────────────────────────────────────────────────────────────
145
614
  # acp — Agent Client Protocol adapter selection.
146
615
  # ─────────────────────────────────────────────────────────────────────────────
147
616
  acp:
148
- # adapter (string): one of symphony's known profiles. The profile encodes the
149
- # binary to launch and the host credential file to stage. Default: 'claude'.
150
- # claude — claude-agent-acp; stages ~/.claude/.credentials.json
151
- # codex — codex-acp; stages ~/.codex/auth.json
617
+ # adapter (string): one of symphony's known profiles. Default: 'claude'.
618
+ # claude — claude-agent-acp. The guest holds a placeholder bearer; the host
619
+ # substitutes the real Anthropic OAuth token at Gondolin egress.
620
+ # No real credential enters the VM.
621
+ # codex — codex-acp (issue 116). Same model: the guest holds a placeholder
622
+ # bearer (in a fake ~/.codex/auth.json); the host substitutes the
623
+ # real OpenAI/ChatGPT token at egress. No real credential — and no
624
+ # real OPENAI_API_KEY — enters the VM.
625
+ # opencode — opencode acp, backed by GitHub Copilot (issue 130). The host
626
+ # exchanges the operator's `opencode auth login` GitHub OAuth token
627
+ # for a short-lived Copilot token host-side and substitutes it at
628
+ # egress — the GitHub token never enters the VM. One Copilot
629
+ # credential unlocks many models (GPT-4o/4.1, Claude Sonnet,
630
+ # Gemini, o-series, …).
152
631
  adapter: claude
153
632
 
154
- # command (string | null): optional shell override. When set, replaces the
155
- # auto-generated launch command and OPTS OUT of credential staging you
156
- # become responsible for placing whatever credentials the adapter needs.
157
- # Leave null for the supported zero-config flow. Default: null.
158
- command: null
633
+ # Credentials never enter the VM (issue 113; codex generalized in 116). The
634
+ # guest holds only a token-shaped placeholder; on every outbound request the
635
+ # host substitutes the real upstream credential at Gondolin egress (TLS-MITM,
636
+ # see src/agent/credential-secrets.ts). The real refresh/durable token always
637
+ # stays host-side.
638
+ #
639
+ # For claude: the host reads the live access token from
640
+ # ~/.claude/.credentials.json (refreshing host-side via `claude -p "ok"` under
641
+ # flock when the cache is stale) and injects it at egress to api.anthropic.com.
642
+ # A minimal ~/.claude.json is staged for identity only — NO refreshToken, NO
643
+ # accessToken on the VM.
644
+ #
645
+ # For codex: the host reads the live credential (`tokens.access_token` from
646
+ # ~/.codex/auth.json, with an OPENAI_API_KEY env fallback — NEVER the refresh
647
+ # token) and injects it at egress to chatgpt.com / api.openai.com. A COMPLETE
648
+ # fake ~/.codex/auth.json is staged (JWT-shaped placeholder tokens + the
649
+ # non-secret account_id/auth_mode/last_refresh codex's completeness check needs),
650
+ # so codex-acp runs in its native mode without an in-VM OAuth handshake or
651
+ # refresh (both stay host-side). Every credential-bearing var is stripped from
652
+ # the forwarded VM boot env.
653
+ #
654
+ # For opencode: a staged opencode.json (at /root/.config/opencode/opencode.json)
655
+ # declares a custom @ai-sdk/openai-compatible provider whose baseURL/apiKey read
656
+ # the OPENCODE_PROXY_* env vars (a `gho_`-shaped placeholder bearer). The host
657
+ # reads the durable GitHub OAuth token from ~/.local/share/opencode/auth.json
658
+ # (COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN env fallback), exchanges it
659
+ # host-side at api.github.com/copilot_internal/v2/token for a short-lived Copilot
660
+ # token (cached + TTL-refreshed before expiry), injects the Copilot editor
661
+ # headers, and substitutes it at egress to api.githubcopilot.com. The durable
662
+ # GitHub token never enters the VM — so do NOT also list it in
663
+ # `gondolin.forward_env`. See docs/research/opencode-copilot-accept-matrix.md.
664
+
665
+ # model (string | null): optional model selector forwarded to the chosen adapter.
666
+ # Each adapter profile knows how to surface it natively:
667
+ # claude — exported as ANTHROPIC_MODEL on the adapter process. Accepts anything
668
+ # claude-agent-acp would (aliases like "opus", "sonnet", or full IDs
669
+ # like "claude-opus-4-7").
670
+ # codex — passed as `-c model="<value>"` argv to codex-acp (parsed as TOML).
671
+ # opencode— baked into the staged opencode.json as model="symphony-copilot/<value>".
672
+ # Use a Copilot chat-completions model id (e.g. gpt-4o, gpt-4.1,
673
+ # claude-sonnet-4.5, gemini-2.5-pro); codex-class models served only
674
+ # on Copilot's /responses path are NOT reachable. Default: gpt-4o.
675
+ # Leave unset / null to use the adapter's own default model. Default: null.
676
+ # model: claude-opus-4-7
677
+
678
+ # effort (string | null): optional reasoning-effort lever forwarded to the chosen
679
+ # adapter. Profile-specific surface:
680
+ # claude — written into a staged `settings.json` ({"effortLevel": "<value>"})
681
+ # copied to /root/.claude/settings.json in the VM before claude-agent-acp
682
+ # starts. Valid values are `low|medium|high|xhigh|max`, gated per-model
683
+ # by claude-agent-acp's `supportedEffortLevels` (Opus supports `xhigh` and
684
+ # `max`; Haiku does not). Symphony does not validate the value — the
685
+ # adapter rejects unsupported choices at startup, which keeps symphony
686
+ # from drifting from the adapter's own supported list.
687
+ # codex — not wired (codex-acp has no first-class effort knob on the wrapper);
688
+ # setting `acp.effort` for a codex-backed state is a no-op.
689
+ # Leave unset / null for the adapter's own default. Default: null.
690
+ # effort: xhigh
691
+
692
+ # NOTE: the launch shape is fixed (an in-VM agent dials back over the bridge
693
+ # and spawns the chosen adapter). Customizing what the agent spawns requires
694
+ # forking that agent and rebuilding the VM image with the fork in place.
159
695
 
160
696
  # shell (string): shell used to run the ACP launch command. Default: 'bash'.
161
697
  shell: bash
@@ -173,18 +709,69 @@ acp:
173
709
  # the turn is killed and retried. Default: 300000
174
710
  stall_timeout_ms: 300000
175
711
 
712
+ # bridge — host-side TCP listener the in-VM agent dials back to for ACP traffic.
713
+ #
714
+ # This replaced the earlier in-VM-exec stdio path. Symphony writes ACP JSON-RPC frames
715
+ # onto an authenticated TCP socket; the in-VM agent (`/opt/symphony/vm-agent.mjs`)
716
+ # spawns the adapter via `child_process.spawn` with kernel pipes and bridges the
717
+ # socket to the adapter's stdio. This decouples symphony from any particular
718
+ # sandbox's stdio quirks — any sandbox that can launch a process with env vars and
719
+ # reach the host loopback works unchanged.
720
+ bridge:
721
+ # bind_host (string): host symphony binds the listener on. 0.0.0.0 allows any
722
+ # in-VM interface to reach the host loopback (Gondolin maps a synthetic guest host to
723
+ # host loopback transparently). Default: 0.0.0.0
724
+ bind_host: 0.0.0.0
725
+
726
+ # bind_port (int): port symphony binds the listener on. 0 picks an ephemeral
727
+ # port (used port surfaces via the in-VM SYMPHONY_ACP_URL env var). Default: 8788
728
+ bind_port: 8788
729
+
730
+ # reach_host (string): host the in-VM agent dials back to. Under Gondolin this is
731
+ # 127.0.0.1 because the guest loopback hits the host loopback. Other sandboxes
732
+ # may need a different alias. Default: 127.0.0.1
733
+ reach_host: 127.0.0.1
734
+
735
+ # reach_url (string|null): full URL override for the in-VM agent's dial
736
+ # destination, e.g. through a reverse proxy or different scheme. When null,
737
+ # symphony constructs `tcp://<reach_host>:<bind_port>`. Default: null
738
+ # reach_url: null
739
+
740
+ # connect_timeout_ms (int): how long to wait for the in-VM agent to connect after
741
+ # the sandbox is launched, before failing the attempt. Default: 30000
742
+ connect_timeout_ms: 30000
743
+
176
744
  # ─────────────────────────────────────────────────────────────────────────────
177
- # smolvmmicroVM execution environment.
745
+ # credentialshost credential lifecycle (issue 113). The host substitutes the
746
+ # real OAuth access token into each VM's outbound request at Gondolin egress; the
747
+ # ticker keeps the host's cached access token warm by periodically running
748
+ # `claude -p "ok"` — Claude Code's own OAuth path detects the stale token,
749
+ # refreshes against Anthropic, and atomically writes the rotated tuple back to
750
+ # `~/.claude/.credentials.json`. Symphony never implements OAuth; Anthropic's own
751
+ # client does.
178
752
  # ─────────────────────────────────────────────────────────────────────────────
179
- smolvm:
180
- # from (path | null): path to a packed .smolmachine.smolmachine artifact.
181
- # Built once with `scripts/build-vm.sh`. Mutually exclusive with `image`.
182
- # Default: null.
183
- from: ./.vm/symphony.smolmachine.smolmachine
753
+ credentials:
754
+ # ticker_interval_ms (int): how often the host ticker spawns `claude -p "ok"`
755
+ # to refresh the OAuth cache. Each live VM also refreshes proactively before its
756
+ # cached token expires, so the ticker is belt-to-the-braces for idle periods.
757
+ # Set to 0 to disable the in-symphony ticker entirely (operator runs their own
758
+ # systemd timer instead). Default: 21600000 (6 hours).
759
+ ticker_interval_ms: 21600000
184
760
 
185
- # image (string | null): container image to pull instead of a packed artifact.
186
- # Mutually exclusive with `from`. Default: null.
187
- image: null
761
+ # ─────────────────────────────────────────────────────────────────────────────
762
+ # gondolin microVM execution environment (Gondolin substrate).
763
+ # ─────────────────────────────────────────────────────────────────────────────
764
+ gondolin:
765
+ # image (string | null): the agent rootfs the VM boots, expressed as a Gondolin
766
+ # image selector. Build it ONCE with `npm run build:image` (see images/agents/) —
767
+ # the build prints a content-addressed build id (a digest); pin that id here for
768
+ # an immutable, reproducible reference. A `name:tag` ref (e.g.
769
+ # `symphony-agents:latest`) or a path to an exported asset directory also work.
770
+ # The image bakes a Node.js runtime, every ACP-capable coding agent
771
+ # (claude-agent-acp, codex-acp, opencode), and the in-VM launcher at
772
+ # /opt/symphony/vm-agent.mjs — so dispatch needs no runtime mounts. REQUIRED:
773
+ # the runner fails fast at boot when this is unset. Default: null.
774
+ image: symphony-agents:latest
188
775
 
189
776
  # cpus (int): vCPU count per VM. Default: 2.
190
777
  cpus: 2
@@ -192,19 +779,12 @@ smolvm:
192
779
  # mem_mib (int): RAM per VM in MiB. Default: 2048.
193
780
  mem_mib: 4096
194
781
 
195
- # net (bool): whether the VM has outbound networking. Default: true.
196
- # Setting false isolates the VM at the cost of breaking adapters that fetch
197
- # tokens, models, or dependencies at run time.
198
- net: true
199
-
200
- # bin_path (path | null): legacy mount; host directory containing the codex
201
- # binary, mounted read-only at /opt/codex. Default: null.
202
- bin_path: null
203
-
204
- # volumes (list): additional host:guest bind mounts. smolvm has a small
205
- # per-VM mount cap (the workspace itself already consumes one slot), so keep
206
- # this list small. Each entry: { host: path, guest: path, readonly?: bool }.
207
- # Default: [].
782
+ # volumes (list): additional host:guest VFS mounts beyond the auto-mounted
783
+ # workspace. Gondolin's VFS is programmable (no hard per-VM mount cap), but keep
784
+ # this lean if ANY state sets `eval_mode: true` it adds two read-only mounts
785
+ # (/symphony/issues + /symphony/logs) on top of the workspace. Prefer baking
786
+ # static tooling into the image over a runtime mount. Each entry:
787
+ # { host: path, guest: path, readonly?: bool }. Default: [].
208
788
  volumes:
209
789
  - host: ~/.cache/npm
210
790
  guest: /root/.npm
@@ -212,13 +792,42 @@ smolvm:
212
792
 
213
793
  # forward_env (string[]): host env vars forwarded into the VM exec.
214
794
  # Default: [OPENAI_API_KEY, ANTHROPIC_API_KEY]
795
+ # NOTE: the runner strips EVERY credential-bearing var from the forwarded boot
796
+ # env before launch — the guest holds only a token-shaped placeholder that
797
+ # Gondolin substitutes with the real token at egress — so listing a credential
798
+ # var here does NOT plant the real key in the VM's PID-1 env.
215
799
  forward_env:
216
800
  - OPENAI_API_KEY
217
801
  - ANTHROPIC_API_KEY
218
802
 
219
- # endpoint (string): smolvm server. unix:// or http:// URI.
220
- # Default: unix://$XDG_RUNTIME_DIR/smolvm.sock (or /run/user/1000/smolvm.sock)
221
- endpoint: unix:///run/user/1000/smolvm.sock
803
+ # ─────────────────────────────────────────────────────────────────────────────
804
+ # egress general dev-tooling firewall for the in-VM agent.
805
+ #
806
+ # Gondolin denies guest egress to non-allowlisted hosts by default. The agent can
807
+ # always reach its own inference host (that is handled by the credential layer,
808
+ # which substitutes the real upstream token at egress). This block additionally
809
+ # opens the dev-tooling hosts the agent needs so gates can run inside the VM —
810
+ # `npm install`, git-based dependencies, release-binary downloads.
811
+ #
812
+ # SECURITY: this is the firewall ONLY. No credential is ever substituted for a host
813
+ # listed here — the real token substitutes solely on each adapter's inference host
814
+ # (see src/agent/credential-secrets.ts `substitutionHosts`). The effective
815
+ # per-adapter allowlist handed to Gondolin is THIS list UNION that adapter's
816
+ # substitution host(s). Listing a host therefore grants plain network egress, never
817
+ # a token. Keep the list tight — every entry widens the network surface of
818
+ # semi-trusted in-VM code.
819
+ # ─────────────────────────────────────────────────────────────────────────────
820
+ egress:
821
+ # allowed_hosts (string[]): hostnames the in-VM agent may reach for dev tooling.
822
+ # Default: [] (no extra hosts — the agent can reach only its inference host).
823
+ # Bare hostnames ONLY — no scheme, port, or path (`github.com`, not
824
+ # `https://github.com/...`). A malformed entry fails safe (the host simply stays
825
+ # blocked, never opened). Each entry is matched against the request host exactly.
826
+ allowed_hosts:
827
+ - registry.npmjs.org # npm install
828
+ - github.com # git-based deps / release pages
829
+ - codeload.github.com # GitHub tarball fetch
830
+ - objects.githubusercontent.com # release-binary downloads
222
831
 
223
832
  # ─────────────────────────────────────────────────────────────────────────────
224
833
  # server — HTTP dashboard + MCP endpoint listener.
@@ -236,11 +845,24 @@ server:
236
845
  # mcp — Model Context Protocol server exposed to in-VM agents.
237
846
  #
238
847
  # The orchestrator runs a JSON-RPC endpoint scoped to each active issue at
239
- # /api/v1/issues/<id>/mcp, gated by a per-dispatch bearer token. Two tools live
240
- # there:
848
+ # /api/v1/issues/<id>/mcp, gated by a per-dispatch bearer token. Three tools
849
+ # live there:
241
850
  #
242
- # • symphony.mark_done({ title, summary })
851
+ # • symphony.transition({ to_state, notes? })
852
+ # — canonical (and only) exit verb. Moves the issue into another declared
853
+ # state, optionally appending `notes` (markdown) to the issue body before
854
+ # the move so the next agent (in `to_state`) reads them as part of
855
+ # `issue.description`. Terminal targets clean the workspace; active and
856
+ # holding targets preserve it so the same `agent/<id>` git branch
857
+ # survives the handoff. Rejected transitions return MCP tool-result
858
+ # errors (isError:true) the agent can read and retry.
243
859
  # • symphony.request_human_steering({ question, context? })
860
+ # • symphony.propose_issue({ title, description?, labels?, priority? })
861
+ # — drops a new issue into the first declared `role: holding` state
862
+ # directory (literal Triage if none declared). The orchestrator does NOT
863
+ # dispatch it; the operator approves or discards from the dashboard. The
864
+ # calling issue is recorded as proposed_by in the new file's
865
+ # front-matter.
244
866
  # ─────────────────────────────────────────────────────────────────────────────
245
867
  mcp:
246
868
  # enabled (bool): when false, the orchestrator refuses to dispatch (MCP is
@@ -248,8 +870,8 @@ mcp:
248
870
  enabled: true
249
871
 
250
872
  # host (string): hostname or IP the agent uses to reach the orchestrator
251
- # from inside the smolvm. The port is resolved at runtime from the
252
- # actually-bound HTTP server. Default: '127.0.0.1' (smolvm proxies VM
873
+ # from inside the VM. The port is resolved at runtime from the
874
+ # actually-bound HTTP server. Default: '127.0.0.1' (Gondolin maps VM
253
875
  # loopback to host loopback; verified empirically).
254
876
  host: 127.0.0.1
255
877
 
@@ -264,17 +886,27 @@ Liquid-templated prompt body. Rendered once per dispatched issue. Context:
264
886
 
265
887
  issue.identifier — the issue's external id (e.g. "DEMO-42").
266
888
  issue.title — issue title (string).
267
- issue.state — current state (string, matches active_states[]).
268
- issue.description — body text (string or empty).
889
+ issue.state — current state (string, matches a key in `states:`).
890
+ issue.description — body text (string or empty). `symphony.transition`
891
+ appends its `notes` block here before the file moves,
892
+ so the next state's agent reads the previous state's
893
+ handoff message verbatim.
269
894
  issue.priority — number or null.
270
895
  issue.labels — list of strings (lowercased).
271
896
  attempt — int, 1-based attempt counter; absent on first attempt.
272
897
 
273
898
  Available Liquid filters: standard Shopify Liquid plus `escape_once`.
274
899
 
900
+ Per-state prompt branching (V1 pattern): when `states:` declares more than
901
+ one active role (e.g. Todo + Review), wrap the state-specific instructions in
902
+ a `{% case issue.state %}` / `{% when "..." %}` / `{% else %}` block. The
903
+ runner renders the prompt fresh on every dispatch, so each state's agent sees
904
+ only its own instructions plus whatever common preamble / postamble lives
905
+ outside the case. See WORKFLOW.md in this repo for a worked example.
906
+
275
907
  The body below is the literal prompt sent to the agent. Keep it specific to
276
- this workflow; orchestrator behavior (mark_done, request_human_steering) is
277
- the same no matter what you write here.
908
+ this workflow; orchestrator behavior (transition, request_human_steering,
909
+ propose_issue) is the same no matter what you write here.
278
910
  -->
279
911
 
280
912
  You are picking up a single issue and shepherding it through the workflow.
@@ -294,12 +926,20 @@ Goals:
294
926
 
295
927
  1. Work in the current directory only; treat it as the issue workspace.
296
928
  2. Make the smallest correct change that satisfies the issue.
297
- 3. Call `symphony.mark_done({ title, summary })` when done. This is the only
298
- way to signal completion. The orchestrator atomically moves the issue file
299
- to the terminal state and stops dispatching.
929
+ 3. Hand off when done. `symphony.transition({ to_state, notes? })` is the
930
+ canonical (and only) exit verb: pass a declared state name and optional
931
+ markdown notes that get appended to the issue body for the next agent.
932
+ For single-agent workflows, transition straight into the first declared
933
+ `role: terminal` state to end the run.
300
934
  4. If you cannot proceed without human input, call
301
935
  `symphony.request_human_steering({ question, context? })`. Your turn ends
302
936
  immediately; the human's reply arrives as your next prompt.
937
+ 5. If you notice work out of scope for this issue — unrelated bugs, follow-ups
938
+ a human should size, refactors worth a separate dispatch — call
939
+ `symphony.propose_issue({ title, description?, labels?, priority? })`. It
940
+ lands in the first declared `role: holding` state directory (defaults to
941
+ `Triage/`); the operator approves or discards. Do not graft unrelated
942
+ edits onto this branch.
303
943
 
304
944
  {% if attempt -%}
305
945
  This is continuation/retry attempt {{ attempt }}. Inspect the workspace before