flowviant 0.125.5 → 0.125.6

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 (86) hide show
  1. package/bin/lib/agentInternalWait.mjs +1 -5
  2. package/bin/lib/agentPlan.mjs +13 -163
  3. package/bin/lib/agentRepositories.mjs +3 -1
  4. package/bin/lib/claudeInput.mjs +28 -9
  5. package/bin/lib/claudeMachineMcp.mjs +85 -0
  6. package/bin/lib/claudePersonal.mjs +6 -57
  7. package/bin/lib/claudePosture.mjs +6 -15
  8. package/bin/lib/claudeSettingsArg.mjs +67 -0
  9. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  10. package/bin/lib/claudeSubagents.mjs +4 -2
  11. package/bin/lib/codexFencedMount.mjs +275 -0
  12. package/bin/lib/codexPersonal.mjs +1 -265
  13. package/bin/lib/codexProjectRead.mjs +5 -4
  14. package/bin/lib/codexResearch.mjs +9 -3
  15. package/bin/lib/codexSubagentEvents.mjs +11 -7
  16. package/bin/lib/codexSubagents.mjs +55 -52
  17. package/bin/lib/codexToml.mjs +328 -0
  18. package/bin/lib/conversationIds.mjs +10 -0
  19. package/bin/lib/fleet.mjs +2 -2
  20. package/bin/lib/git.mjs +32 -0
  21. package/bin/lib/heldTool.mjs +49 -0
  22. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  23. package/bin/lib/hooks/orchestrator.mjs +20 -10
  24. package/bin/lib/intakeDraft.mjs +1 -1
  25. package/bin/lib/jsonlTail.mjs +52 -0
  26. package/bin/lib/mcpInventory.mjs +9 -5
  27. package/bin/lib/mcpReasons.mjs +20 -0
  28. package/bin/lib/mcpReportBounds.mjs +10 -0
  29. package/bin/lib/mcpStdio.mjs +35 -0
  30. package/bin/lib/mergeBatch.mjs +6 -24
  31. package/bin/lib/mergeOrder.mjs +18 -0
  32. package/bin/lib/modelJson.mjs +173 -0
  33. package/bin/lib/nativeAgentTools.mjs +72 -0
  34. package/bin/lib/orchestrator.mjs +212 -133
  35. package/bin/lib/orchestratorClient.mjs +17 -21
  36. package/bin/lib/orchestratorHooks.mjs +21 -4
  37. package/bin/lib/orchestratorNative.mjs +58 -0
  38. package/bin/lib/orchestratorPrompt.mjs +7 -4
  39. package/bin/lib/orchestratorProtocol.mjs +25 -3
  40. package/bin/lib/postureTools.mjs +58 -13
  41. package/bin/lib/prWorkflow.mjs +13 -2
  42. package/bin/lib/projectRepositories.mjs +2 -2
  43. package/bin/lib/projectToolRuntimes.mjs +92 -245
  44. package/bin/lib/projectTools.mjs +59 -19
  45. package/bin/lib/repositoryDelivery.mjs +6 -4
  46. package/bin/lib/repositoryMerge.mjs +8 -12
  47. package/bin/lib/repositoryPlaces.mjs +2 -2
  48. package/bin/lib/repositoryPrompts.mjs +42 -0
  49. package/bin/lib/repositoryTool.mjs +9 -32
  50. package/bin/lib/researchUse.mjs +39 -0
  51. package/bin/lib/resumeMarker.mjs +43 -0
  52. package/bin/lib/runTurn.mjs +34 -52
  53. package/bin/lib/runtimeAntigravity.mjs +1 -1
  54. package/bin/lib/runtimeCapabilities.mjs +6 -16
  55. package/bin/lib/runtimeClaude.mjs +47 -20
  56. package/bin/lib/runtimeCodex.mjs +9 -6
  57. package/bin/lib/runtimeEvents.mjs +13 -2
  58. package/bin/lib/runtimeOfferings.mjs +9 -4
  59. package/bin/lib/runtimes.mjs +3 -1
  60. package/bin/lib/subagentBounds.mjs +20 -0
  61. package/bin/lib/trace.mjs +30 -5
  62. package/bin/lib/turnDeclaration.mjs +1 -1
  63. package/bin/lib/turnDiagnostics.mjs +9 -12
  64. package/bin/lib/turnReceipts.mjs +64 -0
  65. package/bin/lib/turnSubagents.mjs +91 -0
  66. package/bin/lib/turnUsage.mjs +9 -6
  67. package/bin/lib/versionOrder.mjs +11 -1
  68. package/bin/lib/work.mjs +6 -6
  69. package/bin/lib/workAgentMerges.mjs +5 -7
  70. package/bin/lib/workAgentPrecheck.mjs +5 -5
  71. package/bin/lib/workAgentRecovery.mjs +2 -1
  72. package/bin/lib/workAgentResume.mjs +121 -0
  73. package/bin/lib/workAgentTools.mjs +111 -0
  74. package/bin/lib/workAgentTurnExecution.mjs +62 -246
  75. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  76. package/bin/lib/workAgentTurnShape.mjs +90 -0
  77. package/bin/lib/workAgentTurns.mjs +24 -10
  78. package/bin/lib/workArtifactReconcile.mjs +3 -2
  79. package/bin/lib/workMergeBatch.mjs +7 -5
  80. package/bin/lib/workPlaceReports.mjs +3 -3
  81. package/bin/lib/workPlaces.mjs +14 -0
  82. package/bin/lib/workSessionRuntime.mjs +2 -5
  83. package/bin/lib/workSessionTurns.mjs +12 -63
  84. package/bin/lib/workShip.mjs +8 -18
  85. package/bin/lib/workTurnShape.mjs +6 -4
  86. package/package.json +1 -1
