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
package/WORKFLOW.md CHANGED
@@ -5,193 +5,354 @@
5
5
  #
6
6
  # npx symphony WORKFLOW.md
7
7
  #
8
- # Defaults assume a fully local setup: the per-issue workspace clones from this
9
- # repo's `.git` directory, the agent has no network credentials, and on
10
- # mark_done the host writes a `git format-patch` bundle to
11
- # `.symphony/patches/<branch>.patch` for human review.
8
+ # The per-issue workspace clones from this repo's `.git` directory and the agent
9
+ # has no network credentials. The remote PR flow is configured in-file via
10
+ # `workspace.github_repo` below (dizk/smol-symphony): on a terminal transition
11
+ # the Done-state actions push the per-issue branch and open a PR. `gh` on the
12
+ # host must be authenticated (`gh auth status` clean); the token never enters
13
+ # the VM.
12
14
  #
13
- # To opt into the remote PR flow, export before launching:
14
- #
15
- # SYMPHONY_REPO=owner/smol-symphony \
16
- # SYMPHONY_BASE_BRANCH=main \
17
- # npx symphony WORKFLOW.md
18
- #
19
- # `gh` on the host must be authenticated (`gh auth status` clean). The token
20
- # never enters the VM.
15
+ # For a fully local setup (branch left in the workspace until cleanup, nothing
16
+ # pushed), set `workspace.github_repo: none`. The SYMPHONY_REPO env var still
17
+ # overrides the in-file value when exported.
21
18
  #
22
19
  # Every section and option is documented in WORKFLOW.template.md.
23
20
 
21
+ # Declared workflow states. Drives dispatch eligibility (role: active),
22
+ # terminal cleanup (role: terminal), and the propose_issue landing directory
23
+ # (role: holding). This map is the single source of truth — there are no
24
+ # separate active/terminal lists to keep in sync.
25
+ #
26
+ # Per-state `adapter` / `model` / `max_turns` override the workflow-level
27
+ # `acp.*` and `agent.max_turns` defaults at dispatch time, and `max_concurrent`
28
+ # caps how many agents run at once in this state (the global
29
+ # `agent.max_concurrent_agents` stays the cross-state host ceiling).
30
+ # `allowed_transitions` narrows the targets the agent can pass to
31
+ # `symphony.transition` while operating in this state (omit for "any declared
32
+ # state is reachable").
33
+ states:
34
+ Todo:
35
+ role: active
36
+ adapter: claude
37
+ # Opus 4.8, 1M-context variant. The plain `claude-opus-4-7` is the 200K
38
+ # variant — a γ-class refactor dispatch hit two mid-turn compactions before
39
+ # reaching the edit phase; `[1m]` gives ~30x headroom and removes the stall.
40
+ # The suffix is Claude Code's model-selection convention, forwarded to the
41
+ # adapter via ANTHROPIC_MODEL. Review stays on codex (cross-model review).
42
+ model: claude-opus-4-8[1m]
43
+ max_turns: 10
44
+ # Per-state concurrency cap (issue 137): at most one implementer agent at a
45
+ # time. Lives on the state now, symmetric with max_turns; the global
46
+ # `agent.max_concurrent_agents` below remains the cross-state host ceiling.
47
+ max_concurrent: 1
48
+ Review:
49
+ # Codex picks up the implementer's branch and approves or rejects. On
50
+ # approval it transitions the issue to Done with PR-body notes; on
51
+ # rejection it transitions back to Todo with rework instructions.
52
+ role: active
53
+ adapter: codex
54
+ # codex-acp accepts the model via `-c model="..."` argv (TOML); see
55
+ # src/agent/adapters.ts. `gpt-5-codex` was historically rejected with the
56
+ # ChatGPT-account user, so leave `model` unset and let codex-acp pick its
57
+ # own default code-review model. Operators with an API-key Codex setup can
58
+ # pin a specific model here once that's known-good.
59
+ max_turns: 6
60
+ allowed_transitions: [Todo, Done]
61
+ Reflect:
62
+ # Sleep cycle (issue 122). A single recurring "Sleep cycle" issue rests in
63
+ # Dormant; the operator — or an external cron / `symphony reflect` verb —
64
+ # arms a cycle by moving it into Reflect. eval_mode binds the read-only
65
+ # /symphony/issues (all state dirs, including the Done/*.md handoff
66
+ # transcripts) and /symphony/logs (per-issue JSONL run logs) mounts so the
67
+ # agent can mine finished work for *recurring* harness friction, distil
68
+ # lessons, and file improvement proposals via propose_issue (which land in
69
+ # Triage — the human gate). It reflects on *how symphony runs work*
70
+ # (WORKFLOW.md prompt branches, per-state model/max_turns/effort/actions,
71
+ # the gondolin image config, acceptance criteria, timeouts), NOT the product code under
72
+ # review. After filing it transitions to Dormant and waits to be re-armed.
73
+ # See the Reflect prompt branch (the `when "Reflect"` case in the body) for
74
+ # the read → distil → propose loop and the guardrails. Cadence: the operator
75
+ # / an external cron / a `mv` on disk can still arm a cycle, and the
76
+ # orchestrator now also auto-arms Dormant → Reflect on idle or after N
77
+ # terminal transitions (issue 140 — see this state's `arm:` block below).
78
+ role: active
79
+ adapter: claude
80
+ # 1M-context Opus: a reflection turn reads many Done/*.md transcripts plus
81
+ # the relevant logs/<id>.jsonl, so the large-context variant avoids mid-turn
82
+ # compaction (same rationale as the Todo state).
83
+ model: claude-opus-4-8[1m]
84
+ # Higher than Todo/Review: reading the history, distilling patterns, and
85
+ # filing one proposal per lesson takes more turns than a single edit/review.
86
+ max_turns: 20
87
+ # Bind the read-only /symphony/issues + /symphony/logs mounts for this state.
88
+ eval_mode: true
89
+ # The reflector may ONLY go dormant — it cannot route itself into
90
+ # Todo/Review/Done. A guardrail on this self-modifying loop; filing
91
+ # improvements happens through propose_issue (→ Triage), which is
92
+ # independent of allowed_transitions.
93
+ allowed_transitions: [Dormant]
94
+ # Auto-arm trigger (issue 140; replaces the former top-level auto-arm block).
95
+ # This active state arms itself: the orchestrator moves `arm.issue` out of
96
+ # its `arm.from` holding state (Dormant) into Reflect automatically —
97
+ # `on_idle` when the orchestrator is idle and >=1 issue reached a terminal
98
+ # state since the last run, and `after_terminal` as a backstop once that many
99
+ # issues have reached a terminal state since the last run. The
100
+ # terminal-transition counter resets to 0 the moment the issue is armed.
101
+ # GUARDRAILS (carried over from issue 122): auto-arming ONLY moves the issue
102
+ # into Reflect — the proposals it files still land in Triage and still require
103
+ # human approve/discard, so this does not bypass the human gate. Requires a
104
+ # single `sleep-cycle` issue resting in Dormant.
105
+ arm:
106
+ issue: sleep-cycle
107
+ from: Dormant
108
+ on_idle: true
109
+ after_terminal: 10
110
+ Done:
111
+ role: terminal
112
+ # PR autopilot routing (issue 38; moved onto the state in issue 139). Done
113
+ # is the merge state: a MERGEABLE Done-state PR has GitHub auto-merge armed
114
+ # with `squash` (matches the repo's `NN: title (#PR)` history); a
115
+ # CONFLICTING one is routed back to `on_conflict.route_to` (Todo) for the
116
+ # dispatched agent to rebase. The host-global on/off switch + poll TTL live
117
+ # in the top-level `pr:` block below; the merge/close/route targets are
118
+ # derived by scanning states for this `pr:` field (no named-string sibling
119
+ # block). While the engine is enabled, transitions into Done no longer fire
120
+ # the standard terminal workspace cleanup — the pr resource owns the
121
+ # workspace until its PR merges or closes.
122
+ pr:
123
+ auto_merge: squash
124
+ on_conflict:
125
+ route_to: Todo
126
+ # Issue 36 (reconciler v2 / typed action DAG): the legacy `after_run`
127
+ # shell that pushed the branch and opened a PR is replaced by two typed
128
+ # actions. The host pre-stages SYMPHONY_PR_TITLE / SYMPHONY_PR_BODY_FILE /
129
+ # SYMPHONY_BRANCH (the same values the old shell read); the action
130
+ # executor exposes them as $pr_title / $pr_body_file / $branch /
131
+ # $base_branch / $repo in the fixed template namespace
132
+ # (src/actions/types.ts → ActionContext). The `if: $repo` predicate
133
+ # matches the old `[ -n "${SYMPHONY_REPO:-}" ] || exit 0` short-circuit
134
+ # so the local-only mode is still a no-op. Per-action retry/snapshot
135
+ # plumbing replaces the opaque shell-exit-code surface; on rate-limit
136
+ # the create_pr_if_missing action shows "retrying in 60s" on the
137
+ # dashboard instead of a silent failure.
138
+ actions:
139
+ - kind: push_branch
140
+ name: push-branch
141
+ remote: origin
142
+ ref: $branch
143
+ if: $repo
144
+ - kind: create_pr_if_missing
145
+ name: open-pr
146
+ base: $base_branch
147
+ head: $branch
148
+ title_from: $pr_title
149
+ body_from: $pr_body_file
150
+ if: $repo
151
+ Cancelled:
152
+ role: terminal
153
+ # Cancelled means the work was abandoned; no patch, no PR. The workspace is
154
+ # cleaned up after the run unwinds and the commits are discarded with it.
155
+ # PR autopilot close state (issue 139): when the engine is enabled, an open
156
+ # PR for a Cancelled issue is closed without merge and its remote branch is
157
+ # best-effort-deleted. Unlike the merge state, the close path needs no
158
+ # workspace, so standard terminal cleanup still runs on transition in.
159
+ pr:
160
+ close: true
161
+ Triage:
162
+ # Landing directory for `symphony.propose_issue`. Never dispatched; the
163
+ # operator approves or discards from the dashboard. Declared FIRST among
164
+ # holding states so it stays the `propose_issue` landing + triage target
165
+ # (both resolve the first declared holding state).
166
+ role: holding
167
+ Dormant:
168
+ # Resting place for the recurring "Sleep cycle" issue (issue 122) between
169
+ # reflection runs. Holding → never dispatched. A reflection cycle re-arms by
170
+ # moving the issue from Dormant back into Reflect: the orchestrator's
171
+ # `states.Reflect.arm` auto-arm (issue 140) does this on idle / after N
172
+ # terminal transitions, and an external cron, a `symphony reflect` verb, or
173
+ # `mv` on disk still work too. NOTE: the dashboard currently
174
+ # renders triage approve/discard buttons on every holding row and the
175
+ # tracker resolves a move by issue id regardless of source directory, so
176
+ # clicking those buttons on a Dormant issue would mis-route it — re-arm via
177
+ # cron/CLI/filesystem, not the dashboard buttons. A follow-up restricts
178
+ # those buttons to the triage-landing state.
179
+ role: holding
180
+
24
181
  tracker:
