@amenophis1er/foreman 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,7 +230,7 @@ result.
230
230
  ```sh
231
231
  npm ci && npm run setup # dependencies, then the dashboard build
232
232
  npm start # serves http://localhost:4177
233
- npm test # 360 tests, node:test
233
+ npm test # 364 tests, node:test
234
234
  npm run typecheck # server and dashboard
235
235
  npm run dev # API + Vite together
236
236
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
@@ -9,7 +9,7 @@ import {
9
9
  stalledWorkerReport, workerStatusBlock,
10
10
  watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
11
11
  DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
12
- tokenCapLabel,
12
+ tokenCapLabel, stopReasonOf,
13
13
  } from './orchestrator.js';
14
14
  import { makePolicy, type PendingPermission } from './policy.js';
15
15
  import type { AgentEnv } from './provider.js';
@@ -1249,3 +1249,10 @@ test('tokenCapLabel rounds to a figure a director can hold in mind', () => {
1249
1249
  assert.equal(tokenCapLabel(250_000), '250k tokens');
1250
1250
  assert.equal(tokenCapLabel(400), '400 tokens');
1251
1251
  });
1252
+
1253
+ test('stopReasonOf names the cap behind a capReached sentence', () => {
1254
+ assert.equal(stopReasonOf('BUDGET CAP REACHED: $10.02 of $10.00.'), 'budget');
1255
+ assert.equal(stopReasonOf('TURN CAP REACHED: 150 director turns.'), 'turns');
1256
+ assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
1257
+ assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
1258
+ });
@@ -265,6 +265,14 @@ const DEFAULT_MAX_TURNS = 150;
265
265
  */
266
266
  export const DEFAULT_MAX_TOKENS = 20_000_000;
267
267
 
268
+ /** The cap behind a capReached() sentence, as the run record stores it. */
269
+ export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
270
+ if (cap.startsWith('TURN')) return 'turns';
271
+ if (cap.startsWith('TIME')) return 'time';
272
+ if (cap.startsWith('TOKEN')) return 'tokens';
273
+ return 'budget';
274
+ }
275
+
268
276
  /**
269
277
  * The token cap as a director should read it: "5M tokens", not "5000000".
270
278
  * A budget is only useful if the agent it constrains can hold it in mind, and
@@ -1725,6 +1733,8 @@ export class MissionRun {
1725
1733
  const maxSeconds = this.meta.maxSeconds ?? DEFAULT_MAX_SECONDS;
1726
1734
  if (elapsed < maxSeconds + CAP_WATCHDOG_GRACE_MS / 1000) return;
1727
1735
  this.hardStopped = true;
1736
+ this.meta.stopReason = 'time';
1737
+ this.saveMeta(this.meta);
1728
1738
  this.emit('budget_stop', {
1729
1739
  costUsd: this.meta.costUsd,
1730
1740
  budgetUsd: this.meta.budgetUsd,
@@ -1797,6 +1807,8 @@ export class MissionRun {
1797
1807
  const cap = this.capReached();
1798
1808
  if (!cap || this.budgetStopped) return null;
1799
1809
  this.budgetStopped = true;
1810
+ this.meta.stopReason = stopReasonOf(cap);
1811
+ this.saveMeta(this.meta);
1800
1812
  this.emit('budget_stop', {
1801
1813
  costUsd: this.meta.costUsd,
1802
1814
  budgetUsd: this.meta.budgetUsd,
@@ -149,6 +149,46 @@ test('a file write under the root does not escape', () => {
149
149
  assert.ok(bashEscapesFolder('echo hi > /tmp/out.txt', FOLDER, [ROOT]), 'outside the root still prompts');
150
150
  });
151
151
 
152
+ test('operators inside quotes are text: a perl program with => is not a redirect', () => {
153
+ // The exact shape that put an approval card in front of an in-folder edit:
154
+ // `=>/` inside the single-quoted program read as `>` + the path `/…`.
155
+ assert.equal(bashEscapesFolder(`perl -0pi -e 's/\\.filter\\(\\(\\[a, b\\]\\) =>/.filter(([a]) =>/' ${ROOT}/x.tsx`, FOLDER, [ROOT]), null);
156
+ assert.equal(bashEscapesFolder(`set -e; D=src/f; perl -0pi -e 's/ a,\\n b,/ a,/' $D/one.tsx; perl -0pi -e 's/x =>/y/' $D/two.tsx`, FOLDER, [ROOT]), null);
157
+ assert.equal(bashEscapesFolder('echo "a > b; c | d & e" && echo \'x>/etc/passwd\'', FOLDER, [ROOT]), null);
158
+ assert.equal(bashEscapesFolder(`git commit -m "fix: handle a > b" ${ROOT}`, FOLDER, [ROOT]), null);
159
+ // A real redirect next to a quoted one is still seen, with its real text.
160
+ assert.deepEqual(bashEscape('echo "x > y" > /tmp/out.txt', FOLDER), {
161
+ reason: 'echo "x > y" > /tmp/out.txt — redirects output outside the mission folder', path: '/tmp/out.txt', grant: '/tmp',
162
+ });
163
+ // Quoted paths still resolve: the mask touches operators, not text.
164
+ assert.ok(bashEscapesFolder('rm -rf "/opt/homebrew"', FOLDER, [ROOT]));
165
+ // A quoted path with spaces is one token now, so it resolves and is judged.
166
+ assert.equal(bashEscape(`echo hi > "/tmp/o u t.txt"`, FOLDER)!.path, '/tmp/o u t.txt');
167
+ assert.equal(bashEscapesFolder(`echo hi > "${ROOT}/o u t.txt"`, FOLDER, [ROOT]), null);
168
+ });
169
+
170
+ test('the shell keeps its cwd between commands: a cd in one call places the next', () => {
171
+ const shell = { cwd: FOLDER };
172
+ assert.equal(bashEscape('cd examples/demo', FOLDER, [], shell), null);
173
+ assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
174
+ // The exact shape from a real mission: a log two levels up is inside the folder.
175
+ assert.equal(bashEscape('(npx tsx server.ts > ../../.foreman/work/demo/run1.log 2>&1 &) ; sleep 1', FOLDER, [], shell), null);
176
+ // A cd inside a subshell does not move the shell.
177
+ assert.equal(bashEscape('(cd .. && ls) && echo ok', FOLDER, [], shell), null);
178
+ assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
179
+ // From there, three levels up is out, and says so.
180
+ assert.match(bashEscape('touch ../../../x', FOLDER, [], shell)!.reason, /outside the mission folder/);
181
+ // Without a shell, the analysis starts at the folder as before.
182
+ assert.match(bashEscape('echo hi > ../../.foreman/work/demo/run1.log', FOLDER)!.reason, /redirects output outside/);
183
+ });
184
+
185
+ test('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
186
+ assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
187
+ assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
188
+ assert.equal(bashEscapesFolder(`sh -c 'npm test && echo ok'`, FOLDER, [ROOT]), null);
189
+ assert.equal(bashEscape(`bash -c 'cd /tmp/x'`, FOLDER)!.grant, '/tmp/x');
190
+ });
191
+
152
192
  test('bashEscape carries the offending path and the directory to grant', () => {
153
193
  // Directory-ish targets: the path itself is the grant.
154
194
  assert.deepEqual(bashEscape('cd /tmp/x && ls', FOLDER), {
package/src/policy.ts CHANGED
@@ -153,15 +153,52 @@ const WRITE_VERBS: Record<string, { mode: ArgMode; opts?: string[]; does: string
153
153
  const FILE_OPTS = new Set(['-o', '--output', '-O', '--output-document']);
154
154
  /** Prefixes that merely wrap the real verb (`sudo rm`, `env FOO=1 mkdir`). */
155
155
  const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin']);
156
+ /** Shells whose `-c` argument is a command in its own right. */
157
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
156
158
  /** Pseudo-devices: writing to them touches nothing on disk. */
157
159
  const DEV_SINKS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty']);
158
160
  const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
159
161
  /** `> f`, `>> f`, `2> f`, `&> f` — but not `2>&1` / `>&2` (fd duplication). */
160
162
  const REDIRECT_RE = /(?:&>>?|\d*>>?)(?!&)\s*(\S+)/g;
161
163
 
164
+ /**
165
+ * Shell operators inside quotes are text, not operators. The redirect scan
166
+ * and the segment split below work on the raw string, so a perl program in
167
+ * single quotes — `perl -pi -e 's/x =>/y/' f` — read as `>` followed by the
168
+ * path `/y/`, which resolved to the filesystem root and put an approval card
169
+ * in front of an ordinary in-folder edit. Operators inside a quoted region
170
+ * are swapped for private-use characters before the scan and swapped back by
171
+ * `bare()`, so tokens still carry their real text. Spaces inside quotes are
172
+ * masked too, so a quoted string is one token: `sh -c '…'` bodies and paths
173
+ * with spaces both come through whole. Quoting rules followed:
174
+ * single quotes take everything literally; double quotes honour backslash;
175
+ * a backslash outside quotes escapes the next character.
176
+ */
177
+ const MASK: Record<string, string> = { '>': '\uE001', '<': '\uE002', '|': '\uE003', ';': '\uE004', '&': '\uE005', '\n': '\uE006', ' ': '\uE007', '\t': '\uE008' };
178
+ const UNMASK_RE = /[\uE001-\uE008]/g;
179
+ const UNMASK: Record<string, string> = Object.fromEntries(Object.entries(MASK).map(([k, v]) => [v, k]));
180
+ function maskQuoted(command: string): string {
181
+ let out = '';
182
+ let quote: '"' | "'" | null = null;
183
+ for (let i = 0; i < command.length; i++) {
184
+ const c = command[i];
185
+ if (quote === null) {
186
+ if (c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
187
+ if (c === '"' || c === "'") quote = c;
188
+ out += c;
189
+ continue;
190
+ }
191
+ if (quote === '"' && c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
192
+ if (c === quote) { quote = null; out += c; continue; }
193
+ out += MASK[c] ?? c;
194
+ }
195
+ return out;
196
+ }
197
+ const unmask = (s: string): string => s.replace(UNMASK_RE, (ch) => UNMASK[ch] ?? ch);
198
+
162
199
  /** Strips one layer of matching quotes and shell grouping punctuation. */
163
200
  function bare(token: string): string {
164
- let t = token.replace(/^[(\\]+|[)]+$/g, '');
201
+ let t = unmask(token).replace(/^[(\\]+|[)]+$/g, '');
165
202
  if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
166
203
  return t;
167
204
  }
@@ -260,22 +297,31 @@ export interface BashEscape {
260
297
  */
261
298
  export function bashEscape(
262
299
  command: string, folder: string, extraRoots: Iterable<string> = [],
300
+ shell?: { cwd?: string },
263
301
  ): BashEscape | null {
264
302
  const root = path.resolve(folder);
265
303
  // Snapshot the grants once per call: the caller's set is mutable and may be
266
304
  // appended to while we walk, and one command must see one consistent view.
267
305
  const roots = [root, ...extraRoots];
268
306
  const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
269
- const label = (seg: string): string => (seg.length > 80 ? seg.slice(0, 77) + '…' : seg);
307
+ const label = (raw: string): string => { const seg = unmask(raw); return seg.length > 80 ? seg.slice(0, 77) + '…' : seg; };
270
308
  const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
271
309
  reason: `${label(segment)} — ${does} outside the mission folder`,
272
310
  path: p,
273
311
  grant: isDir ? p : path.dirname(p),
274
312
  });
275
313
 
276
- let cwd = root; // virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly
277
-
278
- const segments = command
314
+ // The virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly. It
315
+ // starts where the agent's shell actually is: Claude Code's Bash tool keeps
316
+ // its working directory between commands, so a director that ran
317
+ // `cd examples/demo` one call ago and now writes `../../.foreman/work/x.log`
318
+ // is writing inside the folder — read from the root that looked like two
319
+ // levels out, and put an approval card in front of an in-folder log.
320
+ let cwd = shell?.cwd ? path.resolve(shell.cwd) : root;
321
+ // `cd` inside a subshell does not outlive it; only top-level moves persist.
322
+ let depth = 0;
323
+
324
+ const segments = maskQuoted(command)
279
325
  .split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
280
326
  .map((s) => s.trim())
281
327
  .filter(Boolean);
@@ -286,7 +332,13 @@ export function bashEscape(
286
332
  const p = asPath(m[1], cwd);
287
333
  if (p !== null && outside(p)) return hit(segment, 'redirects output', p, false);
288
334
  }
335
+ const opens = (segment.match(/\(/g) ?? []).length;
336
+ const closes = (segment.match(/\)/g) ?? []).length;
289
337
  const words = segment.replace(REDIRECT_RE, ' ').split(/\s+/).filter(Boolean).map(bare);
338
+ // Depth as seen at this segment's verb: an opening paren on the segment
339
+ // itself puts its `cd` inside the subshell.
340
+ const depthHere = depth + (segment.trimStart().startsWith('(') ? 1 : 0);
341
+ depth = Math.max(0, depth + opens - closes);
290
342
  // Peel wrappers and leading VAR=value assignments to reach the real verb.
291
343
  let i = 0;
292
344
  while (i < words.length && (WRAPPERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))) i++;
@@ -295,6 +347,19 @@ export function bashEscape(
295
347
  const args = words.slice(i + 1);
296
348
  const flagless = args.filter((a) => !a.startsWith('-'));
297
349
 
350
+ // `sh -c '…'`: the quoted body is a command of its own, run from the
351
+ // current virtual cwd. Analysed as one, so quoting cannot hide a `cd /tmp`
352
+ // — masking made the body opaque to the split above, and this is where it
353
+ // is looked at instead.
354
+ if (SHELLS.has(verb)) {
355
+ const c = args.indexOf('-c');
356
+ if (c >= 0 && c + 1 < args.length) {
357
+ const inner = bashEscape(args[c + 1], cwd, roots);
358
+ if (inner) return { ...inner, reason: `${label(segment)} — ${inner.reason.replace(/^.*? — /, '')}` };
359
+ }
360
+ continue;
361
+ }
362
+
298
363
  if (verb === 'cd' || verb === 'pushd') {
299
364
  const target = flagless[0] ?? '~'; // bare `cd` goes home
300
365
  const p = asPath(target, cwd) ?? path.resolve(cwd, target);
@@ -302,6 +367,7 @@ export function bashEscape(
302
367
  // Enough on its own: whatever follows (relative writes, `npm install`,
303
368
  // `git init`) lands there, so we need not look at later segments.
304
369
  if (outside(p)) return hit(segment, 'writes after this land', p, true);
370
+ if (depthHere === 0 && shell) shell.cwd = p;
305
371
  continue;
306
372
  }
307
373
 
@@ -482,6 +548,10 @@ export function makePolicy(
482
548
  const foremanDir = path.join(folder, '.foreman') + path.sep;
483
549
  const toolPolicy = { ...DEFAULT_TOOL_POLICY, ...settings?.toolPolicy };
484
550
  const autoReadOnly = settings?.autoAllowReadOnly !== false;
551
+ // Where this agent's shell is. One policy per agent session, and the SDK's
552
+ // Bash keeps its cwd across calls, so a top-level `cd` in an allowed
553
+ // command is where the next command starts. Reset only with the session.
554
+ const shell: { cwd?: string } = { cwd: folder };
485
555
 
486
556
  return async (toolName, input, opts) => {
487
557
  // An explicit grant (Settings policy or the human's "Always" click) is a
@@ -507,10 +577,15 @@ export function makePolicy(
507
577
  // The shell is the other door out of the job site: a command that would
508
578
  // write outside the folder prompts under the same rule, so a blanket
509
579
  // `Bash: allow` (or an "Always" click) cannot quietly `cd /tmp && npm i`.
580
+ // Analysed from the shell's current directory; the walk records where a
581
+ // top-level `cd` leaves it. A command that escapes is not run unless the
582
+ // human allows it, so its `cd` is remembered only after an allow below.
583
+ const shellAfter = { cwd: shell.cwd };
510
584
  const escape =
511
585
  toolName === 'Bash' && typeof input.command === 'string'
512
- ? bashEscape(input.command, folder, allowedRoots)
586
+ ? bashEscape(input.command, folder, allowedRoots, shellAfter)
513
587
  : null;
588
+ const rememberCwd = () => { if (toolName === 'Bash') shell.cwd = shellAfter.cwd; };
514
589
  const leavesFolder = outsideFolder || escape !== null;
515
590
  // What "always" on this card grants — see `PendingPermission.escapedPath`.
516
591
  // For a file tool the parent directory: the worker that wrote
@@ -540,6 +615,7 @@ export function makePolicy(
540
615
  ) {
541
616
  // Carry the reason through so the log shows what was waved past.
542
617
  hooks.onAutoAllow(agent, toolName, opts.decisionReason);
618
+ rememberCwd();
543
619
  return { behavior: 'allow' };
544
620
  }
545
621
 
@@ -582,13 +658,21 @@ export function makePolicy(
582
658
  input,
583
659
  title,
584
660
  description,
585
- decisionReason: opts.decisionReason ??
586
- (leavesFolder ? `Path is outside the mission folder (${folder})` : undefined),
661
+ // The card's one warning line. When the folder boundary is the reason,
662
+ // say that — the SDK's own heuristic ("contains shell syntax (&) that
663
+ // cannot be statically analyzed") is true of most real commands and
664
+ // tells the reader nothing about why THIS one stopped.
665
+ decisionReason: leavesFolder
666
+ ? (escape?.reason ?? `${filePath} is outside the mission folder`)
667
+ : opts.decisionReason,
587
668
  escapedPath,
588
669
  });
589
670
 
590
671
  return new Promise<PermissionResult>((resolve) => {
591
- hooks.register(id, { resolve, toolName, suggestions: opts.suggestions, escapedPath });
672
+ hooks.register(id, {
673
+ resolve: (r) => { if (r.behavior === 'allow') rememberCwd(); resolve(r); },
674
+ toolName, suggestions: opts.suggestions, escapedPath,
675
+ });
592
676
  opts.signal.addEventListener('abort', () => {
593
677
  if (hooks.unregister(id)) {
594
678
  resolve({ behavior: 'deny', message: 'Run was interrupted.' });
package/src/server.ts CHANGED
@@ -1724,6 +1724,8 @@ async function startRun(
1724
1724
  */
1725
1725
  async function resumeRun(projectId: string, meta: RunMeta, pick: {
1726
1726
  directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
1727
+ /** A new cap for the resumed attempt — the answer to "stopped at its budget". */
1728
+ budgetUsd?: number;
1727
1729
  } = {}): Promise<void> {
1728
1730
  const sessionId = meta.directorSessionId;
1729
1731
  // Resume re-reads Settings for tool policy and auto-allow, so a policy
@@ -1748,9 +1750,24 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1748
1750
  if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
1749
1751
  meta.toolPolicy = settings.toolPolicy;
1750
1752
  meta.autoAllowReadOnly = settings.autoAllowReadOnly;
1753
+ // A raised cap rides the resume: the run that stopped at $10 continues
1754
+ // under $15, and the transcript says who moved it and from where. Written
1755
+ // to the log as a settings change, which is what it is.
1756
+ const budgetWas = meta.budgetUsd;
1757
+ if (typeof pick.budgetUsd === 'number' && Number.isFinite(pick.budgetUsd) && pick.budgetUsd > 0 && pick.budgetUsd !== meta.budgetUsd) {
1758
+ meta.budgetUsd = pick.budgetUsd;
1759
+ }
1760
+ const stoppedAt = meta.stopReason;
1761
+ meta.stopReason = undefined;
1751
1762
  meta.status = 'running';
1752
1763
  meta.endedAt = undefined;
1753
1764
  meta.resumes = (meta.resumes ?? 0) + 1;
1765
+ if (meta.budgetUsd !== budgetWas) {
1766
+ makeEmitter(meta.id, projectId)('settings_changed', {
1767
+ budgetUsd: meta.budgetUsd,
1768
+ changes: [`Budget ${meta.budgetUsd > budgetWas ? 'raised' : 'lowered'} to $${meta.budgetUsd.toFixed(2)} (was $${budgetWas.toFixed(2)}) before resume${stoppedAt === 'budget' ? ' — the last attempt stopped at its cap' : ''}.`],
1769
+ });
1770
+ }
1754
1771
  if (meta.git) {
1755
1772
  const back = await ensureMissionBranch(meta.folder, meta.git.branch);
1756
1773
  if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
@@ -2740,6 +2757,8 @@ const server = http.createServer(async (req, res) => {
2740
2757
  const overrides = {
2741
2758
  directorModel: str(resumeBody.directorModel), directorProviderId: str(resumeBody.directorProviderId),
2742
2759
  workerModel: str(resumeBody.workerModel), workerProviderId: str(resumeBody.workerProviderId),
2760
+ budgetUsd: typeof resumeBody.budgetUsd === 'number' && Number.isFinite(resumeBody.budgetUsd) && resumeBody.budgetUsd > 0
2761
+ ? Math.round(resumeBody.budgetUsd * 100) / 100 : undefined,
2743
2762
  };
2744
2763
  void resumeRun(meta.projectId, meta, overrides);
2745
2764
  json(res, 200, { ok: true });
package/src/types.ts CHANGED
@@ -325,6 +325,12 @@ export interface RunMeta {
325
325
  askTimeoutMs?: number;
326
326
  /** Give agents a headless Playwright browser (navigate, click, screenshot). */
327
327
  browserTools?: boolean;
328
+ /**
329
+ * Which cap ended the last attempt, when one did. The dashboard reads it
330
+ * to offer the one action that helps — raise the budget and resume — and
331
+ * a resume clears it. Absent when the run ended for any other reason.
332
+ */
333
+ stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
328
334
  folder: string;
329
335
  mission: string;
330
336
  budgetUsd: number;