@@ -15,12 +15,13 @@
15
15
  * projectTools.mjs. projectTools.test.mjs pins every transport word to here.
16
16
  */
17
17
 
18
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
19
19
  import { join, resolve } from 'node:path';
20
20
  import { codexHome } from './codexHome.mjs';
21
- import { parseCodexToml, TomlRaw } from './codexPersonal.mjs';
21
+ import { bareKey, headerKeys, lineKeys, parseCodexToml, toml, tomlValue } from './codexToml.mjs';
22
+ import { SERVER_NAME, codexFencedMount } from './codexFencedMount.mjs';
22
23
 
23
- export const toml = (value) => JSON.stringify(String(value));
24
+ export { MCP_HEADER_ENV_PREFIX } from './codexFencedMount.mjs';
24
25
 
25
26
  /**
26
27
  * ONE LINK PER PERSONAL ENTRY, MADE OR MENDED IN PLACE (2026-09-29). A home
@@ -44,31 +45,6 @@ function linkPersonalEntry(target, link) {
44
45
  }
45
46
  }
46
47
 
47
- /** A table header's dotted keys, unquoted — null for one this reader cannot
48
- * split, which is then nobody's to strip. */
49
- function headerKeys(section) {
50
- const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
51
- const keys = [];
52
- for (let i = 0; i < section.length; ) {
53
- key.lastIndex = i;
54
- const m = key.exec(section);
55
- if (!m || key.lastIndex === i) return null;
56
- try {
57
- keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
58
- } catch {
59
- return null;
60
- }
61
- i = key.lastIndex;
62
- }
63
- return keys;
64
- }
65
-
66
- /** The key a `key = …` / `key.sub = …` line defines, unquoted, or null. */
67
- const lineKey = (line) => {
68
- const m = /^\s*(?:"([^"\\]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*[.=]/.exec(line);
69
- return m ? (m[1] ?? m[2] ?? m[3]) : null;
70
- };
71
-
72
48
  /**
73
49
  * THE REPO'S MCP SERVERS, AS THE AGENT'S OWN CONFIG (2026-10-10, the audit).
74
50
  * A Codex build turn was handed the base branch's servers as `-c` pairs —
@@ -79,7 +55,7 @@ const lineKey = (line) => {
79
55
  * ARGV"). They are written here instead, as tables of the agent's private
80
56
  * CODEX_HOME config.toml (0600, in a 0700 home), which a build turn reads and
81
57
  * every fenced posture ignores (`--ignore-user-config`) — the same turns the
82
- * `-c` pairs reached. Written in the shape codexPersonal.mjs's bounded reader
58
+ * `-c` pairs reached. Written in the shape codexToml.mjs's bounded reader
83
59
  * parses, since a fenced turn reads this file for its login.
84
60
  *
85
61
  * SO THE EXPANDED VALUES NOW SIT ON DISK, AND THAT IS STATED (2026-10-10). The
@@ -92,17 +68,25 @@ const lineKey = (line) => {
92
68
  * next turn; a home with no git dir to hold it dies with its turn. NOT DONE:
93
69
  * a URL that would carry an expanded secret is written as any other — refusing
94
70
  * it ('missing') is an open follow-up from the same audit finding (F660).
71
+ *
72
+ * EACH IS APPROVED BY NAME (2026-10-17). These are the daemon's own servers
73
+ * and the reviewed base copy, and a card turn runs `approval_policy="never"`:
74
+ * under SAFE's workspace-write every unannotated tool failed "MCP tool call
75
+ * requires approval, but approval policy is never" (measured, codex-cli
76
+ * 0.163.0 — route_message among them), as the fenced mount had already found.
77
+ * The person's own tables keep their own modes.
95
78
  */