25
182
  kind: local
26
- root: ./issues
27
- active_states:
28
- - Todo
29
- - In Progress
30
- terminal_states:
31
- - Done
32
- - Cancelled
183
+ # Operator-scoped tracker root (outside the repo). State transitions and
184
+ # propose_issue writes don't dirty the codebase's git status. Symphony
185
+ # auto-mkdirs every declared state directory under this root on startup.
186
+ root: ~/.symphony/trackers/smol-symphony
187
+
188
+ # PR autopilot engine toggle (issue 38, simplified by issue 101; routing moved
189
+ # onto states in issue 139). This is the slim host-global half only — the
190
+ # on/off switch and the per-PR `gh pr view` cache TTL. The merge/close/route
191
+ # targets and the auto-merge strategy now live ON the states they describe:
192
+ # `states.Done.pr` (merge state — auto_merge + on_conflict.route_to) and
193
+ # `states.Cancelled.pr` (close state — close: true), above. The reconciler
194
+ # derives those by scanning states; there is no named-string sibling block.
195
+ #
196
+ # Enabled 2026-05-25 so MERGEABLE Done-state PRs have GitHub auto-merge armed
197
+ # and CONFLICTING ones are routed back to Todo for the dispatched agent to
198
+ # rebase (the host runs `git fetch origin <base>` before each dispatch so
199
+ # `origin/<base>` is current, and the Todo prompt's first step is
200
+ # `git rebase origin/<base>`). There is no autopilot-side rebase machinery and
201
+ # no consecutive-failure circuit breaker.
202
+ #
203
+ # PREREQUISITE: `gh pr merge --auto` requires at least one branch-protection
204
+ # rule on `main`, or arming auto-merge errors. Ensure one exists in the repo's
205
+ # GitHub settings. To disable, set `enabled: false` (the resource is then never
206
+ # constructed and Done-state behavior reverts to the actions-block PR-create +
207
+ # operator merge).
208
+ pr:
209
+ enabled: true
210
+ poll_interval_ms: 30000
33
211
 
