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/dist/workflow.js CHANGED
@@ -1,72 +1,45 @@
1
- // WORKFLOW.md loader, watcher, and typed config view (SPEC §5, §6).
2
- import { readFile } from 'node:fs/promises';
3
- import { existsSync, statSync } from 'node:fs';
1
+ // WORKFLOW.md parser and typed config view (SPEC §4). Pure: no fs, no process.
2
+ // The on-disk read and watcher live in `./workflow-loader.ts` (shell).
4
3
  import path from 'node:path';
5
4
  import os from 'node:os';
6
- import chokidar from 'chokidar';
7
- import { parse as parseYaml } from 'yaml';
5
+ import { parseFrontMatter, FrontMatterError } from './util/frontmatter.js';
6
+ import { isKnownAdapter } from './agent/adapter-names.js';
7
+ import { parseActionsBlock } from './actions/parsing.js';
8
+ import { WorkflowError } from './errors.js';
8
9
  import { log } from './logging.js';
9
- import { isKnownAdapter } from './agent/adapters.js';
10
- export class WorkflowError extends Error {
11
- code;
12
- constructor(code, message) {
13
- super(message);
14
- this.code = code;
15
- this.name = 'WorkflowError';
16
- }
17
- }
18
- // §5.2: split YAML front matter from prompt body.
10
+ export { WorkflowError };
11
+ // §4.2: split YAML front matter from prompt body. Thin wrapper over the shared
12
+ // parser that translates FrontMatterError → WorkflowError so callers keep
13
+ // matching on the existing error codes.
19
14
  export function splitFrontMatter(text) {
20
- if (!text.startsWith('---')) {
21
- return { config: {}, body: text.trim() };
22
- }
23
- const lines = text.split(/\r?\n/);
24
- // First and closing fences must be exactly `---` (with optional trailing whitespace),
25
- // unindented. Otherwise an indented `---` inside a multiline YAML hook script would be
26
- // mistaken for the closing fence.
27
- const isFence = (line) => /^---\s*$/.test(line ?? '');
28
- if (!isFence(lines[0])) {
29
- return { config: {}, body: text.trim() };
30
- }
31
- let endIdx = -1;
32
- for (let i = 1; i < lines.length; i++) {
33
- if (isFence(lines[i])) {
34
- endIdx = i;
35
- break;
36
- }
37
- }
38
- if (endIdx < 0) {
39
- throw new WorkflowError('workflow_parse_error', 'unterminated YAML front matter');
40
- }
41
- const fmText = lines.slice(1, endIdx).join('\n');
42
- const body = lines.slice(endIdx + 1).join('\n').trim();
43
- let parsed;
15
+ let fm;
44
16
  try {
45
- parsed = fmText.trim().length === 0 ? {} : parseYaml(fmText);
17
+ fm = parseFrontMatter(text);
46
18
  }
47
19
  catch (err) {
48
- throw new WorkflowError('workflow_parse_error', `invalid YAML front matter: ${err.message}`);
49
- }
50
- if (parsed === null || parsed === undefined)
51
- parsed = {};
52
- if (typeof parsed !== 'object' || Array.isArray(parsed)) {
53
- throw new WorkflowError('workflow_front_matter_not_a_map', 'YAML front matter must decode to a map');
20
+ if (err instanceof FrontMatterError) {
21
+ const code = err.code === 'not_a_map' ? 'workflow_front_matter_not_a_map' : 'workflow_parse_error';
22
+ throw new WorkflowError(code, err.message);
23
+ }
24
+ throw err;
54
25
  }
55
- return { config: parsed, body };
26
+ return { config: fm.fields, body: fm.body };
56
27
  }
