@amenophis1er/foreman 0.1.12 → 0.1.14

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,12 +230,36 @@ 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 # 365 tests, node:test
233
+ npm test # 379 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
237
237
  ```
238
238
 
239
+ ## Use Foreman from another agent
240
+
241
+ `foreman mcp` serves Foreman's tools over stdio, so a Claude Code session, Codex
242
+ or Antigravity can watch and launch missions without a polling loop:
243
+
244
+ ```sh
245
+ claude mcp add foreman -- foreman mcp
246
+ codex mcp add foreman -- foreman mcp
247
+ agy mcp add foreman -- foreman mcp
248
+ ```
249
+
250
+ Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds` and
251
+ `until`: one call that blocks until the run changes, finishes, or needs you),
252
+ `run_report` (a finished run in one call: the director's report, DONE WHEN,
253
+ changed files, branch and pull request), `run_transcript`, `mission_doc`,
254
+ `project_memory`, `search_runs`, `doctor`, `link_project` (folder or Git URL),
255
+ `start_mission`, `steer`. Start, wait until finished, read the report: three calls. It talks to the running server at `FOREMAN_URL`
256
+ (default `http://localhost:4177`) and has no logic of its own.
257
+
258
+ Deliberately absent: approving or denying, answering the director's questions,
259
+ interrupt, resume, raising a budget, opening a pull request, settings and keys.
260
+ Those are the moments Foreman exists to put a human in; `run_status` says when
261
+ a run needs one, and with what, so the agent's job is to send you to decide.
262
+
239
263
  To run a checkout beside an installed Foreman on the same machine, give it its
240
264
  own port and leave the bot to the installed one:
241
265
 