34
212
  polling:
35
213
  interval_ms: 5000
36
214
 
215
+ # The canonical clone + base-branch checkout + `agent/<id>` branch cut +
216
+ # origin/identity setup is owned by the orchestrator's TypeScript
217
+ # `setupWorkspaceDir` action (issue 34 / reconciler stage 3) — there is no
218
+ # shell `hooks:` surface. The per-issue workspace arrives at the dispatched
219
+ # agent with:
220
+ #
221
+ # • a hardlinked `git clone --local` of the source repo on the base branch
222
+ # (`workspace.base_branch`, or the SYMPHONY_BASE_BRANCH env override,
223
+ # default `main`) at the source repo's current local base SHA
224
+ # • all network remotes stripped (in-VM `git push`/`git fetch` fail closed)
225
+ # • when `workspace.github_repo` (or the SYMPHONY_REPO env override) is set:
226
+ # `origin` restored to the canonical HTTPS URL so the host's Done-state
227
+ # `push_branch` action can push (`gh auth setup-git` runs best-effort on the
228
+ # host so the push has credentials; the token never enters the VM)
229
+ # • `user.name = symphony-agent` / `user.email = agent@symphony.local`
230
+ # • `agent/<id>` checked out
231
+ #
232
+ # The source repo's local `<base>` is the single source of truth for the
233
+ # workspace's base ref. To pick up a new base, update the source repo
234
+ # (`git pull` / `git fetch && git checkout <base>`) before the next dispatch;
235
+ # symphony does not implicitly fetch from `origin/<base>` at setup time.
236
+ #
237
+ # Need extra per-VM tooling on top of that? Bake it into the agent image
238
+ # (`images/agents/`), or run arbitrary guest commands from a state's `actions:`
239
+ # via `run_in_vm`. The post-attempt push + PR-create handoff is the Done
240
+ # state's `actions:` block above.
37
241
  workspace:
38
242
  root: ./.symphony/workspaces
39
-
40
- hooks:
41
- timeout_ms: 120000
42
-
43
- # Clone smol-symphony into the fresh per-issue workspace from a strictly local
44
- # source (no creds needed). The agent receives a working git repo with full
45
- # history on the configured base branch, plus a per-issue branch checked out.
46
- # All network remotes are stripped so any `git push`/`git fetch` from inside
47
- # the VM fails closed.
48
- after_create: |
49
- set -eu
50
- SOURCE_REPO="${SYMPHONY_SOURCE_REPO:-${PWD}/../../..}"
51
- BASE="${SYMPHONY_BASE_BRANCH:-main}"
52
- ISSUE_ID="$(basename "$PWD")"
53
- BRANCH="agent/${ISSUE_ID}"
54
-
55
- if [ ! -d "${SOURCE_REPO}/.git" ]; then
56
- echo "after_create: SOURCE_REPO=${SOURCE_REPO} is not a git repo" >&2
57
- exit 1
58
- fi
59
-
60
- # `git clone --local` hardlinks .git/objects when possible; fast and disk-cheap.
61
- # `--no-tags` keeps the local refspec minimal; `--branch` lands on the right base.
62
- git clone --local --no-tags --branch "${BASE}" "${SOURCE_REPO}" .
63
-
64
- # Strip all remotes. The agent will see no network targets at all.
65
- for remote in $(git remote); do
66
- git remote remove "${remote}"
67
- done
68
- git config --local --unset credential.helper 2>/dev/null || true
69
-
70
- # If SYMPHONY_REPO is set, restore an `origin` pointing at the GitHub remote
71
- # so the after_run hook can push. The URL is the canonical HTTPS form (no
72
- # token); auth comes from the host's `gh`, which never enters the VM.
73
- if [ -n "${SYMPHONY_REPO:-}" ]; then
74
- git remote add origin "https://github.com/${SYMPHONY_REPO}.git"
75
- gh auth setup-git 2>/dev/null || true
76
- git fetch --no-tags origin "${BASE}:refs/remotes/origin/${BASE}" || true
77
- fi
78
-
79
- git config --local user.name "symphony-agent"
80
- git config --local user.email "agent@symphony.local"
81
-
82
- git checkout -b "${BRANCH}"
83
-
84
- echo "workspace ready: base=${BASE} branch=${BRANCH} source=${SOURCE_REPO}"
85
-
86
- # Runs after every attempt. Gated on the issue file being in Done/ (i.e. the
87
- # agent has called symphony.mark_done). Two outputs:
88
- # - If SYMPHONY_REPO is set: push branch + open (or update) a PR via gh.
89
- # - Else (local-only mode): write the agent's work as a git format-patch
90
- # bundle into ./.symphony/patches/<branch>.patch for human review.
91
- after_run: |
92
- set -eu
93
- BASE="${SYMPHONY_BASE_BRANCH:-main}"
94
- ISSUE_ID="$(basename "$PWD")"
95
- BRANCH="agent/${ISSUE_ID}"
96
- TRACKER_ROOT="${SYMPHONY_TRACKER_ROOT:-$PWD/../../../issues}"
97
- PATCHES_DIR="${SYMPHONY_PATCHES_DIR:-$PWD/../../../.symphony/patches}"
98
-
99
- if [ ! -f "${TRACKER_ROOT}/Done/${ISSUE_ID}.md" ]; then
100
- echo "issue ${ISSUE_ID} not in Done/ yet; skipping handoff"
101
- exit 0
102
- fi
103
- if ! git rev-parse --verify "${BRANCH}" >/dev/null 2>&1; then
104
- echo "no branch ${BRANCH}; nothing to hand off"
105
- exit 0
106
- fi
107
-
108
- # Resolve the ref the agent diverged from. In remote mode origin/${BASE}
109
- # exists (after_create's `git fetch`). In local-only mode we kept the local
110
- # ${BASE} branch alive (`git clone --branch ${BASE}` creates it at the
111
- # original tip; `git checkout -b ${BRANCH}` does not remove it). That tip
112
- # is exactly where the agent diverged from.
113
- if git rev-parse --verify "origin/${BASE}" >/dev/null 2>&1; then
114
- MERGE_BASE="origin/${BASE}"
115
- elif git rev-parse --verify "${BASE}" >/dev/null 2>&1; then
116
- MERGE_BASE="${BASE}"
117
- else
118
- echo "could not resolve merge base (no origin/${BASE} or local ${BASE})" >&2
119
- exit 1
120
- fi
121
-
122
- if [ -z "$(git log --oneline "${MERGE_BASE}..${BRANCH}" 2>/dev/null)" ]; then
123
- echo "no new commits on ${BRANCH}; nothing to hand off"
124
- exit 0
125
- fi
126
-
127
- # The mark_done MCP tool persists its title + summary into a structured
128
- # markdown file at <staging>/mark_done.md. The staging dir is inside .git/
129
- # when the workspace has its own clone; .symphony-runtime/ otherwise.
130
- MARKDONE=""
131
- if [ -f .git/symphony-runtime/mark_done.md ]; then
132
- MARKDONE=.git/symphony-runtime/mark_done.md
133
- elif [ -f .symphony-runtime/mark_done.md ]; then
134
- MARKDONE=.symphony-runtime/mark_done.md
135
- fi
136
- if [ -n "${MARKDONE}" ]; then
137
- TITLE="$(sed -n '1 s/^# //p' "${MARKDONE}")"
138
- BODY="$(tail -n +3 "${MARKDONE}")"
139
- else
140
- TITLE="${ISSUE_ID}"
141
- BODY="Symphony run for ${ISSUE_ID}."
142
- fi
143
-
144
- if [ -n "${SYMPHONY_REPO:-}" ]; then
145
- # Remote PR mode.
146
- git push -u origin "${BRANCH}"
147
- if gh pr view "${BRANCH}" >/dev/null 2>&1; then
148
- echo "PR already exists for ${BRANCH}; pushed updates"
149
- else
150
- gh pr create \
151
- --base "${BASE}" \
152
- --head "${BRANCH}" \
153
- --title "${ISSUE_ID}: ${TITLE}" \
154
- --body "${BODY}"
155
- fi
156
- else
157
- # Local-only mode: bundle the diff for human review.
158
- mkdir -p "${PATCHES_DIR}"
159
- OUT="${PATCHES_DIR}/$(echo "${BRANCH}" | tr '/' '_').patch"
160
- git format-patch --stdout "${MERGE_BASE}..${BRANCH}" > "${OUT}"
161
- echo "wrote patch bundle: ${OUT}"
162
- echo " apply with: git -C <target-repo> am ${OUT}"
163
- fi
243
+ # PR/push target for the dogfood (symphony-on-symphony) setup: the Done-state
244
+ # actions push the per-issue branch + open a PR against this repo. Set to
245
+ # `none` for local-only (branch left in the workspace). The SYMPHONY_REPO env
246
+ # var still overrides this if exported.
247
+ github_repo: dizk/smol-symphony
248
+ # Branch the per-issue workspace clones from + targets as the PR base. The
249
+ # SYMPHONY_BASE_BRANCH env var still overrides this if exported.
250
+ base_branch: main
251
+
252
+ # Per-issue JSONL run logs plus an orchestrator-side `symphony.log` mirror.
253
+ # One JSONL file per issue, appended across attempts and process restarts;
254
+ # captures every ACP JSON-RPC frame to/from the VM, raw adapter stderr,
255
+ # typed-action output, and orchestrator lifecycle events — intended for
256
+ # later evaluation by another agent. The sibling `symphony.log` captures the
257
+ # orchestrator's structured log (dispatch, actions, reconciler, shutdown) in the
258
+ # same `key=value` format so a post-hoc review has both surfaces in one
259
+ # directory. While the file sink is active the console shows only the startup
260
+ # banner; `tail -f symphony.log` follows the detail, and `--verbose` mirrors it
261
+ # back to the console. See WORKFLOW.template.md for the full schema.
262
+ logs:
263
+ root: ./.symphony/logs
164
264
 
