@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 +25 -1
- package/bin/foreman.mjs +2 -1
- package/package.json +2 -1
- package/skills/director/SKILL.md +5 -0
- package/src/cli.ts +7 -0
- package/src/completion.ts +1 -0
- package/src/deck.ts +4 -2
- package/src/gitwork.test.ts +16 -1
- package/src/gitwork.ts +27 -1
- package/src/mcp.test.ts +181 -0
- package/src/mcp.ts +481 -0
- package/src/orchestrator.ts +28 -1
- package/src/server.ts +53 -5
- package/src/snapshot.test.ts +68 -0
- package/src/snapshot.ts +116 -0
- package/src/store.test.ts +16 -0
- package/src/store.ts +17 -0
- package/src/types.ts +9 -0
- package/ui/dist/assets/{index--dvfX3Mo.js → index-Ct_GJThl.js} +9 -9
- package/ui/dist/index.html +1 -1
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 #
|
|
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.
|
|
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",
|
package/skills/director/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/src/gitwork.test.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/mcp.test.ts
ADDED
|
@@ -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
|
+
});
|