package/bin/foreman.mjs CHANGED
@@ -34,6 +34,7 @@ const USAGE = `foreman ${pkg.version}
34
34
  foreman uninstall Remove the service and the background server; keeps ~/.foreman
35
35
  foreman uninstall --purge --yes …and delete ~/.foreman (every run's history) too
36
36
  foreman completion install Tab-completion for these commands (zsh, bash, fish); or "completion zsh" to print it
37
+ foreman mcp Serve Foreman's tools to another agent over stdio (claude mcp add foreman -- foreman mcp)
37
38
  foreman --version | --help
38
39
 
39
40
  Environment:
@@ -56,7 +57,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
56
57
  } else if (command === 'start') {
57
58
  register();
58
59
  await import(new URL('../src/server.ts', import.meta.url).href);
59
- } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion'].includes(command)) {
60
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion', 'mcp'].includes(command)) {
60
61
  register();
61
62
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
62
63
  process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/claude-agent-sdk": "^0.3.259",
54
+ "@modelcontextprotocol/sdk": "1.30.0",
54
55
  "@playwright/mcp": "^0.0.80",
55
56
  "qrcode": "^1.5.4",
56
57
  "tsx": "^4.23.13",
@@ -5,6 +5,11 @@ description: Kick off a Foreman mission for the current project — ensures the
5
5
 
6
6
  # /director — launch a Foreman mission from this session
7
7
 
8
+ > Prefer the MCP: `claude mcp add foreman -- foreman mcp` gives this session
9
+ > `start_mission`, `run_status` (with a wait, so no polling) and the rest as
10
+ > tools, with the same governance. The steps below are the curl fallback for a
11
+ > session without it.
12
+
8
13
  You are the launcher only. The mission itself runs in Foreman's own director
9
14
  and worker sessions with budgets, approval cards, and MISSION.md governance;
10
15
  the human supervises from the dashboard, not from this session. Do NOT do the
package/src/cli.ts CHANGED
@@ -498,6 +498,13 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
498
498
  case 'uninstall': return uninstall(rest);
499
499
  case 'status': return status();
500
500
  case 'doctor': return doctor();
501
+ case 'mcp': {
502
+ // Stdio is the protocol channel: nothing else may print there.
503
+ const { serveMcp } = await import('./mcp.js');
504
+ await serveMcp();
505
+ await new Promise(() => {}); // until the client closes the pipe
506
+ return 0;
507
+ }
501
508
  case 'completion': {
502
509
  const { completionScript, detectShell, installCompletion } = await import('./completion.js');
503
510
  const arg = rest[0];
package/src/completion.ts CHANGED
@@ -25,6 +25,7 @@ export const COMMANDS: Array<[string, string]> = [
25
25
  ['service', 'Keep Foreman running at login'],
26
26
  ['uninstall', 'Remove the service and the background server'],
27
27
  ['completion', 'Shell completion: zsh, bash, fish, or install'],
28
+ ['mcp', 'Serve Foreman\'s tools to another agent over stdio'],
28
29
  ['help', 'Show usage'],
29
30
  ['version', 'Print the version'],
30
31
  ];
package/src/deck.ts CHANGED
@@ -847,7 +847,7 @@ function sendJson(res: ServerResponse, code: number, body: unknown): void {
847
847
  */
848
848
  export async function handleDeckRoute(
849
849
  req: IncomingMessage, res: ServerResponse, url: URL,
850
- lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
850
+ lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string; deck?: Deck } | null>,
851
851
  ): Promise<boolean> {
852
852
  // The same jail and the same viewer serve two scopes: a run (its deck,
853
853
  // relative to a baseline) and a project (its tree as it stands, no baseline).
@@ -863,7 +863,9 @@ export async function handleDeckRoute(
863
863
  if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
864
864
 
865
865
  if (what === 'deck') {
866
- sendJson(res, 200, await deckFor(run.folder, runId));
866
+ // A finished run's deck is the one frozen when it ended (snapshot.ts);
867
+ // the caller hands it over. A running run is diffed live.
868
+ sendJson(res, 200, run.deck ?? await deckFor(run.folder, runId));
867
869
  return true;
868
870
  }
869
871
  if (what === 'tree') {
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { mkdtemp, writeFile } from 'node:fs/promises';
7
- import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch } from './gitwork.js';
7
+ import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch } from './gitwork.js';
8
8
 
9
9
  const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
10
 
@@ -93,3 +93,18 @@ test('prDraft: the run title, the brief, the boxes as the mission left them, and
93
93
  assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
94
94
  assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
95
95
  });
96
+
97
+
98
+ test('renameMissionBranch takes the run title once there is one, and leaves a branch that moved on', async () => {
99
+ const dir = await repo();
100
+ const g = await startMissionBranch(dir, 'Repo: this folder is a git worktree. Do the thing.', '1788713434983-226123af');
101
+ assert.ok(!('error' in g));
102
+ assert.equal(g.branch, 'foreman/repo-this-folder-is-a-git-23af');
103
+ const renamed = await renameMissionBranch(dir, g.branch, 'Studio data sanitization and null handling', '1788713434983-226123af');
104
+ assert.equal(renamed, 'foreman/studio-data-sanitization-and-null-23af');
105
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), renamed);
106
+ // Same name again: nothing to do. Not on the branch any more: left alone.
107
+ assert.equal(await renameMissionBranch(dir, renamed!, 'Studio data sanitization and null handling', '1788713434983-226123af'), null);
108
+ sh(dir, 'checkout', '-q', 'main');
109
+ assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
110
+ });
package/src/gitwork.ts CHANGED
@@ -70,7 +70,12 @@ export async function gitInfo(folder: string): Promise<GitInfo> {
70
70
  /** `foreman/<first words of the brief>-<id tail>`: readable in `git branch`, unique per run. */
71
71
  export function missionBranchName(mission: string, runId: string): string {
72
72
  const first = mission.split('\n').find((l) => l.trim())?.trim() ?? 'mission';
73
- const slug = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6).join('-').slice(0, 40).replace(/-+$/, '') || 'mission';
73
+ // Whole words up to six and forty characters: a name cut mid-word
74
+ // ("null-handli") reads worse than a shorter one.
75
+ const words = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6);
76
+ let slug = '';
77
+ for (const w of words) { const next = slug ? `${slug}-${w}` : w; if (next.length > 40) break; slug = next; }
78
+ slug = slug || words[0]?.slice(0, 40) || 'mission';
74
79
  const tail = runId.replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase();
75
80
  return `foreman/${slug}-${tail}`;
76
81
  }
@@ -92,6 +97,27 @@ export async function startMissionBranch(folder: string, mission: string, runId:
92
97
  return { branch, base: info.branch ?? 'HEAD', baseHead: info.head ?? null };
93
98
  }
94
99
 
100
+ /**
101
+ * The branch takes the run's title once there is one. Branches are created
102
+ * before the title exists (the title is a model call that lands seconds
103
+ * later), so they started from the brief's first words — and briefs that all
104
+ * open with the same boilerplate gave every run the same name. Renamed in
105
+ * place, only while nothing has been committed on it and it is still checked
106
+ * out. Returns the new name, or null when it was left as it was.
107
+ */
108
+ export async function renameMissionBranch(folder: string, from: string, title: string, runId: string): Promise<string | null> {
109
+ const to = missionBranchName(title, runId);
110
+ if (to === from) return null;
111
+ const info = await gitInfo(folder);
112
+ if (!info.repo || info.branch !== from) return null;
113
+ try {
114
+ await git(['branch', '-m', from, to], folder);
115
+ return to;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
95
121
  /**
96
122
  * Back on the mission's branch for a resume. Returns null when already or
97
123
  * now there, else why not — a dirty tree that would be clobbered, typically.
@@ -0,0 +1,181 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { doneWhen, eventLine, foremanTools, isForemanCheckout, waitForRunEvent } from './mcp.js';
7
+
8
+ /** A Foreman server as the tools see it: routes answered from a table, requests recorded. */
9
+ function fakeServer(routes: Record<string, unknown | ((body: unknown) => unknown)>) {
10
+ const calls: Array<{ method: string; path: string; body?: unknown }> = [];
11
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
12
+ const url = new URL(String(input));
13
+ const key = `${init?.method ?? 'GET'} ${url.pathname}${url.search}`;
14
+ const keyNoQuery = `${init?.method ?? 'GET'} ${url.pathname}`;
15
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
16
+ calls.push({ method: init?.method ?? 'GET', path: url.pathname + url.search, body });
17
+ const hit = key in routes ? routes[key] : routes[keyNoQuery];
18
+ if (hit === undefined) return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
19
+ const val = typeof hit === 'function' ? (hit as (b: unknown) => unknown)(body) : hit;
20
+ if (val instanceof Response) return val;
21
+ return new Response(JSON.stringify(val), { status: 200, headers: { 'content-type': 'application/json' } });
22
+ }) as unknown as typeof fetch;
23
+ return { fetchImpl, calls };
24
+ }
25
+
26
+ const run = (o: Record<string, unknown> = {}) => ({
27
+ id: 'r1', projectId: 'p1', mission: 'Fix the thing', title: 'Fix', status: 'running', costUsd: 1.25, budgetUsd: 5,
28
+ costBasis: 'priced', createdAt: 1, directorModel: 'fable', workerModel: 'opus',
29
+ workers: [{ id: 'worker-1', status: 'done', costUsd: 0.4, task: 'write tests' }],
30
+ git: { branch: 'foreman/fix-r1', base: 'main' }, ...o,
31
+ });
32
+ const tool = (tools: ReturnType<typeof foremanTools>, name: string) => tools.find((t) => t.name === name)!;
33
+
34
+ test('fleet_status reads /projects and says what needs a human', async () => {
35
+ const { fetchImpl } = fakeServer({
36
+ 'GET /projects': { version: '0.1.12', projects: [
37
+ { id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), lastRun: null, pendingPermissions: 1, pendingQuestions: 0, git: { branch: 'main' } },
38
+ { id: 'p2', name: 'lib', folder: '/x/lib', activeRun: null, lastRun: { id: 'r0', mission: 'Old', status: 'done', costUsd: 2, createdAt: 1 }, pendingPermissions: 0, pendingQuestions: 0 },
39
+ ] },
40
+ });
41
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'fleet_status').run({});
42
+ assert.match(r.text, /app \(p1\).*main\n running: r1 · running · \$1\.25 of \$5\.00/);
43
+ assert.match(r.text, /NEEDS YOU: 1 pending/);
44
+ assert.match(r.text, /lib \(p2\)[^\n]*\n idle · last: done · \$2\.00/);
45
+ });
46
+
47
+ test('run_status: crew, DONE WHEN from the mission doc, needs, and no wait on a finished run', async () => {
48
+ const { fetchImpl, calls } = fakeServer({
49
+ 'GET /runs': { runs: [run({ status: 'interrupted', stopReason: 'budget' })] },
50
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, lastRun: null, needs: [] }] },
51
+ 'GET /missiondoc': { doc: '# M\n## DONE WHEN\n- [x] tests pass\n- [ ] docs updated\n' },
52
+ });
53
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 30 });
54
+ assert.match(r.text, /stopped at its budget cap/);
55
+ assert.match(r.text, /DONE WHEN 1\/2\n open: docs updated/);
56
+ assert.match(r.text, /worker-1 · done/);
57
+ assert.match(r.text, /^finished$/m);
58
+ assert.ok(!calls.some((c) => c.path.startsWith('/events')), 'a finished run is never waited on');
59
+ });
60
+
61
+ test('run_status with a wait returns when the run\'s picture changes, not on a bare event', async () => {
62
+ const sse = () => new ReadableStream<Uint8Array>({
63
+ start(c) {
64
+ const enc = new TextEncoder();
65
+ c.enqueue(enc.encode(': connected\n\n'));
66
+ c.enqueue(enc.encode('event: cost\ndata: {"runId":"other","projectId":"p9","data":{}}\n\n'));
67
+ c.enqueue(enc.encode('event: worker_started\ndata: {"runId":"r1","projectId":"p1","data":{"id":"worker-2"}}\n\n'));
68
+ },
69
+ });
70
+ let rounds = 0;
71
+ const { fetchImpl } = fakeServer({
72
+ // First round: an event, but the same run — no change. Second: a new worker.
73
+ 'GET /runs': () => ({ runs: [run(rounds >= 2 ? { workers: [{ id: 'worker-1', status: 'done', costUsd: 0.4, task: 'write tests' }, { id: 'worker-2', status: 'running', costUsd: 0, task: 'more' }] } : {})] }),
74
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash — rm -rf dist' }] }] },
75
+ 'GET /missiondoc': { doc: '' },
76
+ 'GET /events': () => { rounds += 1; return new Response(sse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }); },
77
+ });
78
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5 });
79
+ assert.match(r.text, /changed: yes/);
80
+ assert.ok(rounds >= 2, 'the first event changed nothing visible, so it kept waiting');
81
+ assert.match(r.text, /worker-2 · running/);
82
+ assert.match(r.text, /NEEDS YOU \(1\) — only a human can answer/);
83
+ assert.match(r.text, /\[perm\] director wants Bash/);
84
+ });
85
+
86
+ test('run_status until=finished waits through events that are not the end, and stops at the end', async () => {
87
+ // Two rounds: a cost tick (not finished) then the run turns done.
88
+ let rounds = 0;
89
+ const sseOnce = () => new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: cost\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } });
90
+ const { fetchImpl, calls } = fakeServer({
91
+ 'GET /runs': () => ({ runs: [run({ status: rounds >= 2 ? 'done' : 'running' })] }),
92
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, needs: [] }] },
93
+ 'GET /missiondoc': { doc: '' },
94
+ 'GET /events': () => { rounds += 1; return new Response(sseOnce(), { status: 200 }); },
95
+ });
96
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 10, until: 'finished' });
97
+ assert.match(r.text, /r1 · done/);
98
+ assert.match(r.text, /^finished$/m);
99
+ assert.ok(calls.filter((c) => c.path.startsWith('/events')).length >= 2, 'kept waiting past the first event');
100
+ });
101
+
102
+ test('run_status until=needs_you returns when an approval appears', async () => {
103
+ let asked = false;
104
+ const { fetchImpl } = fakeServer({
105
+ 'GET /runs': { runs: [run()] },
106
+ 'GET /projects': () => ({ projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: asked ? [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash' }] : [] }] }),
107
+ 'GET /missiondoc': { doc: '' },
108
+ 'GET /events': () => { asked = true; return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: permission_request\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } }), { status: 200 }); },
109
+ });
110
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5, until: 'needs_you' });
111
+ assert.match(r.text, /^needs you$/m);
112
+ assert.match(r.text, /\[perm\] director wants Bash/);
113
+ });
114
+
115
+ test('run_report: the director\'s last words, DONE WHEN, changed files and the branch in one call', async () => {
116
+ const say = (agent: string, text: string) => ({ ts: 1, event: 'message', data: { agent, msg: { type: 'assistant', message: { content: [{ type: 'text', text }] } } } });
117
+ const { fetchImpl } = fakeServer({
118
+ 'GET /runs': { runs: [run({ status: 'done', git: { branch: 'foreman/fix-r1', base: 'main', commits: 1, pr: 'https://github.com/o/r/pull/9', prState: 'merged' } })] },
119
+ 'GET /runs/r1/events': { events: [say('director', 'Starting.'), say('worker-1', 'A long worker message that must not be mistaken for the report.'), say('director', 'All done: tests pass, docs updated, nothing left undone in this mission.')] },
120
+ 'GET /missiondoc': { doc: '- [x] tests pass\n- [x] docs updated\n' },
121
+ 'GET /runs/r1/deck': { baseline: { kind: 'git' }, files: [{ path: 'src/a.ts', status: 'modified', additions: 10, deletions: 2 }, { path: 'old.txt', status: 'modified', additions: 1, deletions: 1, preexisting: true }], artifacts: [{ path: 'shot.png', kind: 'image' }], totals: { files: 2, additions: 11, deletions: 3 } },
122
+ });
123
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_report').run({ runId: 'r1' });
124
+ assert.match(r.text, /branch foreman\/fix-r1 from main · 1 commit · PR https:\/\/github.com\/o\/r\/pull\/9 \(merged\)/);
125
+ assert.match(r.text, /DONE WHEN 2\/2/);
126
+ assert.match(r.text, /changed: 1 file · \+11 −3 · 1 screenshot · 1 already dirty before the run/);
127
+ assert.match(r.text, /modified src\/a\.ts \+10 −2/);
128
+ assert.match(r.text, /Director's report:\nAll done: tests pass/);
129
+ assert.doesNotMatch(r.text, /worker message/);
130
+ });
131
+
132
+ test('waitForRunEvent gives up at the timeout when nothing arrives for the run', async () => {
133
+ const quiet = new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(': connected\n\n')); } });
134
+ const { fetchImpl } = fakeServer({ 'GET /events': new Response(quiet, { status: 200 }) });
135
+ const t0 = Date.now();
136
+ assert.equal(await waitForRunEvent('http://f', 'r1', 1, fetchImpl), false);
137
+ assert.ok(Date.now() - t0 >= 900, 'waited about the timeout');
138
+ });
139
+
140
+ test('start_mission: 409 is a fact, the run id is read back, and Foreman itself is refused', async () => {
141
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'foreman-mcp-'));
142
+ const self = path.join(dir, 'foreman'); await mkdir(path.join(self, 'src'), { recursive: true });
143
+ await writeFile(path.join(self, 'package.json'), JSON.stringify({ name: 'x', bin: { foreman: 'bin/foreman.mjs' } }));
144
+ assert.equal(isForemanCheckout(self), true);
145
+ assert.equal(isForemanCheckout(dir), false);
146
+ let started = false;
147
+ const { fetchImpl, calls } = fakeServer({
148
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: dir, activeRun: null }, { id: 'pf', name: 'foreman', folder: self, activeRun: null }] },
149
+ 'POST /run': (b: unknown) => { started = true; return (b as { projectId: string }).projectId === 'busy' ? new Response(JSON.stringify({ error: 'busy' }), { status: 409 }) : { ok: true }; },
150
+ 'GET /runs': () => ({ runs: started ? [run({ status: 'running', budgetUsd: 3 })] : [] }),
151
+ });
152
+ const tools = foremanTools({ base: 'http://f', fetchImpl });
153
+ const refused = await tool(tools, 'start_mission').run({ projectId: 'pf', brief: 'Harden the server please', budgetUsd: 3 });
154
+ assert.match(refused.text, /Refused: this folder is Foreman itself/);
155
+ assert.ok(!calls.some((c) => c.method === 'POST'), 'nothing was posted for the refused one');
156
+ const ok = await tool(tools, 'start_mission').run({ projectId: 'p1', brief: 'Fix the flaky test in ci', budgetUsd: 3, worker: 'sonnet' });
157
+ assert.match(ok.text, /Started r1 on app, cap \$3\.00/);
158
+ assert.deepEqual(calls.find((c) => c.method === 'POST')!.body, { projectId: 'p1', mission: 'Fix the flaky test in ci', budgetUsd: 3, workerModel: 'sonnet' });
159
+ await rm(dir, { recursive: true, force: true });
160
+ });
161
+
162
+ test('the tool set has no human-only actions', () => {
163
+ const names = foremanTools({ base: 'http://f' }).map((t) => t.name);
164
+ for (const forbidden of ['approve', 'deny', 'permission', 'answer', 'interrupt', 'resume', 'budget', 'pull_request', 'open_pr', 'settings', 'key']) {
165
+ assert.ok(!names.some((n) => n.split('_').includes(forbidden) || n === forbidden), `${forbidden} must not be a tool`);
166
+ }
167
+ assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_report', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
168
+ });
169
+
170
+ test('a server that is not there is said in one sentence with the start command', async () => {
171
+ const fetchImpl = (async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
172
+ await assert.rejects(tool(foremanTools({ base: 'http://localhost:4177', fetchImpl }), 'fleet_status').run({}), /not answering at http:\/\/localhost:4177 .*Start it with `foreman`/);
173
+ });
174
+
175
+ test('eventLine and doneWhen condense what the transcript and the doc say', () => {
176
+ assert.equal(eventLine('permission_request', { agent: 'director', toolName: 'Bash', decisionReason: 'leaves the folder' }), 'NEEDS YOU — director wants Bash (leaves the folder)');
177
+ assert.equal(eventLine('message', { agent: 'worker-1', msg: { type: 'assistant', message: { content: [{ type: 'text', text: 'Done.\n\nAll green.' }] } } }), 'worker-1: Done. All green.');
178
+ assert.equal(eventLine('message', { agent: 'w', msg: { type: 'user' } }), null);
179
+ assert.equal(eventLine('cost', {}), null);
180
+ assert.deepEqual(doneWhen('- [ ] a\n* [x] b\n- [X] c\nnot a box'), { done: ['b', 'c'], open: ['a'] });
181
+ });