165
265
  agent:
266
+ # SERIALIZED to 1 (2026-05-27) to stop the FC/IS burn-down conflict storm:
267
+ # every burn-down PR edits the same policy files (package.json --max-warnings
268
+ # ratchet, .dependency-cruiser.cjs, eslint.config.js), so any two in flight
269
+ # conflict by construction. Serial dispatch makes each PR rebase on the prior
270
+ # merge. Revert to 2 once the arch-burndown queue drains.
166
271
  max_concurrent_agents: 1
167
272
  max_turns: 6
168
273
  max_retry_backoff_ms: 120000
169
274
 
170
275
  acp:
171
- # Selecting "claude" is enough: symphony reads ~/.claude/.credentials.json on
172
- # the host, stages a copy into the workspace's runtime dir, and auto-generates
173
- # a launch command that places the file at the adapter's expected path inside
174
- # the VM before exec'ing claude-agent-acp. Set `command` only to override.
276
+ # Selecting "claude" is enough: symphony probes ~/.claude/.credentials.json
277
+ # on the host at startup and auto-generates a launch command for the in-VM
278
+ # agent. There is no `command` escape hatch under the TCP bridge transport
279
+ # the launch shape is fixed; fork scripts/vm-agent.mjs if you need to customize
280
+ # what the agent spawns.
175
281
  adapter: claude
282
+ # Credentials never enter the VM (issue 113; codex generalized in 116). The
283
+ # guest holds only a token-shaped placeholder; the host substitutes the real
284
+ # upstream credential into the outbound request at Gondolin egress (TLS-MITM):
285
+ # for claude, the Anthropic OAuth access token; for codex (the Review state's
286
+ # adapter), the OpenAI credential read from ~/.codex/auth.json (access token or
287
+ # OPENAI_API_KEY, never the refresh token). Every credential-bearing var is
288
+ # stripped from the forwarded VM boot env, so no real credential lands in the VM.
289
+ # Reasoning effort forwarded to claude-agent-acp via a staged settings.json
290
+ # (`{"effortLevel": "xhigh"}`) copied into /root/.claude/settings.json before the
291
+ # adapter starts. xhigh is the second-highest tier under Opus 4.7 (max is the top
292
+ # but is meaningfully slower); operators on a Haiku-backed model must drop this
293
+ # because Haiku rejects xhigh at adapter startup. Valid set is `low|medium|high|xhigh|max`,
294
+ # model-gated by claude-agent-acp's `supportedEffortLevels`.
295
+ effort: xhigh
176
296
  shell: bash
177
- prompt_timeout_ms: 1800000
297
+ # Hard cap on a single session/prompt regardless of activity. Raised from 30min to
298
+ # 60min (the code default) because a heavy refactor turn at effort=xhigh can run a
299
+ # single uninterrupted turn past 30min — issue 103's healthy attempt was killed
300
+ # mid-edit at the old 1800000 cap with turns_completed:0. Distinct from
301
+ # stall_timeout_ms below (which only trips on NO activity).
302
+ prompt_timeout_ms: 3600000
178
303
  read_timeout_ms: 30000
