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.
- package/AGENTS.md +105 -38
- package/PRODUCT.md +2 -1
- package/README.md +195 -98
- package/SPEC.md +543 -1915
- package/WORKFLOW.md +654 -179
- package/WORKFLOW.template.md +761 -121
- package/dist/acp-bridge.js +324 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/actions/cache.js +191 -0
- package/dist/actions/cache.js.map +1 -0
- package/dist/actions/effects.js +41 -0
- package/dist/actions/effects.js.map +1 -0
- package/dist/actions/executor.js +570 -0
- package/dist/actions/executor.js.map +1 -0
- package/dist/actions/index.js +13 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/parsing.js +273 -0
- package/dist/actions/parsing.js.map +1 -0
- package/dist/actions/predicate-env.js +27 -0
- package/dist/actions/predicate-env.js.map +1 -0
- package/dist/actions/predicates.js +49 -0
- package/dist/actions/predicates.js.map +1 -0
- package/dist/actions/templating.js +66 -0
- package/dist/actions/templating.js.map +1 -0
- package/dist/actions/types.js +15 -0
- package/dist/actions/types.js.map +1 -0
- package/dist/agent/acp.js +232 -63
- package/dist/agent/acp.js.map +1 -1
- package/dist/agent/adapter-names.js +159 -0
- package/dist/agent/adapter-names.js.map +1 -0
- package/dist/agent/adapters.js +338 -102
- package/dist/agent/adapters.js.map +1 -1
- package/dist/agent/credential-extractors.js +342 -0
- package/dist/agent/credential-extractors.js.map +1 -0
- package/dist/agent/credential-secrets.js +628 -0
- package/dist/agent/credential-secrets.js.map +1 -0
- package/dist/agent/credential-ticker.js +57 -0
- package/dist/agent/credential-ticker.js.map +1 -0
- package/dist/agent/gondolin-creds-staging.js +356 -0
- package/dist/agent/gondolin-creds-staging.js.map +1 -0
- package/dist/agent/gondolin-dispatch.js +375 -0
- package/dist/agent/gondolin-dispatch.js.map +1 -0
- package/dist/agent/gondolin.js +124 -0
- package/dist/agent/gondolin.js.map +1 -0
- package/dist/agent/runner-decisions.js +134 -0
- package/dist/agent/runner-decisions.js.map +1 -0
- package/dist/agent/runner.js +1352 -290
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-call-summary.js +102 -0
- package/dist/agent/tool-call-summary.js.map +1 -0
- package/dist/agent/vm-acp-mapping.js +73 -0
- package/dist/agent/vm-acp-mapping.js.map +1 -0
- package/dist/agent/vm-guards.js +262 -0
- package/dist/agent/vm-guards.js.map +1 -0
- package/dist/agent/vm-port.js +22 -0
- package/dist/agent/vm-port.js.map +1 -0
- package/dist/agent/vm-process-registry.js +79 -0
- package/dist/agent/vm-process-registry.js.map +1 -0
- package/dist/bin/cli-args.js +105 -0
- package/dist/bin/cli-args.js.map +1 -0
- package/dist/bin/symphony.js +719 -130
- package/dist/bin/symphony.js.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-disk.js +135 -0
- package/dist/http-disk.js.map +1 -0
- package/dist/http-handlers.js +180 -0
- package/dist/http-handlers.js.map +1 -0
- package/dist/http.js +1476 -764
- package/dist/http.js.map +1 -1
- package/dist/issues.js +178 -0
- package/dist/issues.js.map +1 -0
- package/dist/logging.js +163 -5
- package/dist/logging.js.map +1 -1
- package/dist/mcp.js +391 -163
- package/dist/mcp.js.map +1 -1
- package/dist/memory.js +85 -0
- package/dist/memory.js.map +1 -0
- package/dist/orchestrator-decisions.js +331 -0
- package/dist/orchestrator-decisions.js.map +1 -0
- package/dist/orchestrator.js +1189 -303
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt.js +5 -5
- package/dist/prompt.js.map +1 -1
- package/dist/reconciler/cache.js +65 -0
- package/dist/reconciler/cache.js.map +1 -0
- package/dist/reconciler/index.js +448 -0
- package/dist/reconciler/index.js.map +1 -0
- package/dist/reconciler/ledger.js +131 -0
- package/dist/reconciler/ledger.js.map +1 -0
- package/dist/reconciler/pr-adapters.js +174 -0
- package/dist/reconciler/pr-adapters.js.map +1 -0
- package/dist/reconciler/pr-decide.js +167 -0
- package/dist/reconciler/pr-decide.js.map +1 -0
- package/dist/reconciler/pr.js +422 -0
- package/dist/reconciler/pr.js.map +1 -0
- package/dist/reconciler/types.js +12 -0
- package/dist/reconciler/types.js.map +1 -0
- package/dist/reconciler/vm.js +243 -0
- package/dist/reconciler/vm.js.map +1 -0
- package/dist/reconciler/workspace-defaults.js +83 -0
- package/dist/reconciler/workspace-defaults.js.map +1 -0
- package/dist/reconciler/workspace.js +272 -0
- package/dist/reconciler/workspace.js.map +1 -0
- package/dist/runlog.js +403 -0
- package/dist/runlog.js.map +1 -0
- package/dist/scaffold.js +165 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/trackers/local.js +234 -133
- package/dist/trackers/local.js.map +1 -1
- package/dist/trackers/types.js +1 -1
- package/dist/trackers/types.js.map +1 -1
- package/dist/types.js +1 -1
- package/dist/util/clock.js +12 -0
- package/dist/util/clock.js.map +1 -0
- package/dist/util/crypto.js +25 -0
- package/dist/util/crypto.js.map +1 -0
- package/dist/util/frontmatter.js +70 -0
- package/dist/util/frontmatter.js.map +1 -0
- package/dist/util/fs-issues.js +22 -0
- package/dist/util/fs-issues.js.map +1 -0
- package/dist/util/process.js +152 -0
- package/dist/util/process.js.map +1 -0
- package/dist/util/workspace-key.js +10 -0
- package/dist/util/workspace-key.js.map +1 -0
- package/dist/workflow-loader.js +147 -0
- package/dist/workflow-loader.js.map +1 -0
- package/dist/workflow.js +656 -219
- package/dist/workflow.js.map +1 -1
- package/dist/workspace-types.js +8 -0
- package/dist/workspace-types.js.map +1 -0
- package/dist/workspace.js +367 -120
- package/dist/workspace.js.map +1 -1
- package/package.json +14 -6
- package/scripts/vm-agent.mjs +211 -0
- package/dist/agent/codex.js +0 -439
- package/dist/agent/codex.js.map +0 -1
- package/dist/agent/smolvm.js +0 -174
- package/dist/agent/smolvm.js.map +0 -1
- package/scripts/build-vm.sh +0 -67
package/dist/mcp.js
CHANGED
|
@@ -1,110 +1,174 @@
|
|
|
1
1
|
// Per-issue MCP server. Each active dispatch registers an entry here; the agent inside
|
|
2
|
-
// the
|
|
3
|
-
// sees
|
|
2
|
+
// the Gondolin VM connects to /api/v1/issues/<id>/mcp with the per-dispatch bearer token and
|
|
3
|
+
// sees these tools:
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// transition(to_state, notes?) ─ atomic file move into another declared state,
|
|
6
|
+
// optionally appending notes to the issue body
|
|
7
|
+
// first. Sets the `transitioned` flag so the runner
|
|
7
8
|
// exits cleanly on next post-turn check.
|
|
8
9
|
// request_human_steering(question, context?)
|
|
9
10
|
// ─ stashes the question on the RunningEntry, returns
|
|
10
11
|
// an ack to the agent. The runner then pauses the
|
|
11
12
|
// autonomous loop and awaits a human reply submitted
|
|
12
13
|
// via POST /api/v1/issues/<id>/steering-reply.
|
|
14
|
+
// propose_issue(title, ...) ─ drops a new issue file into the first declared
|
|
15
|
+
// holding state directory for operator triage.
|
|
13
16
|
//
|
|
14
17
|
// The URL is the capability: the agent only ever knows its own /<id>/mcp endpoint. The
|
|
15
18
|
// bearer token is belt-and-braces in case a non-agent caller can reach 8787.
|
|
16
19
|
//
|
|
17
20
|
// MCP wire format here is JSON-RPC 2.0 over HTTP (the "Streamable HTTP" transport's
|
|
18
|
-
// non-SSE subset). We implement only the subset our
|
|
21
|
+
// non-SSE subset). We implement only the subset our tools need: initialize, tools/list,
|
|
19
22
|
// tools/call, plus a polite notifications/initialized acknowledgement.
|
|
20
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
-
import { Buffer } from 'node:buffer';
|
|
22
|
-
import { lstat, mkdir, writeFile } from 'node:fs/promises';
|
|
23
|
-
import path from 'node:path';
|
|
24
23
|
import { log } from './logging.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
* Shared between the orchestrator (which snapshots this at dispatch time onto
|
|
30
|
-
* the RunningEntry) and the registry's fallback path. Prefers an entry whose
|
|
31
|
-
* lowercase form is "done"; falls back to the first configured terminal state,
|
|
32
|
-
* then to a hardcoded "Done" when the list is empty.
|
|
33
|
-
*/
|
|
34
|
-
export function pickTerminalTarget(terminalStates) {
|
|
35
|
-
const preferred = terminalStates.find((s) => s.toLowerCase() === 'done');
|
|
36
|
-
if (preferred)
|
|
37
|
-
return preferred;
|
|
38
|
-
if (terminalStates.length > 0)
|
|
39
|
-
return terminalStates[0];
|
|
40
|
-
return TERMINAL_STATE_FOR_DONE_DEFAULT;
|
|
41
|
-
}
|
|
24
|
+
import { writeIssueFile, pickHoldingState, NoHoldingStateError } from './issues.js';
|
|
25
|
+
import { realClock, isoFromClock } from './util/clock.js';
|
|
26
|
+
import { realCrypto } from './util/crypto.js';
|
|
42
27
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
43
28
|
const TOOL_LIST = [
|
|
44
29
|
{
|
|
45
|
-
name: '
|
|
46
|
-
description: '
|
|
30
|
+
name: 'request_human_steering',
|
|
31
|
+
description: 'Pause work and ask the human operator a question. Your current turn will end immediately after this returns; the human response will arrive as the prompt for your next turn. Use only when you cannot proceed without a decision a human must make.',
|
|
47
32
|
inputSchema: {
|
|
48
33
|
type: 'object',
|
|
49
34
|
properties: {
|
|
50
|
-
|
|
35
|
+
question: {
|
|
51
36
|
type: 'string',
|
|
52
|
-
description: '
|
|
37
|
+
description: 'The question to ask the human. Be specific about what decision they need to make.',
|
|
53
38
|
},
|
|
54
|
-
|
|
39
|
+
context: {
|
|
55
40
|
type: 'string',
|
|
56
|
-
description: '
|
|
41
|
+
description: 'Optional: relevant context the human needs to answer (file paths, options considered, etc.).',
|
|
57
42
|
},
|
|
58
43
|
},
|
|
59
|
-
required: ['
|
|
44
|
+
required: ['question'],
|
|
60
45
|
},
|
|
61
46
|
},
|
|
62
47
|
{
|
|
63
|
-
name: '
|
|
64
|
-
description: '
|
|
48
|
+
name: 'transition',
|
|
49
|
+
description: 'Move this issue into another declared state, optionally appending notes to its body for the next agent. Use this for handoffs — implementer → reviewer, reviewer → implementer (rework), or implementer → terminal Done — rather than only at the very end of the work. Notes are appended to the issue file BEFORE the state move, so the next dispatch sees them in `issue.description` along with everything the previous agents wrote. `to_state` must be one of the states declared in WORKFLOW.md; if the current state declares `allowed_transitions`, `to_state` must also be in that list. On a terminal target, the workspace is removed after your turn ends and no further turns will be dispatched. On a non-terminal target, the same workspace and `agent/<id>` git branch survive into the next state.',
|
|
65
50
|
inputSchema: {
|
|
66
51
|
type: 'object',
|
|
67
52
|
properties: {
|
|
68
|
-
|
|
53
|
+
to_state: {
|
|
69
54
|
type: 'string',
|
|
70
|
-
description: '
|
|
55
|
+
description: 'Declared state to transition into (case-insensitive match against `states:` in WORKFLOW.md). Examples: "Review", "Done", "Todo".',
|
|
71
56
|
},
|
|
72
|
-
|
|
57
|
+
notes: {
|
|
73
58
|
type: 'string',
|
|
74
|
-
description: 'Optional
|
|
59
|
+
description: 'Optional markdown notes to append to the issue body before the move. These become part of the issue description the next agent (in `to_state`) reads. Use this for review findings, rework instructions, or PR-body content on a terminal transition.',
|
|
75
60
|
},
|
|
76
61
|
},
|
|
77
|
-
required: ['
|
|
62
|
+
required: ['to_state'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'propose_issue',
|
|
67
|
+
description: 'Propose a new issue for the human to triage. The orchestrator drops the proposal into a non-active "Triage" state directory and does NOT dispatch it — the operator approves (moves to the active queue) or discards it from the dashboard. Use this when you notice work that is out of scope for your current task: an unrelated bug, a follow-up the operator should size, a refactor a future agent could pick up. The parent issue you are working on is automatically recorded; do not paste it into the body.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
title: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'Short single-line title in imperative voice (≤72 chars recommended). Example: "Fix race condition in workspace cleanup".',
|
|
74
|
+
},
|
|
75
|
+
description: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Optional multi-paragraph body for the issue. Explain what you observed, where (file paths), and why it is worth handling separately from your current task.',
|
|
78
|
+
},
|
|
79
|
+
labels: {
|
|
80
|
+
type: 'array',
|
|
81
|
+
items: { type: 'string' },
|
|
82
|
+
description: 'Optional list of label strings.',
|
|
83
|
+
},
|
|
84
|
+
priority: {
|
|
85
|
+
type: 'number',
|
|
86
|
+
description: 'Optional integer priority hint (tracker-defined meaning).',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ['title'],
|
|
78
90
|
},
|
|
79
91
|
},
|
|
80
92
|
];
|
|
81
93
|
export class McpRegistry {
|
|
82
94
|
tracker;
|
|
83
|
-
opts;
|
|
84
95
|
byIdentifier = new Map();
|
|
85
96
|
effectivePort = null;
|
|
86
|
-
|
|
97
|
+
// Live state-config map, kept in sync with the orchestrator's view via
|
|
98
|
+
// `updateStates`. Used by `transition` to validate `to_state` and to resolve
|
|
99
|
+
// the role of the target (terminal => set cleanup_workspace_on_exit). An
|
|
100
|
+
// empty map means every transition call lands in the "unknown_state" branch,
|
|
101
|
+
// which is the correct behaviour: without a declared states map, the agent
|
|
102
|
+
// has no valid target to name.
|
|
103
|
+
states = {};
|
|
104
|
+
// Issue 38 (generalised in issue 144): when the PR engine is enabled,
|
|
105
|
+
// transitions into the merge state do NOT flip `cleanup_workspace_on_exit`
|
|
106
|
+
// even though the target state's role is `terminal`. The pr resource owns
|
|
107
|
+
// the workspace lifecycle for those issues — it rebases inside the workspace
|
|
108
|
+
// and cleans it up after the PR merges/closes. Without this gate the
|
|
109
|
+
// terminal-cleanup path would reap the workspace before the engine could
|
|
110
|
+
// ever use it. The caller (bin/symphony.ts) derives `mergeState` from
|
|
111
|
+
// `derivePrRouting(cfg.states).mergeState` and `enabled` from `cfg.pr.enabled`.
|
|
112
|
+
prMerge = { enabled: false, mergeState: null };
|
|
113
|
+
// Injected wall clock. Tests pin time; production wires `Date.now` (or
|
|
114
|
+
// accepts the realClock default). Used to stamp `proposed_at` on
|
|
115
|
+
// propose_issue submissions so the core is deterministic under test.
|
|
116
|
+
now;
|
|
117
|
+
// Injected crypto port. Production wires `realCrypto` (randomBytes +
|
|
118
|
+
// timingSafeEqual); tests can pin deterministic tokens to assert wire
|
|
119
|
+
// format without sampling randomness.
|
|
120
|
+
crypto;
|
|
121
|
+
constructor(tracker, opts = {}) {
|
|
87
122
|
this.tracker = tracker;
|
|
88
|
-
|
|
123
|
+
if (opts.states)
|
|
124
|
+
this.states = opts.states;
|
|
125
|
+
if (opts.prMerge)
|
|
126
|
+
this.prMerge = opts.prMerge;
|
|
127
|
+
this.now = opts.now ?? realClock;
|
|
128
|
+
this.crypto = opts.crypto ?? realCrypto;
|
|
89
129
|
}
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Push the latest state-config map (and optional PR merge-cleanup gate) in
|
|
132
|
+
* after a workflow reload. Tests that don't care about the PR engine can call
|
|
133
|
+
* with a single argument; production wires both so reloads that flip
|
|
134
|
+
* `pr.enabled` or move the merge state take effect on subsequent transitions.
|
|
135
|
+
*/
|
|
136
|
+
updateStates(states, prMerge) {
|
|
137
|
+
this.states = states;
|
|
138
|
+
if (prMerge !== undefined) {
|
|
139
|
+
this.prMerge = prMerge ?? { enabled: false, mergeState: null };
|
|
140
|
+
}
|
|
92
141
|
}
|
|
93
142
|
/** Called once after the HTTP server binds, so URL construction uses the real port. */
|
|
94
143
|
setEffectivePort(port) {
|
|
95
144
|
this.effectivePort = port;
|
|
96
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The bound HTTP port, or null until the server binds. Exposed so the Gondolin
|
|
148
|
+
* dispatch can wire the guest→host MCP `tcp.hosts` tunnel to the real
|
|
149
|
+
* `mcp.host:effectivePort` while the guest dials a fixed synthetic host.
|
|
150
|
+
*/
|
|
151
|
+
getEffectivePort() {
|
|
152
|
+
return this.effectivePort;
|
|
153
|
+
}
|
|
97
154
|
/**
|
|
98
155
|
* Build the URL the ACP agent will be told to POST to. Returns null when no HTTP server
|
|
99
156
|
* is available and no explicit URL is configured; the runner uses that to skip MCP
|
|
100
157
|
* injection (with a warning) instead of advertising an unreachable endpoint.
|
|
158
|
+
*
|
|
159
|
+
* `baseOverride` (e.g. the Gondolin guest synthetic base `http://symphony-mcp:7001`)
|
|
160
|
+
* wins over both `explicit_host_url` and the real host:port: under Gondolin the
|
|
161
|
+
* guest cannot reach the host loopback directly, so the runner passes the synthetic
|
|
162
|
+
* base that a per-dispatch `tcp.hosts` entry tunnels to the real MCP server.
|
|
101
163
|
*/
|
|
102
|
-
buildUrl(identifier, mcp) {
|
|
103
|
-
const base =
|
|
104
|
-
?
|
|
105
|
-
:
|
|
106
|
-
?
|
|
107
|
-
:
|
|
164
|
+
buildUrl(identifier, mcp, baseOverride) {
|
|
165
|
+
const base = baseOverride
|
|
166
|
+
? baseOverride.replace(/\/+$/, '')
|
|
167
|
+
: mcp.explicit_host_url
|
|
168
|
+
? mcp.explicit_host_url.replace(/\/+$/, '')
|
|
169
|
+
: this.effectivePort === null
|
|
170
|
+
? null
|
|
171
|
+
: `http://${mcp.host}:${this.effectivePort}`;
|
|
108
172
|
if (!base)
|
|
109
173
|
return null;
|
|
110
174
|
return `${base}/api/v1/issues/${encodeURIComponent(identifier)}/mcp`;
|
|
@@ -114,18 +178,18 @@ export class McpRegistry {
|
|
|
114
178
|
* agent's MCP server config via ACP's session/new mcpServers field.
|
|
115
179
|
*/
|
|
116
180
|
activate(entry) {
|
|
117
|
-
const token =
|
|
181
|
+
const token = this.crypto.newToken();
|
|
118
182
|
entry.mcp_token = token;
|
|
119
|
-
entry.
|
|
183
|
+
entry.transitioned = false;
|
|
120
184
|
entry.steering_requested = false;
|
|
121
185
|
entry.steering_question = null;
|
|
122
186
|
entry.steering_context = null;
|
|
123
|
-
// Carry the dispatch-time
|
|
124
|
-
// this.tracker.currentRoot()
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
187
|
+
// Carry the dispatch-time tracker-root snapshot through verbatim. Reading
|
|
188
|
+
// this.tracker.currentRoot() here would be wrong: activate runs AFTER
|
|
189
|
+
// workspace setup and Gondolin VM bring-up — a window during
|
|
190
|
+
// which a WORKFLOW.md reload can mutate tracker.root. The dispatch-time
|
|
191
|
+
// value is the only one that accurately reflects the world the run was
|
|
192
|
+
// started in.
|
|
129
193
|
const active = {
|
|
130
194
|
issueId: entry.issue_id,
|
|
131
195
|
identifier: entry.identifier,
|
|
@@ -133,12 +197,10 @@ export class McpRegistry {
|
|
|
133
197
|
token,
|
|
134
198
|
pendingReply: null,
|
|
135
199
|
trackerRootSnapshot: entry.tracker_root_at_dispatch,
|
|
136
|
-
terminalTarget: entry.terminal_target_at_dispatch,
|
|
137
200
|
};
|
|
138
201
|
this.byIdentifier.set(entry.identifier, active);
|
|
139
202
|
log.debug('mcp activated', {
|
|
140
203
|
issue_identifier: entry.identifier,
|
|
141
|
-
terminal_target: active.terminalTarget,
|
|
142
204
|
tracker_root: active.trackerRootSnapshot,
|
|
143
205
|
});
|
|
144
206
|
return token;
|
|
@@ -212,7 +274,7 @@ export class McpRegistry {
|
|
|
212
274
|
if (!active) {
|
|
213
275
|
return makeError(getId(body), -32001, 'issue not active');
|
|
214
276
|
}
|
|
215
|
-
if (!
|
|
277
|
+
if (!this.crypto.constantTimeEqual(active.token, token)) {
|
|
216
278
|
return makeError(getId(body), -32002, 'invalid token');
|
|
217
279
|
}
|
|
218
280
|
if (!isRpcRequest(body)) {
|
|
@@ -256,12 +318,15 @@ export class McpRegistry {
|
|
|
256
318
|
const params = (body.params ?? {});
|
|
257
319
|
const name = params.name;
|
|
258
320
|
const args = params.arguments ?? {};
|
|
259
|
-
if (name === '
|
|
260
|
-
return await this.
|
|
321
|
+
if (name === 'transition') {
|
|
322
|
+
return await this.callTransition(active, id, args);
|
|
261
323
|
}
|
|
262
324
|
if (name === 'request_human_steering') {
|
|
263
325
|
return this.callRequestHumanSteering(active, id, args);
|
|
264
326
|
}
|
|
327
|
+
if (name === 'propose_issue') {
|
|
328
|
+
return await this.callProposeIssue(active, id, args);
|
|
329
|
+
}
|
|
265
330
|
return makeError(id, -32601, `unknown tool: ${name}`);
|
|
266
331
|
}
|
|
267
332
|
case 'ping': {
|
|
@@ -284,71 +349,135 @@ export class McpRegistry {
|
|
|
284
349
|
return makeError(id, -32603, err.message);
|
|
285
350
|
}
|
|
286
351
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Shared file-move + flag-flip path used by `transition`. Returns the tracker's
|
|
354
|
+
* resolved {from, to, newPath} on success, throwing the underlying TrackerError
|
|
355
|
+
* on failure so the caller can wrap it in a tool-error response.
|
|
356
|
+
*
|
|
357
|
+
* Cleanup-on-exit is decided by the role of the canonical target state: terminal
|
|
358
|
+
* targets clean the workspace, active/holding targets preserve it so the same
|
|
359
|
+
* `agent/<id>` git branch survives across the handoff. Callers must have already
|
|
360
|
+
* resolved `toState` to its canonical declared name via `canonicalStateName`, so
|
|
361
|
+
* this routine assumes the lookup is in the states map.
|
|
362
|
+
*/
|
|
363
|
+
async performTransition(active, toState, notes, actor) {
|
|
364
|
+
if (!this.tracker.moveIssueToState) {
|
|
365
|
+
throw new Error('this tracker does not support state transitions');
|
|
295
366
|
}
|
|
296
|
-
|
|
297
|
-
|
|
367
|
+
const fromRoot = active.trackerRootSnapshot ?? undefined;
|
|
368
|
+
const fromState = active.entry.issue.state;
|
|
369
|
+
const result = await this.tracker.moveIssueToState(active.issueId, toState, {
|
|
370
|
+
fromRoot,
|
|
371
|
+
fromState,
|
|
372
|
+
notes: notes.length > 0 ? notes : undefined,
|
|
373
|
+
actor,
|
|
374
|
+
});
|
|
375
|
+
active.entry.transitioned = true;
|
|
376
|
+
// Look up the canonical declared name (preserving operator-supplied casing)
|
|
377
|
+
// so the role lookup matches the workflow's `states:` map.
|
|
378
|
+
const stateMap = this.states;
|
|
379
|
+
const canonicalName = canonicalStateName(stateMap, result.toState);
|
|
380
|
+
const targetIsTerminal = canonicalName !== null && stateMap[canonicalName].role === 'terminal';
|
|
381
|
+
// Issue 38 / 144: suppress terminal cleanup when the PR engine owns this
|
|
382
|
+
// state's workspace. The pr resource will reap the workspace once the
|
|
383
|
+
// PR has merged (or been closed). Other terminal states (Cancelled
|
|
384
|
+
// typically) still cleanup immediately — the close path only needs to talk
|
|
385
|
+
// to GitHub, not the workspace.
|
|
386
|
+
const suppressCleanupForMerge = targetIsTerminal &&
|
|
387
|
+
canonicalName !== null &&
|
|
388
|
+
this.prMerge.enabled &&
|
|
389
|
+
this.prMerge.mergeState !== null &&
|
|
390
|
+
canonicalName.toLowerCase() === this.prMerge.mergeState.toLowerCase();
|
|
391
|
+
active.entry.cleanup_workspace_on_exit = targetIsTerminal && !suppressCleanupForMerge;
|
|
392
|
+
// Mutate the entry's view of the issue's state so downstream code (runner's
|
|
393
|
+
// cleanup, orchestrator's terminal-state workspace removal) resolves the right
|
|
394
|
+
// state's `actions:`. Without this, the terminal-state cleanup actions would
|
|
395
|
+
// resolve against the pre-transition state and a terminal-state handoff
|
|
396
|
+
// (e.g. Done's push_branch + create_pr_if_missing) would never fire.
|
|
397
|
+
active.entry.issue.state = canonicalName ?? result.toState;
|
|
398
|
+
// Stash the transition so the orchestrator shell can fold it into the run
|
|
399
|
+
// log as a `transition` lifecycle event after the attempt unwinds (issue
|
|
400
|
+
// 123). Pure here — no IO; the run-log write happens host-side.
|
|
401
|
+
active.entry.last_transition = {
|
|
402
|
+
from_state: result.fromState,
|
|
403
|
+
to_state: canonicalName ?? result.toState,
|
|
404
|
+
notes,
|
|
405
|
+
actor,
|
|
406
|
+
terminal: targetIsTerminal,
|
|
407
|
+
};
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* `symphony.transition({ to_state, notes? })`: validate the target against the
|
|
412
|
+
* declared `states:` map + the current state's `allowed_transitions` list, then
|
|
413
|
+
* delegate to `performTransition` which owns the actual file move and flag flip.
|
|
414
|
+
*
|
|
415
|
+
* Validation failures return MCP tool-result errors (`isError: true`) with a
|
|
416
|
+
* human-readable text block AND a structured JSON block describing the error
|
|
417
|
+
* shape. This is NOT a JSON-RPC `error` envelope — the SDK delivers it as a
|
|
418
|
+
* normal tool result and the agent reads the structured payload to pick a valid
|
|
419
|
+
* target on its next call. `transitioned` stays false; no file is touched.
|
|
420
|
+
*/
|
|
421
|
+
async callTransition(active, id, args) {
|
|
422
|
+
const toStateRaw = typeof args.to_state === 'string' ? args.to_state.trim() : '';
|
|
423
|
+
const notes = typeof args.notes === 'string' ? args.notes : '';
|
|
424
|
+
if (!toStateRaw) {
|
|
425
|
+
return makeToolError(id, 'to_state is required and must be a non-empty string');
|
|
298
426
|
}
|
|
299
427
|
if (!this.tracker.moveIssueToState) {
|
|
300
428
|
return makeToolError(id, 'this tracker does not support state transitions');
|
|
301
429
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const target = active.terminalTarget;
|
|
312
|
-
const fromRoot = active.trackerRootSnapshot ?? undefined;
|
|
313
|
-
const fromState = active.entry.issue.state;
|
|
314
|
-
try {
|
|
315
|
-
const result = await this.tracker.moveIssueToState(active.issueId, target, {
|
|
316
|
-
fromRoot,
|
|
317
|
-
fromState,
|
|
430
|
+
const stateMap = this.states;
|
|
431
|
+
const declaredNames = Object.keys(stateMap);
|
|
432
|
+
const canonicalTarget = canonicalStateName(stateMap, toStateRaw);
|
|
433
|
+
if (canonicalTarget === null) {
|
|
434
|
+
const text = `state "${toStateRaw}" is not declared. declared: ${declaredNames.length > 0 ? declaredNames.join(', ') : '<none>'}`;
|
|
435
|
+
log.info('mcp transition rejected: unknown_state', {
|
|
436
|
+
issue_identifier: active.identifier,
|
|
437
|
+
requested_to_state: toStateRaw,
|
|
438
|
+
declared_states: declaredNames,
|
|
318
439
|
});
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
440
|
+
return makeStructuredToolError(id, text, {
|
|
441
|
+
error: 'unknown_state',
|
|
442
|
+
declared_states: declaredNames,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const fromStateRaw = active.entry.issue.state;
|
|
446
|
+
const canonicalFrom = canonicalStateName(stateMap, fromStateRaw);
|
|
447
|
+
if (canonicalFrom !== null) {
|
|
448
|
+
// Per the brief: `allowed_transitions: null | undefined` => "any declared
|
|
449
|
+
// state is reachable"; a present array => restrict to that list (empty
|
|
450
|
+
// list => no transitions out, agent must wait for human cleanup).
|
|
451
|
+
const allowed = stateMap[canonicalFrom].allowed_transitions;
|
|
452
|
+
if (allowed) {
|
|
453
|
+
const allowedLower = new Set(allowed.map((s) => s.toLowerCase()));
|
|
454
|
+
if (!allowedLower.has(canonicalTarget.toLowerCase())) {
|
|
455
|
+
const text = `transition to "${canonicalTarget}" is not allowed from "${canonicalFrom}". allowed: ${allowed.length > 0 ? allowed.join(', ') : '<none>'}`;
|
|
456
|
+
log.info('mcp transition rejected: transition_not_allowed', {
|
|
457
|
+
issue_identifier: active.identifier,
|
|
458
|
+
from_state: canonicalFrom,
|
|
459
|
+
requested_to_state: canonicalTarget,
|
|
460
|
+
allowed_transitions: allowed,
|
|
461
|
+
});
|
|
462
|
+
return makeStructuredToolError(id, text, {
|
|
463
|
+
error: 'transition_not_allowed',
|
|
464
|
+
from_state: canonicalFrom,
|
|
465
|
+
requested_to_state: canonicalTarget,
|
|
466
|
+
allowed_transitions: allowed,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
345
469
|
}
|
|
346
|
-
|
|
470
|
+
}
|
|
471
|
+
const actor = active.entry.resolved_actor;
|
|
472
|
+
try {
|
|
473
|
+
const result = await this.performTransition(active, canonicalTarget, notes, actor);
|
|
474
|
+
log.info('mcp transition', {
|
|
347
475
|
issue_identifier: active.identifier,
|
|
348
476
|
from: result.fromState,
|
|
349
477
|
to: result.toState,
|
|
350
|
-
|
|
351
|
-
|
|
478
|
+
notes_len: notes.length,
|
|
479
|
+
actor,
|
|
480
|
+
cleanup: active.entry.cleanup_workspace_on_exit,
|
|
352
481
|
});
|
|
353
482
|
return {
|
|
354
483
|
jsonrpc: '2.0',
|
|
@@ -357,15 +486,21 @@ export class McpRegistry {
|
|
|
357
486
|
content: [
|
|
358
487
|
{
|
|
359
488
|
type: 'text',
|
|
360
|
-
text: `
|
|
489
|
+
text: `Transitioned ${active.identifier} from ${result.fromState} to ${result.toState}${notes.length > 0 ? ` with notes (${notes.length} chars) appended` : ''}. End this turn now; the next dispatch will pick up under the new state.`,
|
|
361
490
|
},
|
|
362
491
|
],
|
|
363
492
|
isError: false,
|
|
493
|
+
structuredContent: {
|
|
494
|
+
from_state: result.fromState,
|
|
495
|
+
to_state: result.toState,
|
|
496
|
+
cleanup_workspace_on_exit: active.entry.cleanup_workspace_on_exit,
|
|
497
|
+
notes_appended: notes.length > 0,
|
|
498
|
+
},
|
|
364
499
|
},
|
|
365
500
|
};
|
|
366
501
|
}
|
|
367
502
|
catch (err) {
|
|
368
|
-
return makeToolError(id, `failed to
|
|
503
|
+
return makeToolError(id, `failed to transition: ${err.message}`);
|
|
369
504
|
}
|
|
370
505
|
}
|
|
371
506
|
callRequestHumanSteering(active, id, args) {
|
|
@@ -399,53 +534,107 @@ export class McpRegistry {
|
|
|
399
534
|
},
|
|
400
535
|
};
|
|
401
536
|
}
|
|
402
|
-
|
|
403
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Drop a new issue file into the tracker's Triage/ directory. The orchestrator
|
|
539
|
+
* never dispatches Triage entries because the state's role is `holding`, not
|
|
540
|
+
* `active`; the operator approves or discards from the dashboard. Parent issue (the active
|
|
541
|
+
* dispatch this MCP call came from) is stamped into the front-matter as
|
|
542
|
+
* `proposed_by` so provenance is visible in the file and the UI.
|
|
543
|
+
*
|
|
544
|
+
* Uses the dispatch-time tracker root snapshot — same rationale as `transition`:
|
|
545
|
+
* a WORKFLOW.md reload that mutates tracker.root mid-flight must not redirect
|
|
546
|
+
* an in-flight propose call to a different filesystem location.
|
|
547
|
+
*/
|
|
548
|
+
async callProposeIssue(active, id, args) {
|
|
549
|
+
const titleRaw = typeof args.title === 'string' ? args.title.trim() : '';
|
|
550
|
+
const description = typeof args.description === 'string' ? args.description : '';
|
|
551
|
+
if (!titleRaw) {
|
|
552
|
+
return makeToolError(id, 'title is required and must be a non-empty string');
|
|
553
|
+
}
|
|
554
|
+
if (titleRaw.includes('\n')) {
|
|
555
|
+
return makeToolError(id, 'title must be a single line (no embedded newlines)');
|
|
556
|
+
}
|
|
557
|
+
const labels = Array.isArray(args.labels)
|
|
558
|
+
? args.labels.filter((x) => typeof x === 'string')
|
|
559
|
+
: [];
|
|
560
|
+
const priority = typeof args.priority === 'number' && Number.isFinite(args.priority) ? args.priority : null;
|
|
561
|
+
// Resolve the tracker root: prefer the dispatch-time snapshot, fall back to the
|
|
562
|
+
// tracker's live root (e.g. for tests / trackers without a snapshot). Without a
|
|
563
|
+
// resolvable root we can't write the file, so surface a clean tool error.
|
|
564
|
+
const root = active.trackerRootSnapshot ??
|
|
565
|
+
(this.tracker.currentRoot ? this.tracker.currentRoot() : null);
|
|
566
|
+
if (!root) {
|
|
567
|
+
return makeToolError(id, 'tracker root is not available; cannot create issue files (is this a non-local tracker?)');
|
|
568
|
+
}
|
|
569
|
+
// Landing state: first declared `holding` state in declaration order. The
|
|
570
|
+
// workflow parser refuses configs without one, but if validation was
|
|
571
|
+
// bypassed we surface a structured `no_holding_state` error so the agent
|
|
572
|
+
// doesn't keep retrying against a misconfigured workflow.
|
|
573
|
+
let landingState;
|
|
574
|
+
try {
|
|
575
|
+
landingState = pickHoldingState(this.states);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
if (err instanceof NoHoldingStateError) {
|
|
579
|
+
return makeStructuredToolError(id, 'cannot propose issue: workflow has no holding-role state declared', {
|
|
580
|
+
error: 'no_holding_state',
|
|
581
|
+
declared_states: Object.keys(this.states),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
const result = await writeIssueFile({
|
|
588
|
+
trackerRoot: root,
|
|
589
|
+
state: landingState,
|
|
590
|
+
title: titleRaw,
|
|
591
|
+
description,
|
|
592
|
+
priority,
|
|
593
|
+
labels,
|
|
594
|
+
now: realClock,
|
|
595
|
+
extra_front_matter: {
|
|
596
|
+
proposed_by: active.identifier,
|
|
597
|
+
proposed_at: isoFromClock(this.now),
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
log.info('mcp propose_issue', {
|
|
601
|
+
proposed_by: active.identifier,
|
|
602
|
+
identifier: result.identifier,
|
|
603
|
+
state: result.state,
|
|
604
|
+
title: titleRaw,
|
|
605
|
+
description_chars: description.length,
|
|
606
|
+
});
|
|
607
|
+
return {
|
|
608
|
+
jsonrpc: '2.0',
|
|
609
|
+
id,
|
|
610
|
+
result: {
|
|
611
|
+
content: [
|
|
612
|
+
{
|
|
613
|
+
type: 'text',
|
|
614
|
+
text: `Proposed issue ${result.identifier} in ${result.state}/. The operator will approve or discard from the dashboard; do not wait for it. Continue your current task.`,
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
isError: false,
|
|
618
|
+
// Structured data alongside the human-readable text — MCP clients that
|
|
619
|
+
// surface this make the identifier programmatically available without
|
|
620
|
+
// re-parsing the content string.
|
|
621
|
+
structuredContent: {
|
|
622
|
+
identifier: result.identifier,
|
|
623
|
+
state: result.state,
|
|
624
|
+
path: result.path,
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
return makeToolError(id, `failed to propose issue: ${err.message}`);
|
|
631
|
+
}
|
|
404
632
|
}
|
|
405
633
|
/** Snapshot of currently active issues, used by HTTP for routing lookups. */
|
|
406
634
|
isActive(identifier, token) {
|
|
407
635
|
const active = this.byIdentifier.get(identifier);
|
|
408
|
-
return !!active &&
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Constant-time string comparison for secrets. Uses crypto.timingSafeEqual on
|
|
413
|
-
* equal-length buffers; rejects different-length inputs (the registry only ever
|
|
414
|
-
* issues fixed-width base64url tokens, so a length mismatch is unconditionally
|
|
415
|
-
* a wrong-token signal and the early exit doesn't leak per-byte timing).
|
|
416
|
-
*
|
|
417
|
-
* We compare BYTE lengths after UTF-8 encoding, not JS string `.length` (which
|
|
418
|
-
* counts UTF-16 code units). An attacker-supplied non-ASCII token can match the
|
|
419
|
-
* real token's code-unit count while encoding to a different byte length; passing
|
|
420
|
-
* those buffers to timingSafeEqual would throw `Input buffers must have the same
|
|
421
|
-
* byte length`, surfacing as an HTTP 500 instead of a clean wrong-token rejection.
|
|
422
|
-
*/
|
|
423
|
-
function constantTimeStringEqual(a, b) {
|
|
424
|
-
const aBuf = Buffer.from(a, 'utf8');
|
|
425
|
-
const bBuf = Buffer.from(b, 'utf8');
|
|
426
|
-
if (aBuf.length !== bBuf.length)
|
|
427
|
-
return false;
|
|
428
|
-
return timingSafeEqual(aBuf, bBuf);
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Pick the symphony-runtime staging directory for a workspace. Mirrors the
|
|
432
|
-
* adapter-staging policy in adapters.ts: when the workspace has its own
|
|
433
|
-
* `.git/` directory, the runtime files live inside it (outside the working
|
|
434
|
-
* tree, structurally untrackable); otherwise they sit at workspace root.
|
|
435
|
-
* `mark_done` writes its persisted artifact here so the after_run hook can
|
|
436
|
-
* find it via the same probe.
|
|
437
|
-
*/
|
|
438
|
-
async function pickStagingDir(workspacePath) {
|
|
439
|
-
const gitPath = path.join(workspacePath, '.git');
|
|
440
|
-
try {
|
|
441
|
-
const st = await lstat(gitPath);
|
|
442
|
-
if (st.isDirectory())
|
|
443
|
-
return path.join(workspacePath, '.git', 'symphony-runtime');
|
|
636
|
+
return !!active && this.crypto.constantTimeEqual(active.token, token);
|
|
444
637
|
}
|
|
445
|
-
catch {
|
|
446
|
-
// .git missing or unstat-able — fall through to the workspace-root path.
|
|
447
|
-
}
|
|
448
|
-
return path.join(workspacePath, '.symphony-runtime');
|
|
449
638
|
}
|
|
450
639
|
function getId(body) {
|
|
451
640
|
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
|
@@ -475,4 +664,43 @@ function makeToolError(id, message) {
|
|
|
475
664
|
},
|
|
476
665
|
};
|
|
477
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* MCP tool error with both a human-readable text block and a structured JSON
|
|
669
|
+
* block. Used by `symphony.transition` so agents can read the rejection's
|
|
670
|
+
* structured payload (`declared_states`, `allowed_transitions`, etc.) to pick a
|
|
671
|
+
* valid target on their next call without re-parsing the prose. Pairs with
|
|
672
|
+
* `makeToolError` (text-only) for everything else.
|
|
673
|
+
*/
|
|
674
|
+
function makeStructuredToolError(id, text, json) {
|
|
675
|
+
return {
|
|
676
|
+
jsonrpc: '2.0',
|
|
677
|
+
id,
|
|
678
|
+
result: {
|
|
679
|
+
// MCP 2025-06-18 `CallToolResult.content` is `ContentBlock[]` and does not
|
|
680
|
+
// define a `json` block type; the canonical home for machine-readable
|
|
681
|
+
// payloads is `structuredContent`. Keep only the text block in `content[]`
|
|
682
|
+
// for human display and put the structured shape on the SDK-recognised slot.
|
|
683
|
+
content: [{ type: 'text', text }],
|
|
684
|
+
isError: true,
|
|
685
|
+
structuredContent: json,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Resolve a caller-supplied state name to its canonical declared form (the
|
|
691
|
+
* casing the operator wrote in `states:`). Comparison is case-insensitive to
|
|
692
|
+
* mirror the rest of symphony (eligibility, reconciliation, the local-tracker
|
|
693
|
+
* directory scan all compare lowercase). Returns null when no declared name
|
|
694
|
+
* matches.
|
|
695
|
+
*/
|
|
696
|
+
function canonicalStateName(states, name) {
|
|
697
|
+
if (Object.prototype.hasOwnProperty.call(states, name))
|
|
698
|
+
return name;
|
|
699
|
+
const lower = name.toLowerCase();
|
|
700
|
+
for (const declared of Object.keys(states)) {
|
|
701
|
+
if (declared.toLowerCase() === lower)
|
|
702
|
+
return declared;
|
|
703
|
+
}
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
478
706
|
//# sourceMappingURL=mcp.js.map
|