57
- export async function loadWorkflow(workflowPath) {
58
- let text;
59
- try {
60
- text = await readFile(workflowPath, 'utf8');
61
- }
62
- catch (err) {
63
- throw new WorkflowError('missing_workflow_file', `cannot read ${workflowPath}: ${err.message}`);
64
- }
65
- const { config, body } = splitFrontMatter(text);
66
- return { config, prompt_template: body };
28
+ /**
29
+ * Pure entry point: split front matter, build the typed view, and return both
30
+ * shapes. The shell loader reads the file from disk and the operator's env,
31
+ * then calls this. `env` defaults to an empty map so tests that do not exercise
32
+ * `$VAR` expansion need not thread it through.
33
+ */
34
+ export function parseWorkflow(text, workflowPath, env = {}) {
35
+ const { config: raw, body } = splitFrontMatter(text);
36
+ const definition = { config: raw, prompt_template: body };
37
+ const config = buildServiceConfig(raw, workflowPath, env);
38
+ return { definition, config };
67
39
  }
68
- // $VAR / ~ expansion for path/command fields (§6.1).
69
- export function expandVar(value) {
40
+ // $VAR / ~ expansion for path/command fields. `env` carries the variable map
41
+ // (the shell loader passes process.env; tests pass an explicit shape).
42
+ export function expandVar(value, env = {}) {
70
43
  if (typeof value !== 'string')
71
44
  return value;
72
45
  let s = value;
@@ -75,7 +48,7 @@ export function expandVar(value) {
75
48
  }
76
49
  const m = s.match(/^\$([A-Z_][A-Z0-9_]*)$/);
77
50
  if (m) {
78
- const envVal = process.env[m[1]];
51
+ const envVal = env[m[1]];
79
52
  return envVal ?? '';
80
53
  }
81
54
  return s;
@@ -97,38 +70,53 @@ function asStringList(v, fallback) {
97
70
  return v.filter((x) => typeof x === 'string');
98
71
  return fallback;
99
72
  }
100
- function asMapStrPosInt(v) {
101
- const out = {};
102
- if (v && typeof v === 'object' && !Array.isArray(v)) {
103
- for (const [k, raw] of Object.entries(v)) {
104
- const n = asInt(raw, 0);
105
- if (n > 0)
106
- out[k.toLowerCase()] = n;
107
- }
108
- }
109
- return out;
110
- }
111
73
  function getObject(parent, key) {
112
74
  const v = parent[key];
113
75
  if (v && typeof v === 'object' && !Array.isArray(v))
114
76
  return v;
115
77
  return {};
116
78
  }
117
- // Build a fully typed ServiceConfig from a parsed front matter map (§6.1).
118
- export function buildServiceConfig(raw, workflowPath) {
79
+ // workspace.github_repo: a GitHub `owner/repo` slug that enables push/PR mode,
80
+ // or null for local-only. There is no auto-detection — the value is either
81
+ // absent (local-only) or a literal slug. Empty string / "none"
82
+ // (case-insensitive) normalize to null so an operator can disable push mode
83
+ // without deleting the key. Any other value MUST be a GitHub owner/repo slug:
84
+ // `owner` is GitHub's `[alnum]`/`-` charset (no `.`, `:`, `@`, scheme, or host)
85
+ // and `repo` is `[alnum]`/`.`/`_`/`-`. This rejects whole-URL and SSH-style
86
+ // remotes (`https://github.com/foo/bar`, `git@github.com:foo/bar`) and bare
87
+ // names (`foo`) at parse time, so a typo can't slip through and build a broken
88
+ // `https://github.com/<garbage>.git` origin that fails later during setup.
89
+ // Takes the raw YAML value (not asString'd) so a present-but-wrong-type value
90
+ // (`github_repo: true`, `123`, `{}`) is rejected rather than coerced to null —
91
+ // otherwise the fail-fast contract has a hole that silently disables push/PR.
92
+ function parseGithubRepo(input) {
93
+ if (input === undefined || input === null)
94
+ return null;
95
+ if (typeof input !== 'string') {
96
+ throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a string "owner/repo" slug or "none" (got ${typeof input})`);
97
+ }
98
+ const trimmed = input.trim();
99
+ if (trimmed === '' || trimmed.toLowerCase() === 'none')
100
+ return null;
101
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(trimmed)) {
102
+ throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a GitHub "owner/repo" slug or "none" (got: ${input})`);
103
+ }
104
+ return trimmed;
105
+ }
106
+ // Build a fully typed ServiceConfig from a parsed front matter map. `env`
107
+ // supplies the variable map for `$VAR` expansion; defaults to {} so pure
108
+ // callers don't need to thread one in.
109
+ export function buildServiceConfig(raw, workflowPath, env = {}) {
119
110
  const workflowAbs = path.resolve(workflowPath);
120
111
  const workflowDir = path.dirname(workflowAbs);
121
- // tracker (§5.3.1)
112
+ // tracker (§4.3.1)
122
113
  const trackerRaw = getObject(raw, 'tracker');
123
114
  const trackerKind = (asString(trackerRaw['kind']) ?? '').trim();
124
- const apiKeyRaw = asString(trackerRaw['api_key']);
125
- const apiKeyResolved = apiKeyRaw ? expandVar(apiKeyRaw) : null;
126
- const trackerEndpointDefault = trackerKind === 'linear' ? 'https://api.linear.app/graphql' : null;
127
115
  // local-tracker extension: optional `tracker.root` path.
128
116
  const trackerRootRaw = asString(trackerRaw['root']);
129
117
  let trackerRoot = null;
130
118
  if (trackerRootRaw) {
131
- const expanded = expandVar(trackerRootRaw);
119
+ const expanded = expandVar(trackerRootRaw, env);
132
120
  if (expanded === '') {
133
121
  throw new WorkflowError('workflow_parse_error', `tracker.root references an unset variable: ${trackerRootRaw}`);
134
122
  }
@@ -138,32 +126,23 @@ export function buildServiceConfig(raw, workflowPath) {
138
126
  // Default local tracker root: <workflow-dir>/issues
139
127
  trackerRoot = path.resolve(workflowDir, 'issues');
140
128
  }
129
+ const states = parseStatesBlock(raw['states']);
141
130
  const tracker = {
142
131
  kind: trackerKind,
143
- endpoint: asString(trackerRaw['endpoint']) ?? trackerEndpointDefault,
144
- api_key: apiKeyResolved && apiKeyResolved.length > 0 ? apiKeyResolved : null,
145
- project_slug: asString(trackerRaw['project_slug']),
146
- active_states: asStringList(trackerRaw['active_states'], ['Todo', 'In Progress']),
147
- terminal_states: asStringList(trackerRaw['terminal_states'], [
148
- 'Closed',
149
- 'Cancelled',
150
- 'Canceled',
151
- 'Duplicate',
152
- 'Done',
153
- ]),
132
+ states,
154
133
  root: trackerRoot,
155
134
  };
156
- // polling (§5.3.2)
135
+ // polling (§4.3.2)
157
136
  const pollingRaw = getObject(raw, 'polling');
158
137
  const polling = {
159
138
  interval_ms: asInt(pollingRaw['interval_ms'], 30_000),
160
139
  };
161
- // workspace (§5.3.3)
140
+ // workspace (§4.3.3)
162
141
  const workspaceRaw = getObject(raw, 'workspace');
163
142
  const wsRootInput = asString(workspaceRaw['root']);
164
143
  let workspaceRoot;
165
144
  if (wsRootInput) {
166
- const expanded = expandVar(wsRootInput);
145
+ const expanded = expandVar(wsRootInput, env);
167
146
  if (expanded === '') {
168
147
  throw new WorkflowError('workflow_parse_error', `workspace.root references an unset variable: ${wsRootInput}`);
169
148
  }
@@ -172,58 +151,108 @@ export function buildServiceConfig(raw, workflowPath) {
172
151
  else {
173
152
  workspaceRoot = path.join(os.tmpdir(), 'symphony_workspaces');
174
153
  }
175
- const workspace = { root: path.resolve(workspaceRoot) };
176
- // hooks (§5.3.4)
177
- const hooksRaw = getObject(raw, 'hooks');
178
- const hooks = {
179
- after_create: asString(hooksRaw['after_create']),
180
- before_run: asString(hooksRaw['before_run']),
181
- after_run: asString(hooksRaw['after_run']),
182
- before_remove: asString(hooksRaw['before_remove']),
183
- timeout_ms: asInt(hooksRaw['timeout_ms'], 60_000),
154
+ const baseBranchInput = asString(workspaceRaw['base_branch']);
155
+ const baseBranch = baseBranchInput && baseBranchInput.trim().length > 0 ? baseBranchInput.trim() : 'main';
156
+ const workspace = {
157
+ root: path.resolve(workspaceRoot),
158
+ github_repo: parseGithubRepo(workspaceRaw['github_repo']),
159
+ base_branch: baseBranch,
184
160
  };
185
- if (hooks.timeout_ms <= 0) {
186
- throw new WorkflowError('workflow_parse_error', 'hooks.timeout_ms must be positive');
161
+ // logs (symphony extension): per-issue JSONL run logs. Default sits next to the workspace
162
+ // root under `.symphony/logs/` so all symphony-managed state for a project lives in one
163
+ // tree. Same expansion rules as workspace.root.
164
+ const logsRaw = getObject(raw, 'logs');
165
+ const logsRootInput = asString(logsRaw['root']);
166
+ let logsRoot;
167
+ if (logsRootInput) {
168
+ const expanded = expandVar(logsRootInput, env);
169
+ if (expanded === '') {
170
+ throw new WorkflowError('workflow_parse_error', `logs.root references an unset variable: ${logsRootInput}`);
171
+ }
172
+ logsRoot = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
173
+ }
174
+ else {
175
+ logsRoot = path.resolve(workflowDir, '.symphony', 'logs');
187
176
  }
188
- // agent (§5.3.5)
177
+ const logs = { root: path.resolve(logsRoot) };
178
+ // agent (§4.3.4)
189
179
  const agentRaw = getObject(raw, 'agent');
190
180
  const maxTurns = asInt(agentRaw['max_turns'], 20);
191
181
  if (maxTurns <= 0) {
192
182
  throw new WorkflowError('workflow_parse_error', 'agent.max_turns must be positive');
193
183
  }
184
+ // Memory-aware admission cap (issue 27). Default-on with a 2 GiB host reserve — that's
185
+ // enough headroom for the orchestrator process, the per-VM Gondolin runners,
186
+ // and the kernel's working set on a typical workstation. Operators can disable the cap (set
187
+ // `memory_admission_enabled: false`) on hosts that don't expose /proc/meminfo or where
188
+ // the static cap is already the binding constraint.
189
+ const memAdmissionEnabledRaw = agentRaw['memory_admission_enabled'];
190
+ const memoryAdmissionEnabled = memAdmissionEnabledRaw === undefined ? true : memAdmissionEnabledRaw !== false;
191
+ const hostMemoryReserveMib = asInt(agentRaw['host_memory_reserve_mib'], 2048);
192
+ if (hostMemoryReserveMib < 0) {
193
+ throw new WorkflowError('workflow_parse_error', 'agent.host_memory_reserve_mib must be a non-negative integer');
194
+ }
195
+ // Circuit breaker (issue 128). Default 5: after five consecutive identical
196
+ // failures the orchestrator stops retrying and routes the issue to a holding
197
+ // state. 0 disables the breaker; 1 would trip on the first failure (no retry
198
+ // ever), which is rarely wanted, so the parser rejects it as a likely
199
+ // misconfiguration — use 0 to disable or >= 2 to bound the loop.
200
+ const circuitBreakerThreshold = asInt(agentRaw['circuit_breaker_threshold'], 5);
201
+ if (circuitBreakerThreshold < 0 || circuitBreakerThreshold === 1) {
202
+ throw new WorkflowError('workflow_parse_error', 'agent.circuit_breaker_threshold must be 0 (disabled) or an integer >= 2');
203
+ }
194
204
  const agent = {
195
205
  max_concurrent_agents: asInt(agentRaw['max_concurrent_agents'], 10),
196
206
  max_turns: maxTurns,
197
207
  max_retry_backoff_ms: asInt(agentRaw['max_retry_backoff_ms'], 300_000),
198
- max_concurrent_agents_by_state: asMapStrPosInt(agentRaw['max_concurrent_agents_by_state']),
208
+ memory_admission_enabled: memoryAdmissionEnabled,
209
+ host_memory_reserve_mib: hostMemoryReserveMib,
210
+ circuit_breaker_threshold: circuitBreakerThreshold,
199
211
  };
200
- // acp (Symphony extension; supersedes the §5.3.6 `codex` block). `adapter` selects
201
- // one of symphony's known profiles (claude, codex). `command` is optional: when
202
- // null/unset, symphony auto-derives the launch command from the adapter profile and
203
- // stages the host credential file into the workspace. Override `command` only if
204
- // you need a custom launch (testing a forked adapter, a non-default binary path,
205
- // etc.) overriding also opts out of credential staging.
212
+ // acp (Symphony extension; see §4.3.5). `adapter` selects
213
+ // one of symphony's known profiles (claude, codex, opencode); symphony auto-derives the
214
+ // launch command from the adapter profile. Credentials are NOT staged into the workspace:
215
+ // the guest only ever holds a token-shaped placeholder, and the host substitutes the real
216
+ // upstream token into the outbound request at Gondolin egress (TLS-MITM via
217
+ // `createHttpHooks` in src/agent/credential-secrets.ts). The real host credential
218
+ // (`~/.claude/.credentials.json` for claude; `~/.codex/auth.json` access token or
219
+ // `OPENAI_API_KEY` for codex; the GitHub Copilot token exchanged from
220
+ // `~/.local/share/opencode/auth.json` for opencode) never enters the VM.
221
+ //
222
+ // `acp.bridge` configures the host-side TCP listener that the in-VM agent dials back
223
+ // to for ACP traffic. The bridge replaced the earlier in-VM-exec stdio path; see
224
+ // src/acp-bridge.ts for rationale.
206
225
  const acpRaw = getObject(raw, 'acp');
226
+ const bridgeRaw = getObject(acpRaw, 'bridge');
227
+ const modelRaw = asString(acpRaw['model']);
228
+ const modelTrimmed = modelRaw === null ? null : modelRaw.trim();
229
+ const effortRaw = asString(acpRaw['effort']);
230
+ const effortTrimmed = effortRaw === null ? null : effortRaw.trim();
207
231
  const acp = {
208
232
  adapter: asString(acpRaw['adapter']) ?? 'claude',
209
- command: asString(acpRaw['command']),
233
+ model: modelTrimmed && modelTrimmed.length > 0 ? modelTrimmed : null,
234
+ effort: effortTrimmed && effortTrimmed.length > 0 ? effortTrimmed : null,
210
235
  shell: asString(acpRaw['shell']) ?? 'bash',
211
236
  prompt_timeout_ms: asInt(acpRaw['prompt_timeout_ms'], 3_600_000),
212
237
  read_timeout_ms: asInt(acpRaw['read_timeout_ms'], 30_000),
213
238
  stall_timeout_ms: asInt(acpRaw['stall_timeout_ms'], 300_000),
239
+ bridge: {
240
+ bind_host: asString(bridgeRaw['bind_host']) ?? '0.0.0.0',
241
+ bind_port: asInt(bridgeRaw['bind_port'], 8788),
242
+ reach_host: asString(bridgeRaw['reach_host']) ?? '127.0.0.1',
243
+ reach_url: asString(bridgeRaw['reach_url']),
244
+ connect_timeout_ms: asInt(bridgeRaw['connect_timeout_ms'], 30_000),
245
+ },
214
246
  };
215
- // smolvm extension
216
- const smolvmRaw = getObject(raw, 'smolvm');
217
- const fromRaw = asString(smolvmRaw['from']);
218
- let from = null;
219
- if (fromRaw) {
220
- const expanded = expandVar(fromRaw);
221
- if (expanded === '') {
222
- throw new WorkflowError('workflow_parse_error', `smolvm.from references an unset variable: ${fromRaw}`);
223
- }
224
- from = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
225
- }
226
- const volumesRaw = smolvmRaw['volumes'];
247
+ // credentials extension (issue 113). Defaults work out of the box for the
248
+ // common case: run the host ticker every 6 hours.
249
+ const credentialsRaw = getObject(raw, 'credentials');
250
+ const credentials = {
251
+ ticker_interval_ms: asInt(credentialsRaw['ticker_interval_ms'], 6 * 60 * 60 * 1000),
252
+ };
253
+ // gondolin VM extension
254
+ const gondolinRaw = getObject(raw, 'gondolin');
255
+ const volumesRaw = gondolinRaw['volumes'];
227
256
  const volumes = Array.isArray(volumesRaw)
228
257
  ? volumesRaw.flatMap((v) => {
229
258
  if (!v || typeof v !== 'object' || Array.isArray(v))
@@ -233,7 +262,7 @@ export function buildServiceConfig(raw, workflowPath) {
233
262
  const guest = asString(m['guest']);
234
263
  if (!hostRaw || !guest)
235
264
  return [];
236
- const expandedHost = expandVar(hostRaw);
265
+ const expandedHost = expandVar(hostRaw, env);
237
266
  if (expandedHost === '')
238
267
  return [];
239
268
  const host = path.isAbsolute(expandedHost)
@@ -243,32 +272,38 @@ export function buildServiceConfig(raw, workflowPath) {
243
272
  return [{ host, guest, readonly }];
244
273
  })
245
274
  : [];
246
- const smolvm = {
247
- image: asString(smolvmRaw['image']),
248
- from,
249
- cpus: asInt(smolvmRaw['cpus'], 2),
250
- mem_mib: asInt(smolvmRaw['mem_mib'], 2048),
251
- net: smolvmRaw['net'] !== false,
252
- bin_path: asString(smolvmRaw['bin_path']),
275
+ const gondolin = {
276
+ image: asString(gondolinRaw['image']),
277
+ cpus: asInt(gondolinRaw['cpus'], 2),
278
+ mem_mib: asInt(gondolinRaw['mem_mib'], 2048),
253
279
  volumes,
254
- // Default forwarded credentials cover all three shipped ACP adapters so workflows that
255
- // do not override `smolvm.forward_env` still authenticate after the default-adapter
256
- // switch to claude-agent-acp.
257
- forward_env: asStringList(smolvmRaw['forward_env'], [
280
+ // `forward_env` is forwarded into the VM boot env, but the runner strips EVERY
281
+ // credential-bearing var (`stripCredentialEnv`) before boot the guest holds
282
+ // only the token-shaped placeholder Gondolin substitutes at egress, never a real
283
+ // key. So even if an operator lists `OPENAI_API_KEY` here, it never reaches a VM.
284
+ // The defaults are retained for any future forward-env-strategy adapter.
285
+ forward_env: asStringList(gondolinRaw['forward_env'], [
258
286
  'OPENAI_API_KEY',
259
287
  'ANTHROPIC_API_KEY',
260
288
  ]),
261
- endpoint: asString(smolvmRaw['endpoint']) ??
262
- `unix://${process.env.XDG_RUNTIME_DIR ?? '/run/user/1000'}/smolvm.sock`,
263
289
  };
264
- // server extension (§13.7)
290
+ // egress firewall: the general dev-tooling allowlist the in-VM agent may reach
291
+ // (npm/git/CDNs) for gates. DISTINCT from the credential layer's per-adapter
292
+ // substitution hosts — nothing listed here ever gets a real token substituted
293
+ // (see credential-secrets.ts buildAdapterHooksConfig). Empty default: the agent
294
+ // can reach only each adapter's inference host until the operator opts hosts in.
295
+ const egressRaw = getObject(raw, 'egress');
296
+ const egress = {
297
+ allowed_hosts: asStringList(egressRaw['allowed_hosts'], []),
298
+ };
299
+ // server extension (§9.5)
265
300
  const serverRaw = getObject(raw, 'server');
266
301
  const server = {
267
302
  port: typeof serverRaw['port'] === 'number' ? serverRaw['port'] : null,
268
303
  host: asString(serverRaw['host']) ?? '127.0.0.1',
269
304
  };
270
- // mcp extension: per-issue MCP server (mark_done + request_human_steering tools) injected
271
- // into each ACP session. `host` defaults to the QEMU slirp gateway; the port is the
305
+ // mcp extension: per-issue MCP server (transition + request_human_steering + propose_issue
306
+ // tools) injected into each ACP session. `host` defaults to the QEMU slirp gateway; the port is the
272
307
  // actually-bound HTTP server's port (resolved at runtime, not config-parse time, so
273
308
  // `--port` and an unset server.port can never desync). `host_url` is an explicit full-URL
274
309
  // override for cases where the VM can't reach the orchestrator via the host gateway.
@@ -277,109 +312,511 @@ export function buildServiceConfig(raw, workflowPath) {
277
312
  const mcpEnabled = mcpEnabledRaw === undefined ? true : mcpEnabledRaw !== false;
278
313
  const mcp = {
279
314
  enabled: mcpEnabled,
280
- // 127.0.0.1 works for smolvm because its VM network intercepts loopback
281
- // traffic and forwards it to the host's loopback. (Empirically verified;
315
+ // 127.0.0.1 works because Gondolin maps a synthetic guest host to the host's
316
+ // loopback (`tcp.hosts`). (Empirically verified;
282
317
  // 10.0.2.2 — the QEMU slirp gateway — is NOT reachable here.) Other VMMs
283
318
  // can override via the `host` field in the WORKFLOW.md mcp block.
284
319
  host: asString(mcpRaw['host']) ?? '127.0.0.1',
285
320
  explicit_host_url: asString(mcpRaw['host_url']),
286
321
  };
322
+ // pr (issue 38, slimmed in issue 139). Optional block; default off. The slim
323
+ // host-global engine toggle: `pr: { enabled, poll_interval_ms }`. The
324
+ // merge/close/route targets and auto-merge strategy live ON the terminal
325
+ // states they describe (`states.<name>.pr`, parsed in parseStatesBlock) and
326
+ // are derived by scanning states (`derivePrRouting`), never named here.
327
+ const prRaw = getObject(raw, 'pr');
328
+ const pr = {
329
+ enabled: prRaw['enabled'] === true,
330
+ poll_interval_ms: asInt(prRaw['poll_interval_ms'], 30_000),
331
+ };
332
+ if (pr.poll_interval_ms < 0) {
333
+ throw new WorkflowError('workflow_parse_error', 'pr.poll_interval_ms must be non-negative');
334
+ }
335
+ // sleep_cycle (issue 125; retired in issue 140). The auto-arm trigger moved
336
+ // ONTO the active state it arms (`states.<name>.arm`, parsed in
337
+ // parseStatesBlock). A legacy top-level `sleep_cycle:` block is folded onto the
338
+ // reflect_state it named (foldLegacySleepCycle) for one release with a
339
+ // deprecation warning; there is no top-level sleep-cycle field on the resulting
340
+ // ServiceConfig anymore — the orchestrator derives the armed state by scanning
341
+ // states (`deriveArmRouting`).
342
+ foldLegacySleepCycle(states, getObject(raw, 'sleep_cycle'), Object.prototype.hasOwnProperty.call(raw, 'sleep_cycle'));
287
343
  return {
288
344
  workflow_path: workflowAbs,
289
345
  workflow_dir: workflowDir,
290
346
  tracker,
291
347
  polling,
292
348
  workspace,
293
- hooks,
349
+ logs,
294
350
  agent,
295
351
  acp,
296
- smolvm,
352
+ gondolin,
353
+ egress,
297
354
  server,
298
355
  mcp,
356
+ pr,
357
+ credentials,
358
+ states,
299
359
  };
300
360
  }
301
- // §6.3 dispatch preflight validation.
302
- export function validateDispatch(cfg) {
303
- if (!cfg.tracker.kind)
304
- return 'tracker.kind is required';
305
- if (cfg.tracker.kind !== 'linear' && cfg.tracker.kind !== 'local') {
306
- return `unsupported_tracker_kind: ${cfg.tracker.kind}`;
307
- }
308
- if (cfg.tracker.kind === 'linear') {
309
- if (!cfg.tracker.api_key)
310
- return 'missing_tracker_api_key';
311
- if (!cfg.tracker.project_slug)
312
- return 'missing_tracker_project_slug';
313
- }
314
- if (cfg.tracker.kind === 'local') {
315
- if (!cfg.tracker.root)
316
- return 'tracker.root must be set for local tracker';
317
- if (!existsSync(cfg.tracker.root) || !statSync(cfg.tracker.root).isDirectory()) {
318
- return `tracker.root not found or not a directory: ${cfg.tracker.root}`;
361
+ /**
362
+ * Per-state `arm:` block (issue 140). Optional, valid on an active state.
363
+ * `issue` is the recurring issue armed into this state; `from` the holding state
364
+ * it rests in between runs; `on_idle` / `after_terminal` are the two triggers.
365
+ * The structural shape (types, `from` non-empty, `after_terminal` non-negative)
366
+ * is validated here; the cross-reference (arm only on active states, `from` is a
367
+ * declared holding state, `issue` required, at most one armed state) lives in
368
+ * `validateStates`. Returns `undefined` when the block is absent. `on_idle`
369
+ * defaults to false (opt-in, symmetric with `after_terminal: 0`).
370
+ */
371
+ function parseStateArmBlock(stateName, raw) {
372
+ if (raw === undefined || raw === null)
373
+ return undefined;
374
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
375
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm must be a map (issue / from / on_idle / after_terminal)`);
376
+ }
377
+ const m = raw;
378
+ const issueRaw = asString(m['issue']);
379
+ const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
380
+ const fromRaw = asString(m['from']);
381
+ if (!fromRaw || fromRaw.trim().length === 0) {
382
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.from must be a non-empty holding state name`);
383
+ }
384
+ const afterTerminal = asInt(m['after_terminal'], 0);
385
+ if (afterTerminal < 0) {
386
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.after_terminal must be a non-negative integer (0 disables the terminal-count trigger)`);
387
+ }
388
+ return {
389
+ issue,
390
+ from: fromRaw.trim(),
391
+ on_idle: m['on_idle'] === true,
392
+ after_terminal: afterTerminal,
393
+ };
394
+ }
395
+ export function deriveArmRouting(states) {
396
+ for (const [name, sc] of Object.entries(states)) {
397
+ if (sc.role !== 'active' || !sc.arm)
398
+ continue;
399
+ return {
400
+ armState: name,
401
+ issue: sc.arm.issue,
402
+ from: sc.arm.from,
403
+ onIdle: sc.arm.on_idle,
404
+ afterTerminal: sc.arm.after_terminal,
405
+ };
406
+ }
407
+ return { armState: null, issue: null, from: null, onIdle: false, afterTerminal: 0 };
408
+ }
409
+ /**
410
+ * Fold a deprecated top-level `sleep_cycle:` block onto the active state it named
411
+ * (issue 140). Mirrors the now-removed legacy PR-autopilot fold: the trigger
412
+ * that used to live as named strings (`reflect_state` / `dormant_state` / `issue_id` /
413
+ * `arm_on_idle` / `arm_after_done`) is injected onto the reflect_state's `arm:`
414
+ * field so the state scan (`deriveArmRouting`) is the single runtime source of
415
+ * truth. A state that already declares `arm:` wins on conflict. Emits one
416
+ * deprecation warning whenever the block is present. Only folds when the legacy
417
+ * block is `enabled: true` — in the new model declaring `arm:` IS the enable, so
418
+ * a disabled legacy block produces no arm. The legacy `arm_on_idle` default of
419
+ * true is preserved (`!== false`). A `reflect_state` matching no declared state
420
+ * is a silent no-op (the warning already points at the new shape).
421
+ */
422
+ function foldLegacySleepCycle(states, legacyRaw, present) {
423
+ if (!present)
424
+ return;
425
+ log.warn('sleep_cycle: is deprecated; declare the auto-arm trigger on the active state it arms as `states.<name>.arm` (issue / from / on_idle / after_terminal)', {});
426
+ // Preserve the original sleep-cycle parser's non-negative validation: a
427
+ // negative `arm_after_done` was a parse error, not a silent disable. Validated
428
+ // whenever the block is present (independent of `enabled`), matching the
429
+ // retired `validateSleepCycle` and the new `arm.after_terminal` parser.
430
+ const afterTerminal = asInt(legacyRaw['arm_after_done'], 0);
431
+ if (afterTerminal < 0) {
432
+ throw new WorkflowError('workflow_parse_error', 'sleep_cycle.arm_after_done must be a non-negative integer (0 disables the terminal-count trigger)');
433
+ }
434
+ if (legacyRaw['enabled'] !== true)
435
+ return;
436
+ const reflectRaw = asString(legacyRaw['reflect_state']);
437
+ const reflectState = reflectRaw && reflectRaw.trim().length > 0 ? reflectRaw.trim() : 'Reflect';
438
+ const dormantRaw = asString(legacyRaw['dormant_state']);
439
+ const dormantState = dormantRaw && dormantRaw.trim().length > 0 ? dormantRaw.trim() : 'Dormant';
440
+ const issueRaw = asString(legacyRaw['issue_id']);
441
+ const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
442
+ injectStateArm(states, reflectState, {
443
+ issue,
444
+ from: dormantState,
445
+ on_idle: legacyRaw['arm_on_idle'] !== false,
446
+ after_terminal: afterTerminal,
447
+ });
448
+ }
449
+ /**
450
+ * Inject a derived `arm:` block onto the (case-insensitively) named active state,
451
+ * but only when that state declares no `arm:` of its own — state-level config
452
+ * always wins over a folded legacy value. No-op when the name matches no state.
453
+ */
454
+ function injectStateArm(states, name, arm) {
455
+ const lower = name.toLowerCase();
456
+ for (const [stateName, sc] of Object.entries(states)) {
457
+ if (stateName.toLowerCase() !== lower)
458
+ continue;
459
+ if (sc.arm)
460
+ return;
461
+ sc.arm = arm;
462
+ return;
463
+ }
464
+ }
465
+ // Parse the top-level `states:` block. The block is mandatory: every workflow
466
+ // must declare at least one `active`, one `terminal`, and one `holding` state
467
+ // (validation happens in `validateStates`). Insertion order matters —
468
+ // downstream consumers (dashboard, role-filtered active/terminal listings)
469
+ // follow declaration order — so we build a plain object incrementally rather
470
+ // than reconstructing via `Object.fromEntries`.
471
+ function parseStatesBlock(raw) {
472
+ if (raw === undefined || raw === null) {
473
+ throw new WorkflowError('workflow_parse_error', 'workflow YAML must declare a top-level `states:` block with at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
474
+ }
475
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
476
+ throw new WorkflowError('workflow_parse_error', 'states: must be a map of name → config');
477
+ }
478
+ const entries = Object.entries(raw);
479
+ if (entries.length === 0) {
480
+ throw new WorkflowError('workflow_parse_error', 'workflow YAML `states:` block is empty; declare at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
481
+ }
482
+ const out = {};
483
+ for (const [name, value] of entries) {
484
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
485
+ throw new WorkflowError('workflow_parse_error', `state "${name}": value must be a map`);
486
+ }
487
+ const m = value;
488
+ const roleRaw = asString(m['role']);
489
+ if (roleRaw !== 'active' && roleRaw !== 'terminal' && roleRaw !== 'holding') {
490
+ throw new WorkflowError('workflow_parse_error', `state "${name}": role must be one of active|terminal|holding (got: ${String(m['role'])})`);
491
+ }
492
+ const adapter = asString(m['adapter']);
493
+ const modelRaw = asString(m['model']);
494
+ const modelTrimmed = modelRaw === null ? undefined : modelRaw.trim();
495
+ const model = modelTrimmed === undefined ? undefined : modelTrimmed.length > 0 ? modelTrimmed : null;
496
+ // Same undefined-vs-null semantics as `model`: a missing key inherits the
497
+ // workflow-level `acp.effort`; a blank/whitespace string normalizes to null
498
+ // (an explicit "use the adapter default for this state" signal).
499
+ const effortRaw = asString(m['effort']);
500
+ const effortTrimmed = effortRaw === null ? undefined : effortRaw.trim();
501
+ const effort = effortTrimmed === undefined ? undefined : effortTrimmed.length > 0 ? effortTrimmed : null;
502
+ let maxTurns;
503
+ if (m['max_turns'] !== undefined) {
504
+ const n = asInt(m['max_turns'], -1);
505
+ if (n <= 0) {
506
+ throw new WorkflowError('workflow_parse_error', `state "${name}": max_turns must be a positive integer`);
507
+ }
508
+ maxTurns = n;
509
+ }
510
+ // Per-state concurrency cap (issue 137) — same positive-integer validation
511
+ // as max_turns. Undefined when omitted (no per-state cap; only the global
512
+ // agent.max_concurrent_agents ceiling applies).
513
+ let maxConcurrent;
514
+ if (m['max_concurrent'] !== undefined) {
515
+ const n = asInt(m['max_concurrent'], -1);
516
+ if (n <= 0) {
517
+ throw new WorkflowError('workflow_parse_error', `state "${name}": max_concurrent must be a positive integer`);
518
+ }
519
+ maxConcurrent = n;
520
+ }
521
+ let allowed;
522
+ if (m['allowed_transitions'] === undefined) {
523
+ allowed = undefined;
524
+ }
525
+ else if (m['allowed_transitions'] === null) {
526
+ allowed = null;
527
+ }
528
+ else if (Array.isArray(m['allowed_transitions'])) {
529
+ allowed = m['allowed_transitions'].filter((x) => typeof x === 'string');
530
+ }
531
+ else {
532
+ throw new WorkflowError('workflow_parse_error', `state "${name}": allowed_transitions must be a list of state names (or null/omitted)`);
533
+ }
534
+ const stateActions = parseActionsBlock(name, m['actions']);
535
+ // eval_mode is a strict boolean opt-in: only true enables it, any other
536
+ // value (including undefined, null, "true" string) leaves it off. Strict
537
+ // typing here matches the rest of the YAML-flag plumbing in the parser
538
+ // and stops a YAML-quoting accident ("true") from silently enabling the
539
+ // mounts.
540
+ const evalModeRaw = m['eval_mode'];
541
+ if (evalModeRaw !== undefined && typeof evalModeRaw !== 'boolean') {
542
+ throw new WorkflowError('workflow_parse_error', `state "${name}": eval_mode must be a boolean (true/false)`);
543
+ }
544
+ const statePr = parseStatePrBlock(name, m['pr']);
545
+ const stateArm = parseStateArmBlock(name, m['arm']);
546
+ const sc = { role: roleRaw };
547
+ if (adapter !== null)
548
+ sc.adapter = adapter;
549
+ if (model !== undefined)
550
+ sc.model = model;
551
+ if (effort !== undefined)
552
+ sc.effort = effort;
553
+ if (maxTurns !== undefined)
554
+ sc.max_turns = maxTurns;
555
+ if (maxConcurrent !== undefined)
556
+ sc.max_concurrent = maxConcurrent;
557
+ if (allowed !== undefined)
558
+ sc.allowed_transitions = allowed;
559
+ if (stateActions !== undefined)
560
+ sc.actions = stateActions;
561
+ if (evalModeRaw === true)
562
+ sc.eval_mode = true;
563
+ if (statePr !== undefined)
564
+ sc.pr = statePr;
565
+ if (stateArm !== undefined)
566
+ sc.arm = stateArm;
567
+ out[name] = sc;
568
+ }
569
+ return out;
570
+ }
571
+ /**
572
+ * Per-state `pr:` block (issue 139). Optional, valid on a terminal state.
573
+ * `auto_merge` (squash|merge|rebase) marks the merge state and picks the
574
+ * `gh pr merge --auto` strategy; `on_conflict.route_to` names the active state
575
+ * a non-mergeable PR is routed back into; `close: true` marks the close state.
576
+ * Returns `undefined` when the block is absent or declares nothing meaningful
577
+ * (so an empty `pr: {}` doesn't shadow a legacy fold). The structural shape is
578
+ * validated here; the cross-reference (route_to is a declared state, pr only on
579
+ * terminal states, merge/close uniqueness) is in `validateStates`.
580
+ */
581
+ function parseStatePrBlock(stateName, raw) {
582
+ if (raw === undefined || raw === null)
583
+ return undefined;
584
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
585
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr must be a map (auto_merge / on_conflict / close)`);
586
+ }
587
+ const m = raw;
588
+ const out = {};
589
+ if (m['auto_merge'] !== undefined && m['auto_merge'] !== null) {
590
+ const s = asString(m['auto_merge']);
591
+ if (s !== 'squash' && s !== 'merge' && s !== 'rebase') {
592
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.auto_merge must be one of squash|merge|rebase`);
593
+ }
594
+ out.auto_merge = s;
595
+ }
596
+ if (m['on_conflict'] !== undefined && m['on_conflict'] !== null) {
597
+ const oc = m['on_conflict'];
598
+ if (typeof oc !== 'object' || Array.isArray(oc)) {
599
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict must be a map with a route_to field`);
600
+ }
601
+ const routeTo = asString(oc['route_to']);
602
+ if (!routeTo || routeTo.trim().length === 0) {
603
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict.route_to must be a non-empty state name`);
604
+ }
605
+ out.on_conflict = { route_to: routeTo.trim() };
606
+ }
607
+ if (m['close'] !== undefined && m['close'] !== null) {
608
+ if (typeof m['close'] !== 'boolean') {
609
+ throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.close must be a boolean`);
610
+ }
611
+ if (m['close'] === true)
612
+ out.close = true;
613
+ }
614
+ return Object.keys(out).length > 0 ? out : undefined;
615
+ }
616
+ export function derivePrRouting(states) {
617
+ let mergeState = null;
618
+ let closeState = null;
619
+ let conflictRouteTo = null;
620
+ let strategy = 'squash';
621
+ for (const [name, sc] of Object.entries(states)) {
622
+ if (sc.role !== 'terminal' || !sc.pr)
623
+ continue;
624
+ if (sc.pr.auto_merge && mergeState === null) {
625
+ mergeState = name;
626
+ strategy = sc.pr.auto_merge;
627
+ conflictRouteTo = sc.pr.on_conflict?.route_to ?? null;
628
+ }
629
+ if (sc.pr.close && closeState === null) {
630
+ closeState = name;
319
631
  }
320
632
  }
321
- // When acp.command is explicitly set, the operator owns the launch (and credential
322
- // staging). Otherwise the adapter id must be one symphony has a profile for, so
323
- // the runner can auto-derive the command and ship credentials.
324
- if (cfg.acp.command !== null) {
325
- if (!cfg.acp.command.trim())
326
- return 'acp.command must be non-empty when set';
633
+ return { mergeState, closeState, conflictRouteTo, strategy };
634
+ }
635
+ /**
636
+ * Resolve the typed action list a given state should run on transition-in.
637
+ * Case-insensitive state lookup; returns the parsed `WorkflowAction[]` (or
638
+ * undefined when the state has no actions block). The runner consults this on
639
+ * transition into a terminal state to drive the push/PR handoff.
640
+ */
641
+ export function resolveActionsForState(cfg, stateName) {
642
+ const states = cfg.states;
643
+ let key = null;
644
+ if (Object.prototype.hasOwnProperty.call(states, stateName)) {
645
+ key = stateName;
327
646
  }
328
- else if (!isKnownAdapter(cfg.acp.adapter)) {
329
- return `acp.adapter "${cfg.acp.adapter}" is not a known profile; set acp.command to override or use one of: claude, codex`;
647
+ else {
648
+ const lower = stateName.toLowerCase();
649
+ for (const name of Object.keys(states)) {
650
+ if (name.toLowerCase() === lower) {
651
+ key = name;
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ if (key === null)
657
+ return undefined;
658
+ return states[key].actions;
659
+ }
660
+ // Dispatch preflight validation (structural, pure). The fs-touching probes —
661
+ // `tracker.root` existence and the adapter credential files — live in the shell
662
+ // loader's `validateDispatchIo`, which the orchestrator calls alongside this
663
+ // function. Both adapters are startup-probed: claude requires a single readable
664
+ // host file (`~/.claude/.credentials.json`); codex passes when either
665
+ // `~/.codex/auth.json` holds a token (ChatGPT-OAuth `tokens.access_token` or a
666
+ // top-level `OPENAI_API_KEY`) or the host `OPENAI_API_KEY` env var is set. Keeping this
667
+ // structural half pure means tests and the reload tick can re-run it cheaply on
668
+ // every reconcile without re-hitting the disk.
669
+ export function validateDispatch(cfg) {
670
+ if (cfg.tracker.kind !== 'local') {
671
+ return `unsupported_tracker_kind: ${cfg.tracker.kind || '<missing>'}`;
330
672
  }
673
+ if (!cfg.tracker.root)
674
+ return 'tracker.root must be set for local tracker';
675
+ // `cfg.states` is always populated by buildServiceConfig — the parser refuses
676
+ // workflows without a `states:` block — so callers never need a fallback here.
677
+ const statesError = validateStates(cfg.states);
678
+ if (statesError)
679
+ return statesError;
680
+ // cfg.agent is always populated by buildServiceConfig; guard for legacy
681
+ // hand-built ServiceConfigs (older test fixtures) that omit the block.
682
+ const concurrencyError = validateConcurrencyCaps(cfg.states, cfg.agent?.max_concurrent_agents);
683
+ if (concurrencyError)
684
+ return concurrencyError;
685
+ if (!isKnownAdapter(cfg.acp.adapter)) {
686
+ return `acp.adapter "${cfg.acp.adapter}" is not a known profile; use one of: claude, codex, opencode`;
687
+ }
688
+ // PR autopilot routing (issue 139) is validated structurally inside
689
+ // `validateStates` (pr: only on terminal states; on_conflict.route_to must be
690
+ // a declared state; at most one merge/close state). The state's own `role` is
691
+ // authoritative, so the old `validatePrAutopilot` role re-validator is gone.
692
+ // Auto-arm trigger (issue 140) is validated structurally inside
693
+ // `validateStates` (arm: only on active states; arm.from must be a declared
694
+ // holding state; arm.issue required; at most one armed state). The state's own
695
+ // `role` is authoritative, so the old `validateSleepCycle` role re-validator is
696
+ // gone.
331
697
  return null;
332
698
  }
333
- // Build + watch a workflow source. Throws on initial load failure.
334
- export async function watchWorkflow(workflowPath) {
335
- const workflowAbs = path.resolve(workflowPath);
336
- const initialDef = await loadWorkflow(workflowAbs);
337
- const initialCfg = buildServiceConfig(initialDef.config, workflowAbs);
338
- let current = { definition: initialDef, config: initialCfg };
339
- const listeners = new Set();
340
- const watcher = chokidar.watch(workflowAbs, {
341
- ignoreInitial: true,
342
- persistent: true,
343
- });
344
- let reloadInFlight = null;
345
- const reload = async () => {
346
- if (reloadInFlight)
347
- return reloadInFlight;
348
- reloadInFlight = (async () => {
349
- try {
350
- const def = await loadWorkflow(workflowAbs);
351
- const cfg = buildServiceConfig(def.config, workflowAbs);
352
- current = { definition: def, config: cfg };
353
- log.info('workflow reloaded', { path: workflowAbs });
354
- for (const cb of listeners)
355
- cb({ definition: def, config: cfg });
699
+ /**
700
+ * Validate that the sum of per-state `max_concurrent` caps does not exceed the
701
+ * global `agent.max_concurrent_agents` host ceiling (issue 137). A sum greater
702
+ * than the ceiling can never be satisfied — the global clamp binds first — so it
703
+ * is almost always a misconfiguration worth surfacing at startup. Returns null
704
+ * when in budget or when the ceiling is unknown (legacy hand-built configs that
705
+ * omit `agent`). The legacy by-name map is already folded into the per-state
706
+ * caps by the time this runs, so its entries count toward the sum too.
707
+ */
708
+ function validateConcurrencyCaps(states, ceiling) {
709
+ if (typeof ceiling !== 'number')
710
+ return null;
711
+ let sum = 0;
712
+ for (const sc of Object.values(states)) {
713
+ if (typeof sc.max_concurrent === 'number')
714
+ sum += sc.max_concurrent;
715
+ }
716
+ if (sum > ceiling) {
717
+ return `sum of per-state max_concurrent caps (${sum}) exceeds agent.max_concurrent_agents (${ceiling})`;
718
+ }
719
+ return null;
720
+ }
721
+ // State-map validation, exposed as a string|null so it composes with the rest of
722
+ // `validateDispatch`. Checks declared in the same order the operator would hit
723
+ // them while iterating on a malformed workflow: structural (roles, uniqueness),
724
+ // then cross-references (allowed_transitions targets), then host-resource
725
+ // dependencies (adapter known + credential readable).
726
+ function validateStates(states) {
727
+ const names = Object.keys(states);
728
+ if (names.length === 0)
729
+ return 'states: at least one state must be declared';
730
+ let hasActive = false;
731
+ let hasTerminal = false;
732
+ let hasHolding = false;
733
+ for (const cfg of Object.values(states)) {
734
+ if (cfg.role === 'active')
735
+ hasActive = true;
736
+ else if (cfg.role === 'terminal')
737
+ hasTerminal = true;
738
+ else if (cfg.role === 'holding')
739
+ hasHolding = true;
740
+ }
741
+ if (!hasActive)
742
+ return 'states: at least one state must have role: active';
743
+ if (!hasTerminal)
744
+ return 'states: at least one state must have role: terminal';
745
+ // `holding` is required so `propose_issue` always has a declared landing
746
+ // directory; the dashboard's triage approve/discard surface also needs it.
747
+ if (!hasHolding)
748
+ return 'states: at least one state must have role: holding';
749
+ const seen = new Map();
750
+ for (const name of names) {
751
+ const key = name.toLowerCase();
752
+ const prior = seen.get(key);
753
+ if (prior !== undefined) {
754
+ return `states: duplicate state name (case-insensitive): "${prior}" and "${name}"`;
755
+ }
756
+ seen.set(key, name);
757
+ }
758
+ // PR autopilot routing (issue 139): `pr:` is only meaningful on a terminal
759
+ // state, `on_conflict.route_to` must name a declared state, and at most one
760
+ // terminal state may declare the merge (`auto_merge`) or close (`close`)
761
+ // behavior so `derivePrRouting`'s first-match is unambiguous.
762
+ let mergeStateCount = 0;
763
+ let closeStateCount = 0;
764
+ let armStateCount = 0;
765
+ for (const [name, cfg] of Object.entries(states)) {
766
+ if (cfg.allowed_transitions) {
767
+ for (const target of cfg.allowed_transitions) {
768
+ if (!seen.has(target.toLowerCase())) {
769
+ return `state "${name}": allowed_transitions references undeclared state "${target}"`;
770
+ }
356
771
  }
357
- catch (err) {
358
- const e = err instanceof WorkflowError
359
- ? err
360
- : new WorkflowError('workflow_parse_error', err.message);
361
- log.warn('workflow reload failed; keeping last good config', { error: e.message, code: e.code });
362
- for (const cb of listeners)
363
- cb({ error: e });
772
+ }
773
+ if (cfg.adapter !== undefined && !isKnownAdapter(cfg.adapter)) {
774
+ return `state "${name}": adapter "${cfg.adapter}" is not a known profile; use one of: claude, codex, opencode`;
775
+ }
776
+ if (cfg.pr) {
777
+ if (cfg.role !== 'terminal') {
778
+ return `state "${name}": pr: is only valid on a terminal state (got role: ${cfg.role})`;
364
779
  }
365
- finally {
366
- reloadInFlight = null;
780
+ if (cfg.pr.auto_merge)
781
+ mergeStateCount += 1;
782
+ if (cfg.pr.close)
783
+ closeStateCount += 1;
784
+ const routeTo = cfg.pr.on_conflict?.route_to;
785
+ if (routeTo && !seen.has(routeTo.toLowerCase())) {
786
+ return `state "${name}": pr.on_conflict.route_to references undeclared state "${routeTo}"`;
367
787
  }
368
- })();
369
- return reloadInFlight;
370
- };
371
- watcher.on('change', () => void reload());
372
- watcher.on('add', () => void reload());
373
- // §5.5: workflow read errors must block dispatch. If the file is deleted or temporarily
374
- // renamed, surface a missing_workflow_file error so the orchestrator knows.
375
- watcher.on('unlink', () => void reload());
376
- return {
377
- current: () => current,
378
- onChange: (cb) => {
379
- listeners.add(cb);
380
- return () => listeners.delete(cb);
381
- },
382
- stop: () => watcher.close(),
383
- };
788
+ }
789
+ // Auto-arm trigger (issue 140): `arm:` is only meaningful on an active state,
790
+ // `arm.issue` is required, `arm.from` must name a declared holding state, and
791
+ // at most one active state may declare arm so `deriveArmRouting`'s first-match
792
+ // is unambiguous. The state's own `role` is authoritative — no separate
793
+ // sleep-cycle role re-validator.
794
+ if (cfg.arm) {
795
+ armStateCount += 1;
796
+ if (cfg.role !== 'active') {
797
+ return `state "${name}": arm: is only valid on an active state (got role: ${cfg.role})`;
798
+ }
799
+ if (!cfg.arm.issue || cfg.arm.issue.trim().length === 0) {
800
+ return `state "${name}": arm.issue is required (the recurring issue to arm into this state)`;
801
+ }
802
+ const fromCanonical = seen.get(cfg.arm.from.toLowerCase());
803
+ if (!fromCanonical) {
804
+ return `state "${name}": arm.from references undeclared state "${cfg.arm.from}"`;
805
+ }
806
+ if (states[fromCanonical].role !== 'holding') {
807
+ return `state "${name}": arm.from "${cfg.arm.from}" must be a holding state (got role: ${states[fromCanonical].role})`;
808
+ }
809
+ }
810
+ }
811
+ if (armStateCount > 1) {
812
+ return `states: at most one active state may declare arm (found ${armStateCount})`;
813
+ }
814
+ if (mergeStateCount > 1) {
815
+ return `states: at most one terminal state may declare pr.auto_merge (found ${mergeStateCount})`;
816
+ }
817
+ if (closeStateCount > 1) {
818
+ return `states: at most one terminal state may declare pr.close (found ${closeStateCount})`;
819
+ }
820
+ return null;
384
821
  }
385
822
  //# sourceMappingURL=workflow.js.map