179
- stall_timeout_ms: 300000
180
-
181
- smolvm:
182
- from: ./.vm/symphony.smolmachine.smolmachine
304
+ # ACP TCP bridge. Symphony binds a listener on `bridge.bind_host:bind_port`; the in-VM
305
+ # agent (`/opt/symphony/vm-agent.mjs`) dials `bridge.reach_host:bind_port` on startup
306
+ # and authenticates with a per-dispatch bearer token. This replaced the earlier
307
+ # in-VM-exec stdio path so symphony is not coupled to any particular sandbox's quirks.
308
+ bridge:
309
+ bind_host: 0.0.0.0
310
+ bind_port: 8788
311
+ reach_host: 127.0.0.1
312
+ # Time between any ACP event from the adapter before symphony kills the attempt as stalled.
313
+ # Raised from the 5-minute default because Opus 4.7 at effort=xhigh can take many minutes to
314
+ # produce its first thought chunk on a heavy prompt. If a real wedge happens, attempts will
315
+ # die at this longer threshold; if the agent is just thinking, we let it finish.
316
+ stall_timeout_ms: 1800000
317
+
318
+ gondolin:
319
+ # Per-issue microVM (Gondolin substrate). `image` is the agent rootfs the VM
320
+ # boots, built ONCE with `npm run build:image` (see images/agents/) — not baked
321
+ # per issue. The value is a Gondolin image selector: the content-addressed build
322
+ # id printed by the build (pinned below for reproducibility), a `name:tag` ref
323
+ # like `symphony-agents:latest`, or a path to an exported asset directory.
324
+ image: cb875342-03ef-56e0-9306-dde8628aa17d
183
325
  cpus: 2
184
326
  mem_mib: 4096
185
- net: true
186
- # No volume mounts. Workspace is auto-mounted by the runner. Credentials are
187
- # staged into the workspace by symphony and copied into ~/.claude by the
188
- # auto-derived acp.command. The tracker is reached only through the symphony
189
- # MCP server.
327
+ # No runtime bind-mounts. The in-VM launcher (/opt/symphony/vm-agent.mjs) is
328
+ # baked into the image, so it needs no per-dispatch mount. Keeping `volumes`
329
+ # empty leaves room for an eval_mode state's two read-only mounts (/symphony/issues
330
+ # + /symphony/logs) on top of the auto-mounted workspace. Credentials never
331
+ # mount: the host substitutes the real token at Gondolin egress; the tracker is
332
+ # reached via the symphony MCP server (or the eval_mode mount).
190
333
  volumes: []
334
+ # forward_env is a generic passthrough into the VM boot env, but the runner
335
+ # strips EVERY credential-bearing var before boot (the guest holds only a
336
+ # placeholder Gondolin substitutes at egress) — so listing OPENAI_API_KEY here
337
+ # does NOT plant the real key in a VM.
191
338
  forward_env:
192
339
  - OPENAI_API_KEY
193
340
  - ANTHROPIC_API_KEY
194
341
 
342
+ egress:
343
+ # General dev-tooling firewall for the in-VM agent. Gondolin denies guest egress
344
+ # by default; the agent can always reach its own inference host (handled by the
345
+ # credential layer), and these hosts are additionally opened so gates can run
346
+ # (`npm install`, git-based deps, release binaries). SECURITY: nothing here ever
347
+ # gets a real token substituted — listing a host grants plain network egress
348
+ # only. The real upstream token is substituted solely on each adapter's inference
349
+ # host (see src/agent/credential-secrets.ts).
350
+ allowed_hosts:
351
+ - registry.npmjs.org # npm install
352
+ - github.com # git-based deps / release pages
353
+ - codeload.github.com # GitHub tarball fetch
354
+ - objects.githubusercontent.com # release-binary downloads
355
+
195
356
  server:
196
357
  port: 8787
197
358
  # Bound to all interfaces because access is gated by tailscale, not by the
@@ -200,13 +361,13 @@ server:
200
361
  host: 0.0.0.0
201
362
 
202
363
  mcp:
203
- # The VM's loopback transparently reaches the host's loopback in smolvm, so
364
+ # Gondolin maps a synthetic guest host to the host's loopback (`tcp.hosts`), so
204
365
  # 127.0.0.1 from inside the VM hits the host's listener. Override only if
205
366
  # your VMM has a different host alias.
206
367
  host: 127.0.0.1
207
368
  ---
208
369
  You are working on **smol-symphony**, a TypeScript orchestrator that dispatches
209
- coding agents into per-issue smolvm microVMs and talks to them over the Agent
370
+ coding agents into per-issue Gondolin microVMs and talks to them over the Agent
210
371
  Client Protocol (ACP). Your workspace is a fresh clone of this repo.
211
372
 
212
373
  Issue: **{{ issue.identifier }} — {{ issue.title }}**
@@ -225,25 +386,320 @@ Orientation:
225
386
  - This is the smol-symphony codebase. Start by reading `README.md` and
226
387
  `PRODUCT.md` if you haven't seen them. `SPEC.md` is the long-form design
227
388
  spec. `CLAUDE.md` (if present) has any standing instructions for this repo.
228
- - Source lives under `src/`. Tests live under `tests/`. Run `npm test` and
229
- `npm run typecheck` before declaring work done.
389
+ - Source lives under `src/`. Tests live under `tests/`. Before declaring work
390
+ done, run `npm run typecheck`, `npm test`, `npm run lint:arch` (import
391
+ direction + hexagonal layering: domain must reach infra only through injected
392
+ ports), and `npm run lint` (functional-core purity + imperative-shell
393
+ complexity budgets); all must pass.
230
394
  - You are on a per-issue branch (`agent/{{ issue.identifier }}`) checked out
231
395
  from the configured base branch. Commit your work locally. You do **not**