96
79
  function codexServerTables(servers) {
97
80
  let out = '';
98
81
  for (const [name, server] of Object.entries(servers ?? {})) {
99
82
  out += `\n[mcp_servers.${name}]\n`;
83
+ const approve = 'default_tools_approval_mode = "approve"\n';
100
84
  if (typeof server.command === 'string') {
101
- out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n`;
85
+ out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n${approve}`;
102
86
  const env = Object.entries(server.env ?? {}).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
103
87
  if (env.length) out += `[mcp_servers.${name}.env]\n${env.map(([key, value]) => `${key} = ${toml(value)}`).join('\n')}\n`;
104
88
  } else {
105
- out += `url = ${toml(server.url)}\n`;
89
+ out += `url = ${toml(server.url)}\n${approve}`;
106
90
  const headers = Object.entries(server.headers ?? {});
107
91
  if (headers.length) out += `[mcp_servers.${name}.http_headers]\n${headers.map(([key, value]) => `${toml(key)} = ${toml(value)}`).join('\n')}\n`;
108
92
  }
@@ -132,32 +116,53 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
132
116
  const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
133
117
  const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
134
118
  // The repo's servers replace a personal server of the same name whole —
135
- // its `[mcp_servers.<n>…]` tables are dropped below. A personal
136
- // `mcp_servers` written inline cannot be told apart line by line, so it
137
- // fails closed, as an inline `skills.config` does below.
119
+ // its `[mcp_servers.<n>…]` tables are dropped below.
120
+ //
121
+ // A PERSONAL SERVER WRITTEN OUTSIDE A TABLE (2026-10-17). Dotted keys
122
+ // (`mcp_servers.roblox.command = …`, at the top or under `[mcp_servers]`)
123
+ // stand beside an appended table (measured, codex-cli 0.163.0), so they are
124
+ // kept, and refused only when they name one of these servers: a dotted
125
+ // definition cannot be dropped whole line by line. The one-line inline
126
+ // table (`mcp_servers = { … }`) does NOT stand beside one ("failed to load
127
+ // bootstrap configuration", measured), so it is read and rewritten as one
128
+ // dotted line per server, without the names these servers replace; one this
129
+ // reader cannot take alone fails closed, as an inline `skills.config` does
130
+ // below. Names compare unquoted, however the person quoted them.
138
131
  const repoServers = new Set(Object.keys(mcpServers ?? {}));
139
132
  const inline = () => new Error('Cannot isolate Codex project MCP servers beside an inline mcp_servers entry in config.toml');
140
- if (repoServers.size && /^\s*["']?mcp_servers["']?\s*[.=]/m.test(topLevel)) throw inline();
133
+ const dotted = (name) => new Error(`Cannot isolate Codex MCP server ${name}: config.toml defines one of that name outside its own table, which cannot be replaced line by line — write it as a [mcp_servers.${name}] table`);
141
134
  // The `projects` sections alone grant a checkout permission to supply
142
135
  // project settings. Keep every other personal setting, skill and session
143
136
  // store.
144
137
  const lines = sourceConfig.split('\n');
145
138
  let dropping = false;
146
139
  let inServers = false;
147
- let config = lines.filter((line) => {
140
+ let top = true;
141
+ let config = lines.flatMap((line) => {
148
142
  const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
149
143
  if (header) {
150
144
  const section = header[1] ?? header[2];
151
145
  const keys = headerKeys(section);
146
+ top = false;
152
147
  inServers = keys?.length === 1 && keys[0] === 'mcp_servers';
153
148
  dropping =
154
149
  section === 'projects' ||
155
150
  section.startsWith('projects.') ||
156
151
  (keys?.[0] === 'mcp_servers' && keys.length > 1 && repoServers.has(keys[1]));
157
- } else if (inServers && repoServers.has(lineKey(line))) {
158
- throw inline();
152
+ } else if (repoServers.size && (top || inServers)) {
153
+ const keys = lineKeys(line);
154
+ const name = top ? (keys?.[0] === 'mcp_servers' ? keys[1] : undefined) : keys?.[0];
155
+ if (top && !keys && /^\s*["']?mcp_servers["']?\s*[.=]/.test(line)) throw inline();
156
+ if (name !== undefined && repoServers.has(name)) throw dotted(name);
157
+ if (top && keys?.length === 1 && keys[0] === 'mcp_servers') {
158
+ let servers;
159
+ try { servers = parseCodexToml(line).mcp_servers; } catch { throw inline(); }
160
+ return Object.entries(servers)
161
+ .filter(([key]) => !repoServers.has(key))
162
+ .map(([key, value]) => `mcp_servers.${bareKey(key)} = ${tomlValue(value)}`);
163
+ }
159
164
  }
160
- return !dropping;
165
+ return dropping ? [] : [line];
161
166
  }).join('\n');
162
167
  if (worktree) {
163
168
  // Codex discovers these even in an untrusted project. Disable exactly the
@@ -176,6 +181,8 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
176
181
  for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
177
182
  }
178
183
  config += codexServerTables(mcpServers);
184
+ const approvals = personalApprovals(sourceConfig, repoServers);
185
+ const machineServers = personalServerNames(sourceConfig, repoServers);
179
186
 
180
187
  // The home itself, only once the config is decided: a refused turn touches
181
188
  // nothing here. A home that is not a real directory is not ours to write
@@ -208,213 +215,51 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
208
215
  } finally {
209
216
  rmSync(staged, { force: true });
210
217
  }
211
- return { path: dest, personalDeveloperInstructions };
218
+ return { path: dest, personalDeveloperInstructions, approvals, machineServers };
212
219
  }
213
220
 
214
- /** The environment names a fenced Codex turn's MCP header values ride under,
215
- * one per header (`codexFencedMount`). */
216
- export const MCP_HEADER_ENV_PREFIX = 'FLOWVIANT_MCP_HEADER_';
217
- const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
218
- const SERVER_NAME = /^[A-Za-z0-9_-]{1,80}$/;
219
- const expands = (value) => /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(String(value));
220
221
  /**
221
- * Names this machine's Codex, or the shell and tools under it, read for
222
- * themselves. A server's env value rides in the CLI's own environment
223
- * (`env_vars` forwards it), so a name the machine does not already set to that
224
- * value would change how the CLI itself runs — a proxy, a CA bundle, a
225
- * provider key. Such a server is left unmounted, in words.
222
+ * THE PERSON'S OWN SERVERS, APPROVED WHERE THEY SET NO MODE (2026-10-17, the
223
+ * review). A card turn runs `approval_policy="never"`, and under SAFE's
224
+ * workspace-write a machine server's unannotated tool failed "MCP tool call
225
+ * requires approval, but approval policy is never" (measured, codex-cli
226
+ * 0.163.0) while the same card on Claude reaches it through the allow hook
227
+ * (hooks/mcpAllow.mjs) and a fenced Codex card through its own mount — the
228
+ * owner: board agents use the machine's MCP servers, fenced cards included.
229
+ * As `-c` pairs, not lines of the kept file: a server written as an inline
230
+ * table takes no key after it, and a `-c` pair reaches it however it is
231
+ * written (measured). A mode the person set is theirs and stays; a file this
232
+ * reader cannot parse approves nothing.
226
233
  */
227
- const CLI_ENV =
228
- /^(PATH|HOME|SHELL|USER|LOGNAME|PWD|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|DISPLAY|WAYLAND_DISPLAY|BROWSER|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|RUST_LOG|RUST_BACKTRACE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy)$|^(LC|XDG|LD|DYLD|OPENAI|CODEX|AZURE_OPENAI|FLOWVIANT)_/;
229
-
230
- /** This machine's own Codex servers: its config.toml's `[mcp_servers.*]`,
231
- * read the way codexPersonal.mjs reads the login (bounded; a file that does
232
- * not parse carries nothing). */
233
- function machineCodexServers(machineEnv) {
234
- try {
235
- const path = join(resolve(codexHome(machineEnv)), 'config.toml');
236
- const st = statSync(path);
237
- if (!st.isFile() || st.size > 1024 * 1024) return [];
238
- const servers = parseCodexToml(readFileSync(path, 'utf8'))?.mcp_servers;
239
- if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
240
- return Object.entries(servers).filter(([, v]) => v && typeof v === 'object' && !Array.isArray(v));
241
- } catch {
242
- return [];
243
- }
234
+ function personalApprovals(sourceConfig, repoServers) {
235
+ let servers;
236
+ try { servers = parseCodexToml(sourceConfig)?.mcp_servers; } catch { return []; }
237
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
238
+ return Object.entries(servers)
239
+ .filter(([name, v]) => !repoServers.has(name) && SERVER_NAME.test(name) && v && typeof v === 'object' && !Array.isArray(v)
240
+ && (typeof v.command === 'string' || typeof v.url === 'string') && v.default_tools_approval_mode === undefined)
241
+ .flatMap(([name]) => ['-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`]);
244
242
  }
245
- const tomlScalar = (v) => (v instanceof TomlRaw ? v.text : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
246
- const words = (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null);
247
- const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
248
243
 
249
244
  /**
250
- * THE MCP SERVERS ON A FENCED CODEX TURN — the project's and, since 2026-10-11,
251
- * the machine's own (the owner: "the server that is running flowviant's claude
252
- * HAS these MCPs yet the agents are not using them"). A Codex build reads both
253
- * from the agent's config.toml (`isolatedCodexHome`: the person's own tables
254
- * kept, the repo's beside them). Every fenced posture — write-up, mockup,
255
- * 3D model, deck, image — passes `--ignore-user-config`, which drops that
256
- * file, and a `-p` profile layered beside the flag was measured to load no
257
- * server either (codex-cli 0.159.2, artifactProjectMcp.test.mjs). So these
258
- * turns take the servers as `-c` pairs, the way codexPersonal.mjs carries a
259
- * login back, under the same rule: A LITERAL SECRET NEVER RIDES ARGV.
260
- *
261
- * · A stdio server's command and arguments ride as written: they are the
262
- * server process's own command line once Codex starts it, so `-c` adds no
263
- * reader. A project server whose command or arguments expand a `${VAR}`
264
- * is still left unmounted (the 2026-10-10 audit took expanded values off
265
- * argv, and this keeps that line).
266
- * · Every env value rides in the turn's ENVIRONMENT under its own name,
267
- * forwarded by Codex's `env_vars` — except a project value with no `${VAR}`,
268
- * which is text of the reviewed file and rides as written. Refused (the
269
- * server is left unmounted, with the reason) when the name is the
270
- * daemon's own (`FLOWVIANT_*`, `CODEX_HOME`), when this machine already
271
- * sets it to something else, or when it is one the CLI reads itself
272
- * (`CLI_ENV`) and the machine does not set it.
273
- * · Every header value rides in the environment, named by
274
- * `env_http_headers`. A machine server's own `env_http_headers`,
275
- * `bearer_token_env_var` and `env_vars` are names, and ride as written.
276
- * · A URL that expands a `${VAR}`, or carries a query, fragment or
277
- * credentials, has no indirection in Codex's config: left unmounted.
278
- * · A machine server keeps its own `cwd`, timeouts and tool lists; it is
279
- * skipped where the person turned it off (`enabled = false`), and a
280
- * project server of the same name replaces it, as on the build.
281
- * · Each mounted server's tools are approved by name: an unannotated tool
282
- * under a fenced posture failed "MCP tool call requires approval, but
283
- * approval policy is never" (measured, 0.159.2) — never a blanket
284
- * approval policy.
285
- *
286
- * `usable` is the base snapshot's ready entries; `expand` reads a value the
287
- * way projectTools.mjs does; `machineEnv` is the caller's own (what the child
288
- * would inherit). Returns `{ args, env, mounted, machine, unmounted }`:
289
- * `mounted` the project servers, `machine` the machine's own, and
290
- * `unmounted` `[{ name, reason, source: 'project' | 'machine' }]`.
245
+ * …AND THEIR NAMES, AS A BUILD CARD IS TOLD THEM (2026-10-18). The kept
246
+ * config.toml mounts every server the person configured beside the repo's —
247
+ * a URL with a query, a mode set to ask and every other one the fenced
248
+ * mount refuses — so a code card is told these, in file order, and not the
249
+ * fenced mount's list (2026-10-17: a card is told the machine's servers by
250
+ * name). `[]` with no config, NULL for one this reader cannot parse: unknown,
251
+ * never "none".
291
252
  */
292
- function codexFencedMount(usable, expand, machineEnv) {
293
- const args = [];
294
- const childEnv = {};
295
- const mounted = [];
296
- const machine = [];
297
- const unmounted = [];
298
- let header = 0;
299
- /** A refusal sentence, or null with the value staged in `extra`. */
300
- const forward = (key, value, extra) => {
301
- if (key.startsWith('FLOWVIANT_') || key === 'CODEX_HOME') return `its env ${key} is a name this daemon keeps for itself`;
302
- const has = machineEnv[key];
303
- if (has !== undefined) return has === value ? null : `its env ${key} would replace this machine's own ${key}, which a fenced Codex turn cannot do`;
304
- if (CLI_ENV.test(key)) return `its env ${key} is one this machine's Codex reads itself, which a fenced Codex turn cannot set`;
305
- const staged = childEnv[key] ?? extra[key];
306
- if (staged !== undefined && staged !== value) return `its env ${key} would replace another server's ${key}`;
307
- extra[key] = value;
308
- return null;
309
- };
310
- const headerEnv = (headers, extra) =>
311
- Object.entries(headers).map(([key, value]) => {
312
- const envName = `${MCP_HEADER_ENV_PREFIX}${header++}`;
313
- extra[envName] = value;
314
- return `${toml(key)}=${toml(envName)}`;
315
- });
316
- const accept = (name, pairs, extra, list) => {
317
- args.push(...pairs, '-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`);
318
- Object.assign(childEnv, extra);
319
- list.push(name);
320
- };
321
-
322
- for (const { name, config } of usable) {
323
- const pairs = [];
324
- const extra = {};
325
- let refused = null;
326
- if (typeof config.command === 'string') {
327
- const rawArgs = Array.isArray(config.args) ? config.args : [];
328
- if (expands(config.command) || rawArgs.some(expands)) {
329
- refused = 'its command or arguments name a ${VAR}, which a fenced Codex turn would put on the command line';
330
- } else {
331
- pairs.push('-c', `mcp_servers.${name}.command=${toml(String(config.command))}`);
332
- pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map((v) => toml(String(v))).join(',')}]`);
333
- const forwarded = [];
334
- for (const [key, value] of Object.entries(config.env ?? {})) {
335
- if (!ENV_NAME.test(key)) continue;
336
- if (!expands(value)) {
337
- pairs.push('-c', `mcp_servers.${name}.env.${key}=${toml(String(value))}`);
338
- continue;
339
- }
340
- refused = forward(key, expand(value), extra);
341
- if (refused) break;
342
- forwarded.push(key);
343
- }
344
- if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
345
- }
346
- } else if (typeof config.url === 'string') {
347
- if (expands(config.url)) {
348
- refused = 'its URL names a ${VAR}, which a fenced Codex turn would put on the command line';
349
- } else {
350
- pairs.push('-c', `mcp_servers.${name}.url=${toml(String(config.url))}`);
351
- const named = headerEnv(Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)])), extra);
352
- if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
353
- }
354
- } else continue;
355
- if (refused) unmounted.push({ name, reason: refused, source: 'project' });
356
- else accept(name, pairs, extra, mounted);
357
- }
358
-
359
- const projectNames = new Set(usable.map((t) => t.name));
360
- for (const [name, server] of machineCodexServers(machineEnv)) {
361
- if (projectNames.has(name) || name === 'flowviant' || server.enabled === false) continue;
362
- if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
363
- const pairs = [];
364
- const extra = {};
365
- let refused = SERVER_NAME.test(name) ? null : 'its name cannot be spelled as a command-line setting';
366
- if (!refused && typeof server.command === 'string') {
367
- const rawArgs = server.args === undefined ? [] : words(server.args);
368
- if (!rawArgs) refused = 'its args are not a list of words';
369
- else {
370
- pairs.push('-c', `mcp_servers.${name}.command=${toml(server.command)}`);
371
- pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map(toml).join(',')}]`);
372
- const forwarded = [...(words(server.env_vars) ?? []).filter((k) => ENV_NAME.test(k))];
373
- for (const [key, value] of Object.entries(table(server.env))) {
374
- if (!ENV_NAME.test(key) || typeof value !== 'string') {
375
- refused = `its env ${String(key).slice(0, 40)} is not a name and text`;
376
- break;
377
- }
378
- refused = forward(key, value, extra);
379
- if (refused) break;
380
- if (!forwarded.includes(key)) forwarded.push(key);
381
- }
382
- if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
383
- if (typeof server.cwd === 'string') pairs.push('-c', `mcp_servers.${name}.cwd=${toml(server.cwd)}`);
384
- }
385
- } else if (!refused) {
386
- let url = null;
387
- try { url = new URL(server.url); } catch { /* refused below */ }
388
- if (!url || url.search || url.hash || url.username || url.password) {
389
- refused = 'its URL carries a query, fragment or credentials, which a fenced Codex turn would put on the command line';
390
- } else {
391
- pairs.push('-c', `mcp_servers.${name}.url=${toml(server.url)}`);
392
- if (typeof server.bearer_token_env_var === 'string' && ENV_NAME.test(server.bearer_token_env_var))
393
- pairs.push('-c', `mcp_servers.${name}.bearer_token_env_var=${toml(server.bearer_token_env_var)}`);
394
- const literal = table(server.http_headers);
395
- const byName = table(server.env_http_headers);
396
- if (Object.values(literal).some((v) => typeof v !== 'string') || Object.values(byName).some((v) => typeof v !== 'string' || !ENV_NAME.test(v))) {
397
- refused = 'its headers are not text';
398
- } else {
399
- const named = [...headerEnv(literal, extra), ...Object.entries(byName).map(([key, envName]) => `${toml(key)}=${toml(envName)}`)];
400
- if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
401
- }
402
- }
403
- }
404
- if (!refused) {
405
- for (const key of ['startup_timeout_sec', 'startup_timeout_ms', 'tool_timeout_sec']) {
406
- const v = tomlScalar(server[key]);
407
- if (v != null && /^[0-9_.]+$/.test(v)) pairs.push('-c', `mcp_servers.${name}.${key}=${v}`);
408
- }
409
- for (const key of ['enabled_tools', 'disabled_tools']) {
410
- const list = words(server[key]);
411
- if (list) pairs.push('-c', `mcp_servers.${name}.${key}=[${list.map(toml).join(',')}]`);
412
- }
413
- }
414
- if (refused) unmounted.push({ name, reason: refused, source: 'machine' });
415
- else accept(name, pairs, extra, machine);
416
- }
417
- return { args, env: childEnv, mounted, machine, unmounted };
253
+ function personalServerNames(sourceConfig, repoServers) {
254
+ if (sourceConfig === '') return [];
255
+ let servers;
256
+ try { servers = parseCodexToml(sourceConfig)?.mcp_servers; } catch { return null; }
257
+ if (servers === undefined) return [];
258
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
259
+ return Object.entries(servers)
260
+ .filter(([name, v]) => !repoServers.has(name) && v && typeof v === 'object' && !Array.isArray(v)
261
+ && (typeof v.command === 'string' || typeof v.url === 'string') && v.enabled !== false)
262
+ .map(([name]) => name);
418
263
  }
419
264
 
420
265
  /** Claude takes the base branch's skills as a plugin written for this turn. */
@@ -437,7 +282,8 @@ function reviewedClaudePlugin(dir, snapshot) {
437
282
  * turn)` returns the runner-specific part of prepareAgentTools' result — under
438
283
  * `dir`, which dies with the turn, except what `turn.codexHome` names a
439
284
  * lasting place for. `turn.mcpServers` is the base branch's servers this
440
- * runtime takes, expanded.
285
+ * runtime takes, expanded; `turn.fenced` a posture that reads no config file
286
+ * of the runner's own.
441
287
  */
442
288
  const TOOL_RUNTIMES = {
443
289
  claude: {
@@ -452,10 +298,11 @@ const TOOL_RUNTIMES = {
452
298
  refuseMcp: (config) => (config.type === 'sse' ? 'Codex cannot take SSE MCP' : null),
453
299
  // The agent's own lasting home when the caller has one (2026-09-29: its
454
300
  // threads are indexed under it — see isolatedCodexHome), else one that
455
- // dies with the turn, as every home did before.
301
+ // dies with the turn, as every home did before. A fenced turn ignores the
302
+ // home's config.toml, so no server is written into it (2026-10-17).
456
303
  prepare: (dir, _snapshot, env, worktree, turn) => {
457
- const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
458
- return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
304
+ const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : turn?.mcpServers);
305
+ return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions, codexApprovals: turn?.fenced ? [] : isolated.approvals, codexMachineServers: turn?.fenced ? null : isolated.machineServers };
459
306
  },
460
307
  // A fenced posture ignores that home's config.toml (2026-10-10).
461
308
  fencedMount: codexFencedMount,
@@ -472,9 +319,9 @@ const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES
472
319
  * null for a runner whose fenced turns read the same file as its build
473
320
  * (Claude's strict mcp.json) or that takes none. `usable` is the base
474
321
  * snapshot's ready entries; `expand(value)` reads a `${VAR}`. */
475
- export function fencedMountFor(runner, usable, expand, machineEnv = process.env) {
322
+ export function fencedMountFor(runner, usable, expand, machineEnv = process.env, turnServers = {}) {
476
323
  const mount = rowFor(runner).fencedMount;
477
- return mount ? mount(usable, expand, machineEnv) : null;
324
+ return mount ? mount(usable, expand, machineEnv, turnServers) : null;
478
325
  }
479
326
 
480
327
  /** Pure mapping used before a future tool-attach gesture is offered. */
@@ -501,5 +348,5 @@ export function skillsRefusal(runner) {
501
348
  /** The runner-specific files a turn needs, written under `dir` — or, for the
502
349
  * `codexHome` it names, where the caller keeps it between turns. */
503
350
  export function prepareForRunner(runner, dir, snapshot, env, worktree, turn = {}) {
504
- return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
351
+ return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, codexApprovals: [], codexMachineServers: null, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
505
352
  }
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { git, gitRaw, splitNul } from './git.mjs';
6
6
  import { withoutMachineCredentials } from './machineEnv.mjs';
7
7
  import { fencedMountFor, mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
8
+ import { mcpCommandNotOnPathReason, mcpEnvUnsetReason, mcpLauncherNotExecutableReason } from './mcpReasons.mjs';
8
9
 
9
10
  const MAX_TOOLS = 40;
10
11
  const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
@@ -39,14 +40,41 @@ function onPath(command, env) {
39
40
  const relativeCommand = (command) => !isAbsolute(command) && command.includes('/');
40
41
 
41
42
  /** Repo launchers are not PATH commands. Measure the reviewed tree's mode,
42
- * never an uncommitted executable in the checkout used to read that tree. */
43
- function reviewedExecutable(root, commit, command) {
43
+ * never an uncommitted executable in the checkout used to read that tree —
44
+ * `{ path, blob }` for an executable file there, else null. */
45
+ function reviewedLauncher(root, commit, command) {
44
46
  const path = relative(resolve(root), resolve(root, command));
45
- if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return false;
47
+ if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return null;
46
48
  try {
47
- return splitNul(gitRaw(['ls-tree', '-z', commit, '--', path], root, SHOW))
48
- .some((entry) => entry.startsWith('100755 blob ') && entry.slice(entry.indexOf('\t') + 1) === path);
49
- } catch { return false; }
49
+ for (const entry of splitNul(gitRaw(['ls-tree', '-z', commit, '--', path], root, SHOW))) {
50
+ const m = /^100755 blob ([0-9a-f]+)\t/.exec(entry);
51
+ if (m && entry.slice(entry.indexOf('\t') + 1) === path) return { path, blob: m[1] };
52
+ }
53
+ } catch { /* not in the reviewed tree */ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * …AND THE FILE A TURN RUNS IS THAT FILE (2026-10-17). The launcher is run
59
+ * from the agent's worktree, where a branch cut before it landed has none
60
+ * and the agent may have edited it — and an MCP server runs outside every
61
+ * fence of the turn (a Codex stdio server is not sandboxed). So the
62
+ * worktree's copy must be the reviewed blob, byte for byte (`hash-object
63
+ * --no-filters`, which runs no filter the worktree configures), or the server
64
+ * is not mounted, in words. Checked here rather than copied out of the tree,
65
+ * so a launcher still finds what it names beside itself. What the launcher
66
+ * then runs is its own business, as a PATH command's is.
67
+ */
68
+ function launcherRefusal(snapshot, launchRoot, { config, launcher }) {
69
+ const file = resolve(launchRoot, launcher.path);
70
+ const named = `the launcher ${config.command}`;
71
+ try {
72
+ if (!statSync(file).isFile()) return `${named} is not in this worktree`;
73
+ } catch { return `${named} is not in this worktree`; }
74
+ try { accessSync(file, constants.X_OK); } catch { return `${named} is not executable in this worktree`; }
75
+ let blob = null;
76
+ try { blob = git(['hash-object', '--no-filters', '--', file], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* unread */ }
77
+ return blob === launcher.blob ? null : `${named} in this worktree differs from the reviewed base copy`;
50
78
  }
51
79
 
52
80
  function referenced(value) {
@@ -116,13 +144,14 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
116
144
  const missing = [...vars].filter((key) => !env[key]).sort();
117
145
  const command = typeof config.command === 'string' ? expand(config.command, env) : null;
118
146
  const repoCommand = command != null && relativeCommand(command);
119
- const commandMissing = command != null && !(repoCommand ? reviewedExecutable(root, commit, command) : onPath(command, env));
147
+ const launcher = repoCommand ? reviewedLauncher(root, commit, command) : null;
148
+ const commandMissing = command != null && !(repoCommand ? launcher : onPath(command, env));
120
149
  const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
121
- const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing
122
- ? repoCommand ? `command ${config.command} is not an executable file in the base branch; commit the launcher with executable permission on base`
123
- : `command ${config.command} not on PATH`
150
+ const reason = missing.length ? mcpEnvUnsetReason(missing) : commandMissing
151
+ ? repoCommand ? mcpLauncherNotExecutableReason(config.command)
152
+ : mcpCommandNotOnPathReason(config.command)
124
153
  : unsupported ? 'MCP configuration unsupported' : null;
125
- return { name, config, state: reason ? 'missing' : 'ready', reason };
154
+ return { name, config, state: reason ? 'missing' : 'ready', reason, ...(launcher ? { launcher } : {}) };
126
155
  });
127
156
  // Nested instructions are normally discovered when a CLI enters that path.
128
157
  // Their worktree copies must not regain authority through that discovery.
@@ -156,22 +185,26 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
156
185
  * to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
157
186
  * (projectToolRuntimes.mjs) — never `-c` pairs on the command line. A fenced
158
187
  * Codex turn, which ignores that file, takes `fencedMount` instead: the
159
- * repo's own words as `-c` pairs and every expanded value in its environment.
188
+ * repo's own words as `-c` pairs and every expanded value in its environment
189
+ * — and `fenced` (the card's posture is not the build) keeps the servers out
190
+ * of the file it ignores (2026-10-17).
160
191
  */
161
- export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {} } = {}) {
192
+ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {}, fenced = false } = {}) {
162
193
  const env = expansionEnv(callerEnv);
163
- const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
164
- const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
165
194
  // The CLI may read its config from a temporary directory, and Codex research
166
195
  // changes cwd to an isolated copy. Keep repository launchers rooted at the
167
196
  // agent worktree, not either of those directories or the daemon's checkout.
168
197
  const launchRoot = worktree ?? snapshot.root;
169
198
  const launchCommand = (command) => launchRoot && relativeCommand(command) ? resolve(launchRoot, command) : command;
199
+ const refusals = new Map(snapshot.tools.map((t) => [t, t.state !== 'ready' ? null
200
+ : mcpRefusal(runner, t.config) ?? (t.launcher && launchRoot ? launcherRefusal(snapshot, launchRoot, t) : null)]));
201
+ const usable = snapshot.tools.filter((t) => t.state === 'ready' && refusals.get(t) == null);
170
202
  // What the base lists and this turn cannot mount, and why — the turn is told
171
203
  // (postureTools.mjs), so a missing server is a named fact, never a guess.
172
204
  const mcpUnmounted = snapshot.tools
173
205
  .filter((t) => !usable.includes(t))
174
- .map((t) => ({ name: t.name, reason: String(mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
206
+ .map((t) => ({ name: t.name, reason: String(refusals.get(t) ?? mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
207
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
175
208
  const mcpServers = {};
176
209
  for (const { name, config } of usable) {
177
210
  if (typeof config.command === 'string') {
@@ -201,12 +234,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
201
234
  ].filter(Boolean).join('\n\n');
202
235
  let prepared;
203
236
  try {
204
- prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers });
237
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, fenced });
205
238
  } catch (error) {
206
239
  rmSync(dir, { recursive: true, force: true });
207
240
  throw error;
208
241
  }
209
- const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
242
+ const { pluginDir, codexHome, personalDeveloperInstructions, codexApprovals, codexMachineServers } = prepared;
210
243
  return {
211
244
  mcpPath,
212
245
  /** The names of the servers mounted, as Claude's allow rules need them. */
@@ -217,11 +250,18 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
217
250
  fencedMount: fencedMountFor(runner, usable.map((entry) => ({ ...entry, config: {
218
251
  ...entry.config,
219
252
  ...(typeof entry.config.command === 'string' ? { command: launchCommand(entry.config.command) } : {}),
220
- } })), (value) => expand(value, env), callerEnv),
253
+ } })), (value) => expand(value, env), callerEnv, turnServers),
221
254
  instructions,
222
255
  pluginDir,
223
256
  codexHome,
224
257
  personalDeveloperInstructions,
258
+ /** `-c` pairs approving the person's own Codex servers that set no mode,
259
+ * for a build card (projectToolRuntimes.mjs `personalApprovals`). */
260
+ codexApprovals,
261
+ /** The person's own Codex servers a build card's kept config.toml mounts,
262
+ * by name (projectToolRuntimes.mjs `personalServerNames`); null when
263
+ * unread, and on every other turn. */
264
+ codexMachineServers,
225
265
  cleanup: () => rmSync(dir, { recursive: true, force: true }),
226
266
  };
227
267
  }
@@ -1,10 +1,12 @@
1
1
  /** Delivery order is the agent's decision, checked against Git's evidence. */
2
2
  import { parseTurnResult } from './agentPlan.mjs';
3
- import { git, gitOrNull } from './git.mjs';
3
+ import { git, gitOrNull, isAncestor } from './git.mjs';
4
4
  import { taskIdsFromMessage, commitRecord } from './worktreeDiff.mjs';
5
5
  import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
6
6
  import { fleetRequest } from './fleetPost.mjs';
7
7
  import { fleetEndpoint } from './fleetWire.mjs';
8
+ import { mergeOrderCorrection } from './repositoryPrompts.mjs';
9
+ import { completeMergeOrder } from './mergeOrder.mjs';
8
10
 
9
11
  export function measuredCardRepos(rows, taskId, receipts = []) {
10
12
  return rows.filter((r) => {
@@ -17,7 +19,7 @@ export function deliveryOrder(value, ids, catalog) {
17
19
  const names = new Map(catalog.flatMap((r) => [[r.id, r.id], [r.name, r.id]]));
18
20
  const order = Array.isArray(value?.mergeOrder) ? value.mergeOrder.map((id) => names.get(id) ?? id) : ids.length <= 1 ? ids : [];
19
21
  const reason = typeof value?.mergeOrderReason === 'string' ? value.mergeOrderReason.trim().replace(/\s+/g, ' ').slice(0, 1000) : '';
20
- if (order.length !== ids.length || new Set(order).size !== order.length || order.some((id) => !ids.includes(id)) || (ids.length > 1 && !reason)) return null;
22
+ if (!completeMergeOrder(order, ids, reason)) return null;
21
23
  return { changedRepos: ids, mergeOrder: order, ...(reason ? { mergeOrderReason: reason } : {}) };
22
24
  }
23
25
 
@@ -52,13 +54,13 @@ export async function finishRepositoryDelivery({ job, run, out, answer, resume,
52
54
  return out;
53
55
  }
54
56
  if (attempt === 3) throw new Error('The delivery still has no valid mergeOrder and mergeOrderReason for every repository with commits. The card has not entered Review.');
55
- out = await resume(`Your delivery needs correction before Review. Git measured commits for these repositories (data): ${JSON.stringify(rows.filter((r) => ids.includes(r.id)).map((r) => ({ id: r.id, name: r.name })))}. Return your final delivered JSON again with mergeOrder: [every changed repository short name exactly once, in landing order] and mergeOrderReason: one line explaining that order. Keep summary and progress. Do not ask the person to choose the order.`);
57
+ out = await resume(mergeOrderCorrection(rows.filter((r) => ids.includes(r.id)).map((r) => ({ id: r.id, name: r.name }))));
56
58
  }
57
59
  }
58
60
 
59
61
  export function allResolutionsCommitted(rows) {
60
62
  return rows.length > 0 && rows.every((r) => !gitOrNull(['rev-parse', '--verify', 'MERGE_HEAD'], r.wt)
61
- && (() => { try { git(['merge-base', '--is-ancestor', r.baseRef, 'HEAD'], r.wt); return true; } catch { return false; } })()
63
+ && isAncestor(r.baseRef, 'HEAD', r.wt)
62
64
  && !git(['diff', '--name-only', '--diff-filter=U'], r.wt).trim()
63
65
  && !git(['status', '--porcelain', '--untracked-files=no'], r.wt).trim());
64
66
  }