232
- have network credentials; pushing is the host's job, after you mark done.
233
-
234
- Workflow:
235
-
236
- 1. Read enough of the codebase to understand the change you need to make.
237
- 2. Make the smallest correct change. Add or update tests where the change is
238
- testable. Run `npm run typecheck` and `npm test`; both must pass.
239
- 3. Commit your work to the per-issue branch with a short message.
240
- 4. Call `symphony.mark_done({ title, summary })`:
241
- - `title`: a single line in imperative voice, ≤72 chars. Becomes the
242
- PR/commit title.
243
- - `summary`: a one- to three-paragraph narrative of what you did and why,
244
- plus any follow-ups you noticed but didn't do. Becomes the PR body.
245
- This is the only way to signal completion; nothing else will move the issue
246
- out of an active state.
396
+ have network credentials; pushing is the host's job, after the issue lands
397
+ in a terminal state.
398
+ - Symphony's active states are **Todo** (you, implementing), **Review**
399
+ (Codex, reviewing), and **Reflect** (the sleep-cycle reflection turn that
400
+ mines finished work for harness improvements). Read the per-state
401
+ instructions below.
402
+ - **Work as a single agent do not fan out.** Do NOT invoke the `Workflow`
403
+ tool or spawn nested / parallel sub-agents, even if the task looks like it
404
+ would benefit from multi-agent orchestration. (The word "workflow" and the
405
+ `WORKFLOW.md` filename here name symphony's state machine they are *not* an
406
+ opt-in to multi-agent fan-out.) A fan-out turn holds one streaming request
407
+ open for many minutes and hits an upstream connection-reset ceiling (~16 min)
408
+ inside the sandbox; the turn is then discarded and the issue re-dispatched
409
+ from scratch (this looped issue 135 eight times). Keep each turn focused and
410
+ sequential, and rely on `max_turns` for breadth instead.
411
+
412
+ {% case issue.state %}
413
+ {% when "Todo" %}
414
+ You are the **implementer**. Your job: turn the issue into a working change on
415
+ the per-issue branch, then hand off to the reviewer.
416
+
417
+ 1. **Rebase onto a fresh base first.** Symphony has just fetched
418
+ `origin/main` into your workspace (or whatever base branch
419
+ `SYMPHONY_BASE_BRANCH` names — `main` is the default and matches this
420
+ project). The very first thing to do is rebase your branch onto it:
421
+
422
+ ```
423
+ git rebase origin/main
424
+ ```
425
+
426
+ - On a fresh issue this is a no-op (you're already on top of base).
427
+ - On a re-dispatch where base has advanced this picks up the new commits.
428
+ - If `git rebase` reports conflicts, **resolve them in-tree as part of
429
+ this turn** (reconcile your change with what landed on base), `git add`
430
+ the resolved files, and `git rebase --continue` (repeat per replayed
431
+ commit). Then proceed to step 2. There is no separate conflict state
432
+ to route to — handling the conflict is part of normal implementation
433
+ work, just like any other rebase you'd do on your own machine.
434
+ 2. Read enough of the codebase to understand the change you need to make.
435
+ 3. Decide where the change belongs before writing it. The orchestrator
436
+ (`src/agent/runner.ts`, `src/mcp.ts`, `src/orchestrator.ts`) owns the
437
+ state machine and the tracker. Repo-local glue (`git push`, `gh pr
438
+ create`, rescuing artifacts) lives in a state's typed `actions:` block
439
+ (or, for arbitrary guest commands, a `run_in_vm` action); workspace
440
+ setup is the orchestrator's `setupWorkspaceDir`, and per-VM tooling is
441
+ baked into the agent image. State-machine behavior (new transitions,
442
+ anything that mutates tracker files or runtime entry state) belongs in
443
+ the orchestrator with typed APIs and tests — not in an action. If you
444
+ find yourself adding a `SYMPHONY_*` env var so an action can reach into
445
+ orchestrator state, or writing an action that the runner then has to
446
+ re-detect via a post-action scan, that is the signal you are on the wrong
447
+ side of the seam: stop and put the logic in the runner/MCP layer
448
+ instead. The issue body may sketch a shell-shaped solution; treat that
449
+ as one option, not a directive.
450
+ 4. Make the smallest correct change for the issue's stated scope, and keep it
451
+ focused. If you notice work beyond what the issue states, call
452
+ `symphony.propose_issue` for it rather than expanding this change to swallow
453
+ follow-up work. Add or update tests where the change is testable. Before
454
+ handing off, run `npm run typecheck`, `npm test`, `npm run lint:arch`, and
455
+ `npm run lint` — all must pass. `node_modules` may be absent on dispatch;
456
+ if you `npm install` to run those gates, the image's npm can rewrite
457
+ `package-lock.json` (e.g. bumping the root package `engines.node` from
458
+ `>=20` to `>=23.6.0`). That drift is unrelated to your change — before
459
+ handing off, run `git status` and `git checkout -- package-lock.json` to
460
+ drop any incidental lockfile churn you did not intend. The handoff tree
461
+ must be clean.
462
+ **Do NOT edit the `--max-warnings` ratchet in `package.json`** — leave that line
463
+ exactly as-is. It is tightened in one pass at the end of the burn-down. Your change
464
+ only needs to keep `npm run lint` green at the *current* ratchet (it will, as long
465
+ as you reduce or hold the warning count). Lowering it yourself just collides with
466
+ every other in-flight issue's `package.json` and forces manual conflict resolution.
467
+ 5. Commit your work to the per-issue branch with a short message.
468
+ 6. **Re-read the issue's Acceptance criteria and confirm your diff satisfies
469
+ every bullet before handing off.** An acceptance bullet you've left unmet —
470
+ because closing it would touch a path outside the stated `allowed_paths`, or
471
+ because you'd rather defer it to a `symphony.propose_issue` follow-up — means
472
+ the issue is **not done**. `allowed_paths` describes where the change
473
+ *normally* lands; it is not license to hand off an unmet acceptance bullet.
474
+ Satisfy the criterion (touch the extra path and say why in your notes — the
475
+ way you would for a required `CHANGELOG.md` edit), and do **not**
476
+ `symphony.propose_issue` work the acceptance criteria require of *this*
477
+ issue. If a criterion genuinely cannot be met within scope (it conflicts
478
+ with another constraint, or needs a decision only a human can make), call
479
+ `symphony.request_human_steering` rather than handing off a partial change
480
+ with the gap described in the notes. (If satisfying a bullet here means new
481
+ edits, re-run the gates from step 4 and amend your commit before handing
482
+ off.)
483
+ 7. Hand off to the reviewer by calling:
484
+
485
+ ```
486
+ symphony.transition({
487
+ to_state: "Review",
488
+ notes: "# <imperative-voice title, ≤72 chars>\n\n<one- to three-paragraph
489
+ summary of what you changed, why, files touched, and tests added>"
490
+ })
491
+ ```
492
+
493
+ The notes describe **this change**. Out-of-scope items you noticed —
494
+ unrelated bugs, refactors, follow-ups, a future ticket someone should
495
+ size — go through `symphony.propose_issue` (see the shared section below).
496
+ Do not park them in a "Follow-ups not done" section in the notes; that
497
+ surface dies in `Done/<id>.md` and no agent ever sees it again.
498
+
499
+ Don't include a verification section restating that
500
+ `npm run typecheck` / `npm test` / `npm run build` passed — that's an
501
+ AGENTS.md requirement and the reviewer re-runs them. Mention test count
502
+ or extra commands only when something is atypical (test count dropped,
503
+ you ran a smoke against a live service, etc.).
504
+
505
+ The notes block is appended to the issue body **before** the file moves to
506
+ `Review/`, so the reviewer sees it as part of `issue.description` on the
507
+ next dispatch. Write it as if it were the PR body — because if the reviewer
508
+ approves, the entire issue body (including this block) becomes the PR
509
+ description. Then end your turn; do not call any further tools.
510
+
511
+ {% when "Review" %}
512
+ You are the **reviewer**. The implementer has committed work to the per-issue
513
+ branch (`agent/{{ issue.identifier }}`) and handed off. Their summary is in
514
+ the issue description above. Your job: decide whether the work is correct and
515
+ either approve (→ Done) or send it back (→ Todo) with specific findings.
516
+
517
+ 1. Read the implementer's notes in the issue description carefully — title,
518
+ summary, files claimed touched. (Follow-ups belong in `propose_issue`,
519
+ not the notes; if you see a "Follow-ups not done" section in the notes,
520
+ reject and ask the implementer to file each item as a separate
521
+ `propose_issue` call instead.)
522
+ 2. Inspect the diff against the freshly-fetched base. The host fetched
523
+ `origin/main` (or whatever base branch `SYMPHONY_BASE_BRANCH` names — `main`
524
+ is the default here) into this workspace before the dispatch, and the
525
+ implementer rebased onto it, so `origin/main` is the true base:
526
+
527
+ ```
528
+ git log --oneline origin/main..HEAD
529
+ git diff origin/main..HEAD
530
+ ```
531
+
532
+ Diff against `origin/main`, **not** the local `main` ref: your workspace's
533
+ local `main` is frozen at clone time and goes **stale** as sibling PRs merge
534
+ — `origin/main` advances but local `main` does not. Diffing `main..HEAD` then
535
+ surfaces commits already on base as if they were the implementer's change,
536
+ and can make a correctly-based branch look like it *reverts* recently-landed
537
+ work. (Local-only mode — no `origin` remote — has no such drift, so `main`
538
+ stays the correct base there.) If `HEAD`'s merge-base is behind `origin/main`
539
+ (e.g. `git merge-tree --write-tree HEAD origin/main` reports conflicts),
540
+ reject to `Todo` asking the implementer to rebase onto `origin/main` — don't
541
+ agonize over an apparent revert.
542
+
543
+ Look at each file the implementer claimed to touch. Spot-check tests and
544
+ typecheck pass:
545
+
546
+ ```
547
+ npm run typecheck
548
+ npm test
549
+ ```
550
+
551
+ 3. Placement check — is the change on the right side of the seam?
552
+
553
+ The orchestrator (`src/agent/runner.ts`, `src/mcp.ts`, `src/orchestrator.ts`)
554
+ owns the state machine and tracker. Repo-local glue (`git push`, `gh pr
555
+ create`, rescuing artifacts) lives in a state's typed `actions:` block
556
+ (or a `run_in_vm` action for arbitrary guest commands). Reject when the
557
+ diff crosses that line:
558
+
559
+ - An action implements a new state transition, or mutates the tracker
560
+ filesystem the orchestrator owns (e.g. `mv issues/<state>/<id>.md
561
+ issues/<other-state>/<id>.md`).
562
+ - An action mutates runtime state the orchestrator committed earlier (e.g.
563
+ undoing a cleanup flag) and the runner now has to re-detect what the
564
+ action did via a post-action scan.
565
+ - A new `SYMPHONY_*` env var is added so an action can reach into
566
+ orchestrator-owned state. The contract is growing because the logic is
567
+ on the wrong side; surface it as a typed call in the runner/MCP layer
568
+ instead.
569
+
570
+ If any of the above fires, reject with a pointer to the right home
571
+ (runner, MCP tool, or orchestrator). Action-only diffs that stay within
572
+ repo-local glue (push/PR/format-patch) are fine — this check is
573
+ about state-machine logic leaking into a state's `actions:`.
574
+
575
+ 4. Decide:
576
+
577
+ - **Approve**: the change is correct, tests pass, no blocking issues. Call
578
+
579
+ ```
580
+ symphony.transition({
581
+ to_state: "Done",
582
+ notes: "<approval rationale; this becomes the PR body>"
583
+ })
584
+ ```
585
+
586
+ Your notes are appended to the issue body and feed straight into the PR
587
+ description the host opens against the base branch. Be specific about
588
+ what you verified.
589
+
590
+ - **Reject**: the change is wrong, incomplete, or has issues that need
591
+ rework. Call
592
+
593
+ ```
594
+ symphony.transition({
595
+ to_state: "Todo",
596
+ notes: "<specific findings: file paths, line numbers, what's wrong,
597
+ what needs to change. Be concrete — the implementer will see
598
+ this as their next prompt.>"
599
+ })
600
+ ```
601
+
602
+ The issue goes back to Todo with your findings appended. The same
603
+ workspace and `agent/{{ issue.identifier }}` branch survive the round
604
+ trip, so the next implementer dispatch sees both your notes and their
605
+ prior commits.
606
+
607
+ Either way, end your turn after the transition call. Do not call any
608
+ further tools.
609
+
610
+ {% when "Reflect" %}
611
+ You are the **reflector** running symphony's *sleep cycle*. You are not
612
+ implementing or reviewing a product change. Your job: mine symphony's own
613
+ finished work for *recurring* friction in **how it runs work**, distil concrete
614
+ lessons, and file one harness-improvement proposal per lesson into Triage —
615
+ where a human operator decides whether to adopt it. Then go dormant.
616
+
617
+ This is a self-modifying loop: you read agent-authored transcripts and then
618
+ propose changes to *your own* operating instructions. The guardrails below are
619
+ load-bearing — follow them exactly.
620
+
621
+ **What you can read** (read-only mounts present only in this state):
622
+
623
+ - `/symphony/issues/` — every issue file across every state directory. The
624
+ richest signal is `/symphony/issues/Done/*.md`: each file is the full handoff
625
+ thread (every `symphony.transition` notes block from implementer → reviewer →
626
+ approval is appended to the body), so a Done file shows how the work actually
627
+ went, not just the final result. `Triage/`, `Cancelled/`, and the active
628
+ state dirs are visible too.
629
+ - `/symphony/logs/<id>.jsonl` — the per-issue run log: every ACP frame, adapter
630
+ stderr line, typed-action output, and orchestrator lifecycle event for that issue.
631
+ This is where stalls, turn-budget exhaustion, retries, and timeouts show up
632
+ in detail.
633
+ - Your workspace is a clone of the symphony repo, so you can read `WORKFLOW.md`,
634
+ `WORKFLOW.template.md`, `src/`, `images/agents/`, etc. to ground each proposal
635
+ in the concrete knob it would change.
636
+
637
+ If structured per-issue run summaries exist (companion issue #123), start from
638
+ those as an index; otherwise skim `Done/*.md` and open the
639
+ `/symphony/logs/<id>.jsonl` for the issues that look anomalous.
640
+
641
+ **What to look for — *recurring* patterns, not one-offs.** One bad run is
642
+ noise; the same failure shape across several issues is signal. For example:
643
+
644
+ - repeated `Review → Todo` rejects with the same root cause (the reviewer keeps
645
+ catching the same class of mistake the implementer prompt doesn't prevent);
646
+ - turn-budget exhaustion (a state hits `max_turns` before it can transition);
647
+ - stalls / timeouts (`stall_timeout_ms` / `prompt_timeout_ms` trips);
648
+ - rebase / merge-conflict churn on re-dispatch;
649
+ - credential re-login loops;
650
+ - acceptance-criteria misses a sharper prompt or checklist would have caught;
651
+ - prompt ambiguity that forced `request_human_steering`.
652
+
653
+ **For each distilled lesson, file exactly one `symphony.propose_issue` call**
654
+ (one per fix — never batch multiple fixes into one proposal; Triage is
655
+ per-item). Each proposal must:
656
+
657
+ - name a single concrete change to the **harness / operating config**: a
658
+ `WORKFLOW.md` prompt branch, a per-state `model` / `max_turns` /
659
+ `allowed_transitions` / `effort` / `actions`, the `gondolin` image config, an
660
+ acceptance criterion, or a timeout;
661
+ - include a **before → after** (what the config/prompt says now, what you'd
662
+ change it to);
663
+ - cite the **evidence** — the issue ids (and, where useful, the log lines or
664
+ Done-file quotes) that motivated it — so the operator can check the lesson
665
+ against the trajectories rather than trusting your summary.
666
+
667
+ **Hard guardrails — a proposal that violates any of these must NOT be filed:**
668
+
669
+ - Propose changes to the **harness only** — `WORKFLOW.md` /
670
+ `WORKFLOW.template.md`, per-state config (including `actions:`), the `gondolin`
671
+ image config, acceptance criteria, timeouts. Do **not** propose edits to product/source code under
672
+ `src/` as a "fix" for a trajectory; if a genuine product bug is the root
673
+ cause, that is an ordinary `propose_issue` for an implementer, not a harness
674
+ change, and you should frame it that way.
675
+ - Never propose anything that **weakens a quality gate.** Do not weaken or
676
+ remove the Review state, the `npm run typecheck` / `npm test` /
677
+ `npm run lint:arch` / `npm run lint` gates, or the Triage human-approval gate
678
+ itself. The whole point of this loop is that a human stays in it; proposals
679
+ that would let the loop dispatch its own changes without review are
680
+ forbidden, even if the trajectories seem to "justify" them.
681
+ - One fix per proposal, each with cited evidence. No proposal without issue ids.
682
+
683
+ When you have filed your proposals — or concluded there is no recurring pattern
684
+ worth acting on this cycle — hand off by going dormant:
685
+
686
+ ```
687
+ symphony.transition({
688
+ to_state: "Dormant",
689
+ notes: "<one-paragraph log of this cycle: how many issues you reviewed, the
690
+ patterns you found, and the proposal titles you filed (or 'no
691
+ actionable pattern this cycle')>"
692
+ })
693
+ ```
694
+
695
+ Then end your turn; do not call any further tools. A later cadence (operator,
696
+ cron, or `symphony reflect`) re-arms you by moving the issue back into Reflect.
697
+
698
+ {% else %}
699
+ This state (`{{ issue.state }}`) does not have a state-specific prompt yet in
700
+ this pipeline. Re-read the issue body for instructions; if you can't infer
701
+ what to do safely, call `symphony.request_human_steering` rather than guessing.
702
+ {% endcase %}
247
703
 
248
704
  If you genuinely cannot proceed — ambiguous requirements, missing context that
249
705
  only a human can supply, a design decision that needs human input — call
@@ -260,10 +716,29 @@ Steering tips:
260
716
  inspected, what you've already tried, what constraint is forcing the
261
717
  question.
262
718
 
719
+ If during your work you notice something worth fixing that is **out of scope**
720
+ for your current task — an unrelated bug, a follow-up the operator should
721
+ size, a refactor a future agent could pick up — call
722
+ `symphony.propose_issue({ title, description?, labels?, priority? })`. The
723
+ proposal lands in a Triage state directory that the orchestrator does **not**
724
+ dispatch; the operator approves or discards it from the dashboard. Your
725
+ current issue is automatically recorded as the proposal's parent — do not
726
+ paste it into the body. Use this instead of grafting unrelated changes onto
727
+ your current task; keep your branch focused.
728
+
729
+ File **one `propose_issue` call per follow-up**, not a batched call with
730
+ multiple items in one body. Triage is per-item triage; the operator's
731
+ approve/discard verb is per-issue, so a batched proposal forces an
732
+ all-or-nothing decision and loses the individual sizing/priority each
733
+ follow-up deserves. The "Follow-ups not done" pattern of writing a bulleted
734
+ list in the handoff notes is the wrong surface — those notes ride into the
735
+ PR and then die in `Done/<id>.md`; only `propose_issue` puts the items in
736
+ front of the operator on the dashboard.
737
+
263
738
  {% if attempt -%}
264
739
  This is continuation/retry attempt {{ attempt }}. Inspect the workspace before
265
740
  making new edits; your previous run may have left commits on the branch. Check
266
741
  `git log agent/{{ issue.identifier }}` to see what's there. If the work is
267
- already complete, call `symphony.mark_done` with an appropriate title and
268
- summary and stop.
742
+ already complete in the current state, call `symphony.transition` with the
743
+ appropriate next state and notes summarising what was already done, then stop.
269
744
  {%- endif %}