@amenophis1er/foreman 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planner's proposal safety nets.
|
|
3
|
+
*
|
|
4
|
+
* Two small pure rules sit between what the planner writes and what the card
|
|
5
|
+
* shows. Both exist because of one afternoon: a proposal whose criteria said
|
|
6
|
+
* "screenshots saved" arrived with the browser off, and a planner that could
|
|
7
|
+
* not see the machine's model list could not have recommended one. Neither
|
|
8
|
+
* rule is allowed to depend on the model remembering something.
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { needsBrowser, pickKnownModel, type PlannerModel } from './planner.js';
|
|
13
|
+
|
|
14
|
+
test('criteria that need a browser are recognised from the words the planner wrote', () => {
|
|
15
|
+
// The exact shapes from the real proposal that shipped with the browser off.
|
|
16
|
+
assert.equal(needsBrowser('Build a static site.', [
|
|
17
|
+
'index.html opens correctly in a browser with no console errors',
|
|
18
|
+
]), true);
|
|
19
|
+
assert.equal(needsBrowser('Build a static site.', [
|
|
20
|
+
'A screenshots/ directory exists containing desktop-full.png',
|
|
21
|
+
]), true);
|
|
22
|
+
assert.equal(needsBrowser('Build a static site.', [
|
|
23
|
+
'All images load successfully (no broken image icons) when checked in a real browser',
|
|
24
|
+
]), true);
|
|
25
|
+
assert.equal(needsBrowser('Verify the page renders correctly at 375px mobile width.', []), true);
|
|
26
|
+
assert.equal(needsBrowser('Use Playwright to click through the checkout.', []), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a mission with no browser in it is left alone', () => {
|
|
30
|
+
// A false positive costs a switch the human can flip off; a false negative
|
|
31
|
+
// cost an hour. The net is broad, but it must not fire on everything.
|
|
32
|
+
assert.equal(needsBrowser('Refactor the payment module and add unit tests.', [
|
|
33
|
+
'npm test passes', 'no function longer than 40 lines',
|
|
34
|
+
]), false);
|
|
35
|
+
assert.equal(needsBrowser('Write a CLI that converts CSV to JSON.', ['handles empty files']), false);
|
|
36
|
+
assert.equal(needsBrowser('', []), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const MODELS: PlannerModel[] = [
|
|
40
|
+
{ id: 'sonnet', label: 'Sonnet', providerLabel: 'Anthropic', costBasis: 'priced' },
|
|
41
|
+
{ id: 'glm-5.3-flash:cloud', label: 'glm-5.3-flash:cloud', providerId: 'ollama-local', providerLabel: 'Ollama', costBasis: 'unpriced' },
|
|
42
|
+
{ id: 'qwen3.8:27b-q8_0', label: 'qwen3.8:27b-q8_0', providerId: 'ollama-local', providerLabel: 'Ollama', costBasis: 'free' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
test('a recommended model is kept only if the machine lists it', () => {
|
|
46
|
+
assert.equal(pickKnownModel('sonnet', MODELS)?.id, 'sonnet');
|
|
47
|
+
assert.equal(pickKnownModel('glm-5.3-flash:cloud', MODELS)?.providerId, 'ollama-local');
|
|
48
|
+
// Case and whitespace are not reasons to refuse a real id.
|
|
49
|
+
assert.equal(pickKnownModel(' Sonnet ', MODELS)?.id, 'sonnet');
|
|
50
|
+
assert.equal(pickKnownModel('GLM-5.3-FLASH:CLOUD', MODELS)?.id, 'glm-5.3-flash:cloud');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an id the machine cannot run becomes inherit, never a mission that fails at dispatch', () => {
|
|
54
|
+
assert.equal(pickKnownModel('gpt-9-ultra', MODELS), undefined);
|
|
55
|
+
assert.equal(pickKnownModel('claude-opus-5', MODELS), undefined); // real elsewhere, not listed here
|
|
56
|
+
assert.equal(pickKnownModel(undefined, MODELS), undefined);
|
|
57
|
+
assert.equal(pickKnownModel('', MODELS), undefined);
|
|
58
|
+
assert.equal(pickKnownModel('sonnet', []), undefined);
|
|
59
|
+
assert.equal(pickKnownModel('sonnet', undefined), undefined);
|
|
60
|
+
});
|
package/src/planner.ts
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planning session — the conversation that happens before a mission.
|
|
3
|
+
*
|
|
4
|
+
* Foreman's director is excellent at executing a well-specified mission and
|
|
5
|
+
* helpless against a vague one, and the brief is where nearly all run quality
|
|
6
|
+
* is decided. Yet the composer demanded that brief at the moment the human
|
|
7
|
+
* knew least. The planner exists to fix that ordering: you talk first, it
|
|
8
|
+
* reads the folder and asks questions, and the conversation's *output* is a
|
|
9
|
+
* mission proposal you can start.
|
|
10
|
+
*
|
|
11
|
+
* Three properties define it, and each is load-bearing:
|
|
12
|
+
*
|
|
13
|
+
* - **No resident process.** Each turn resumes the stored session, answers,
|
|
14
|
+
* and exits. An idle conversation costs nothing but disk, and there is
|
|
15
|
+
* never any ambiguity about whether Foreman is working or waiting — a
|
|
16
|
+
* question a warm always-on agent could not answer.
|
|
17
|
+
* - **Read-only, always.** The planner can read the project and nothing else:
|
|
18
|
+
* no Write, no Edit, no Bash. That is what makes it safe to leave sitting
|
|
19
|
+
* there, and it is the line that keeps Foreman from quietly becoming a
|
|
20
|
+
* worse Claude Code. Work is what missions are for.
|
|
21
|
+
* - **The handoff is an artifact.** `propose_mission` produces a card the
|
|
22
|
+
* human reads, edits and starts. The moment of commitment stays exactly
|
|
23
|
+
* where it is today — visible, budgeted, deliberate.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import {
|
|
27
|
+
query, tool, createSdkMcpServer,
|
|
28
|
+
type CanUseTool, type PermissionResult, type SDKMessage,
|
|
29
|
+
} from '@anthropic-ai/claude-agent-sdk';
|
|
30
|
+
import type { AgentEnv } from './provider.js';
|
|
31
|
+
import type { MissionProposal } from './types.js';
|
|
32
|
+
import {
|
|
33
|
+
armAskTimeout, formatAnswers, normaliseQuestions,
|
|
34
|
+
type AskAnswers, type AskQuestion, type PendingAsk,
|
|
35
|
+
} from './ask.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long the planner waits on a question before answering itself.
|
|
39
|
+
*
|
|
40
|
+
* Planning is the one attended surface — the human is, by definition, in the
|
|
41
|
+
* chat — so this is long. It is not infinite, because the rule that every
|
|
42
|
+
* ask carries an unattended default has no exceptions: a human who stepped
|
|
43
|
+
* away mid-conversation should come back to a planner that made a reasonable
|
|
44
|
+
* assumption and said so, not to a turn that has been hanging for an hour.
|
|
45
|
+
*/
|
|
46
|
+
export const PLANNER_ASK_TIMEOUT_MS = 30 * 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Questions the planner is waiting on, one per project at most.
|
|
50
|
+
*
|
|
51
|
+
* Module-level rather than per turn because the answer arrives on a different
|
|
52
|
+
* HTTP request than the one that started the turn. Keyed by project: a
|
|
53
|
+
* planning turn is one `query()` call, one turn runs at a time per project
|
|
54
|
+
* (server.ts enforces it), and a turn can only be parked on one question at a
|
|
55
|
+
* time — so the project id is the natural key and a second question from the
|
|
56
|
+
* same turn replaces the first.
|
|
57
|
+
*/
|
|
58
|
+
const pendingAsks = new Map<string, PendingAsk & {
|
|
59
|
+
resolve: (answers: AskAnswers | null) => void;
|
|
60
|
+
cancel: () => void;
|
|
61
|
+
}>();
|
|
62
|
+
|
|
63
|
+
/** The question a project's planner is currently parked on, for a client that loads mid-turn. */
|
|
64
|
+
export function pendingChatQuestion(projectId: string): PendingAsk | null {
|
|
65
|
+
const p = pendingAsks.get(projectId);
|
|
66
|
+
return p ? { id: p.id, questions: p.questions, askedAt: p.askedAt } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Deliver the human's answers to a waiting `ask_user`. Returns false when
|
|
71
|
+
* nothing is waiting under that id — a stale card, or a double click — so the
|
|
72
|
+
* route can say so instead of pretending.
|
|
73
|
+
*/
|
|
74
|
+
export function answerChatQuestion(projectId: string, id: string, answers: AskAnswers): boolean {
|
|
75
|
+
const p = pendingAsks.get(projectId);
|
|
76
|
+
if (!p || p.id !== id) return false;
|
|
77
|
+
p.cancel();
|
|
78
|
+
pendingAsks.delete(projectId);
|
|
79
|
+
p.resolve(answers);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Called when a turn ends for any reason, so a dead turn never holds a question open. */
|
|
84
|
+
export function dropPendingAsk(projectId: string): void {
|
|
85
|
+
const p = pendingAsks.get(projectId);
|
|
86
|
+
if (!p) return;
|
|
87
|
+
p.cancel();
|
|
88
|
+
pendingAsks.delete(projectId);
|
|
89
|
+
p.resolve(null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The only built-in tools the planner gets. Passed as the `tools` base set, so
|
|
94
|
+
* the rest are never even defined for the model — cheaper than defining them
|
|
95
|
+
* and refusing the calls, and it removes the temptation entirely.
|
|
96
|
+
*/
|
|
97
|
+
const PLANNER_TOOLS = ['Read', 'Grep', 'Glob'];
|
|
98
|
+
|
|
99
|
+
/** Conversation, not deep reasoning. Overridable from Settings (plannerModel). */
|
|
100
|
+
export const DEFAULT_PLANNER_MODEL = 'sonnet';
|
|
101
|
+
|
|
102
|
+
/** A planning turn that reads half the repo has misunderstood its job. */
|
|
103
|
+
const MAX_TURNS = 20;
|
|
104
|
+
|
|
105
|
+
const PLANNER_CHARTER = `
|
|
106
|
+
You are the FOREMAN PLANNER. You are the foreman in the site office: the human
|
|
107
|
+
comes to you to think out loud about what they want done in this folder, and
|
|
108
|
+
your job is to turn that into a mission the crew can execute.
|
|
109
|
+
|
|
110
|
+
You are NOT doing the work. You cannot write, edit, or run anything — you can
|
|
111
|
+
only read the project. When work needs doing, you propose a mission and the
|
|
112
|
+
human starts it; a director and its workers then execute it autonomously.
|
|
113
|
+
|
|
114
|
+
How to behave:
|
|
115
|
+
|
|
116
|
+
1. TALK LIKE A COLLEAGUE, NOT A FORM. Short, direct answers. Ask about what is
|
|
117
|
+
genuinely ambiguous and would change the work; do not interrogate the human
|
|
118
|
+
through a checklist. One or two good questions beat six obvious ones.
|
|
119
|
+
ASK WITH OPTIONS, NOT PROSE. When a question has a small set of sensible
|
|
120
|
+
answers — stack, scope, source of assets, which of two approaches — call
|
|
121
|
+
mcp__foreman__ask_user with those answers as options. The human clicks
|
|
122
|
+
instead of typing, and you get an unambiguous answer instead of a
|
|
123
|
+
paragraph to interpret. Put the option you would recommend FIRST and say
|
|
124
|
+
why in its hint. Batch up to three related questions in one call. Keep
|
|
125
|
+
prose questions for the genuinely open-ended ("what is this for?"). If no
|
|
126
|
+
answer comes, the tool tells you so: proceed on your recommendation and
|
|
127
|
+
state the assumption in your reply and in any proposal.
|
|
128
|
+
2. LOOK BEFORE YOU ASK. Read the folder first — README, package manifests, the
|
|
129
|
+
files under discussion, CLAUDE.md if present. Never ask a human something
|
|
130
|
+
the repository already answers. Ground what you say in what you actually
|
|
131
|
+
read, and say which files you looked at when it matters.
|
|
132
|
+
3. SAY WHAT YOU THINK. If the idea has a problem — the wrong approach, a
|
|
133
|
+
hidden dependency, a much simpler path, something already half-built in the
|
|
134
|
+
repo — say so plainly before it becomes a mission. This conversation is the
|
|
135
|
+
cheapest possible place to change direction.
|
|
136
|
+
4. PROPOSE WHEN THE SHAPE IS CLEAR, NOT BEFORE. When you and the human agree
|
|
137
|
+
on what is being built and how you would both know it worked, call
|
|
138
|
+
mcp__foreman__propose_mission. A proposal needs:
|
|
139
|
+
- a mission brief written for an agent that has never seen this
|
|
140
|
+
conversation: full context, paths, constraints, and what to leave alone;
|
|
141
|
+
- DONE WHEN criteria that are actually checkable by reading files or
|
|
142
|
+
running commands, not "works well";
|
|
143
|
+
- a budget you can justify from the size of the work;
|
|
144
|
+
- browser: true whenever a DONE WHEN criterion needs a page to load,
|
|
145
|
+
render, be free of console errors, or be screenshotted. The card starts
|
|
146
|
+
with the browser on; a mission that needs one and starts without it
|
|
147
|
+
fails its own criteria an hour later;
|
|
148
|
+
- director_model / worker_model, from MODELS AVAILABLE, with a one-line
|
|
149
|
+
model_rationale in terms of the work. The director plans, delegates and
|
|
150
|
+
verifies — give it a capable model. Workers implement — a fast or free
|
|
151
|
+
model is right when the work is mostly authoring or mechanical, and
|
|
152
|
+
wrong when it needs judgement across many files. Say which and why.
|
|
153
|
+
Omit both to inherit the project's defaults; never invent an id.
|
|
154
|
+
Do not propose on the first message unless the human's request is already
|
|
155
|
+
completely unambiguous. Do not propose the same thing twice; refine it.
|
|
156
|
+
BUDGET, ANCHORED: one small file or a contained fix, $1-2. A feature across
|
|
157
|
+
a few files with real verification, $3-5. A multi-worker build, or anything
|
|
158
|
+
needing browser checks and screenshots, $8-15. The cap is a stop, not a
|
|
159
|
+
target: pick the smallest figure that plausibly finishes the work, and say
|
|
160
|
+
in one line what drove it. Never reach for a round default because it is
|
|
161
|
+
the obvious number.
|
|
162
|
+
5. THE HUMAN DECIDES. A proposal is a draft, not a launch. Say what you
|
|
163
|
+
proposed and let them read it. If they want changes, propose again.
|
|
164
|
+
6. NEVER discuss Foreman's own server or oversight tooling as a work target.
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
/** One model the planner may recommend — the server's list, trimmed to what a recommendation needs. */
|
|
168
|
+
export interface PlannerModel {
|
|
169
|
+
id: string;
|
|
170
|
+
label: string;
|
|
171
|
+
providerId?: string;
|
|
172
|
+
providerLabel: string;
|
|
173
|
+
costBasis: 'priced' | 'free' | 'unpriced';
|
|
174
|
+
note?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Does this mission need a browser? Decided from the words the planner
|
|
179
|
+
* itself wrote, so a planner that forgot to set `browser: true` cannot
|
|
180
|
+
* propose a mission whose criteria say "screenshots" with the browser off.
|
|
181
|
+
* The planner's explicit flag still wins when present; this is the net
|
|
182
|
+
* under it, not a replacement. Deliberately broad: a false positive costs a
|
|
183
|
+
* switch the human can flip off, a false negative cost an hour once.
|
|
184
|
+
*/
|
|
185
|
+
export function needsBrowser(mission: string, doneWhen: string[]): boolean {
|
|
186
|
+
const text = `${mission}\n${doneWhen.join('\n')}`.toLowerCase();
|
|
187
|
+
return /\b(screenshot|browser|console error|render(s|ed|ing)? (correctly|properly|in)|viewport|mobile width|playwright|opens? (correctly )?in a browser|no broken image)/.test(text);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Keeps a recommended model only if it is one the machine can actually run.
|
|
192
|
+
* A planner that hallucinates an id, or names one from a provider that has
|
|
193
|
+
* since gone away, gets "inherit" rather than a mission that fails at
|
|
194
|
+
* dispatch on a model nobody could have picked from the list.
|
|
195
|
+
*/
|
|
196
|
+
export function pickKnownModel(
|
|
197
|
+
id: string | undefined, models: PlannerModel[] | undefined,
|
|
198
|
+
): PlannerModel | undefined {
|
|
199
|
+
if (!id || !models?.length) return undefined;
|
|
200
|
+
const want = id.trim().toLowerCase();
|
|
201
|
+
return models.find((m) => m.id.toLowerCase() === want || m.label.toLowerCase() === want);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The section appended to the charter so the planner can recommend real models. */
|
|
205
|
+
function modelsSection(models: PlannerModel[] | undefined): string {
|
|
206
|
+
if (!models?.length) return '';
|
|
207
|
+
const lines = models.map((m) =>
|
|
208
|
+
` - ${m.id} — ${m.providerLabel} · ${m.costBasis}${m.note ? ` · ${m.note}` : ''}`);
|
|
209
|
+
return `\nMODELS AVAILABLE ON THIS MACHINE (use these exact ids in propose_mission):\n${lines.join('\n')}\n`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface PlanningTurn {
|
|
213
|
+
/** Keys the one question this project's planner may be parked on. */
|
|
214
|
+
projectId: string;
|
|
215
|
+
/** What the machine can run, so recommendations are real ids, not guesses. */
|
|
216
|
+
models?: PlannerModel[];
|
|
217
|
+
/** Session to resume; absent starts a fresh conversation. */
|
|
218
|
+
sessionId?: string;
|
|
219
|
+
folder: string;
|
|
220
|
+
text: string;
|
|
221
|
+
model?: string;
|
|
222
|
+
/** Credential + wire, resolved by the caller. See provider.ts. */
|
|
223
|
+
agentEnv: AgentEnv;
|
|
224
|
+
/** Broadcasts and persists, exactly like a run's emitter. */
|
|
225
|
+
emit: (event: string, data: unknown) => void;
|
|
226
|
+
/** Aborting it ends the turn: the model call stops, a parked question is dropped, the reply is discarded. */
|
|
227
|
+
abort?: AbortController;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface PlanningResult {
|
|
231
|
+
/** Session id to store, so the next message continues this conversation. */
|
|
232
|
+
sessionId?: string;
|
|
233
|
+
/** What this turn cost. */
|
|
234
|
+
costUsd: number;
|
|
235
|
+
/** Set when the planner proposed a mission during the turn. */
|
|
236
|
+
proposal?: MissionProposal;
|
|
237
|
+
/** Present when the turn failed; the conversation is still usable. */
|
|
238
|
+
error?: string;
|
|
239
|
+
/** The human stopped it. Not an error: nothing to fix, nothing to retry. */
|
|
240
|
+
stopped?: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The planner may read the project and propose a mission. Everything else is
|
|
245
|
+
* denied with an explanation rather than silently failing, so the model
|
|
246
|
+
* redirects to a proposal instead of retrying a tool it will never get.
|
|
247
|
+
*/
|
|
248
|
+
const canUseTool: CanUseTool = async (toolName, input): Promise<PermissionResult> => {
|
|
249
|
+
if (
|
|
250
|
+
toolName === 'mcp__foreman__propose_mission'
|
|
251
|
+
|| toolName === 'mcp__foreman__ask_user'
|
|
252
|
+
|| PLANNER_TOOLS.includes(toolName)
|
|
253
|
+
) {
|
|
254
|
+
return { behavior: 'allow', updatedInput: input };
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
behavior: 'deny',
|
|
258
|
+
message:
|
|
259
|
+
`The planner is read-only and cannot use ${toolName}. You can Read, Grep and Glob ` +
|
|
260
|
+
'to understand the project. If this needs doing, put it in a mission with ' +
|
|
261
|
+
'mcp__foreman__propose_mission and let the human start it.',
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Runs one turn of the conversation to completion.
|
|
267
|
+
*
|
|
268
|
+
* Never throws: a planning turn that fails leaves the conversation intact and
|
|
269
|
+
* reports the failure as text, because losing a design discussion to a
|
|
270
|
+
* transient SDK error would be a far worse outcome than an apology in the
|
|
271
|
+
* transcript.
|
|
272
|
+
*/
|
|
273
|
+
export async function runPlanningTurn(turn: PlanningTurn): Promise<PlanningResult> {
|
|
274
|
+
let proposal: MissionProposal | undefined;
|
|
275
|
+
|
|
276
|
+
const proposeMission = tool(
|
|
277
|
+
'propose_mission',
|
|
278
|
+
'Propose a mission for the human to review and start. Shows them an editable ' +
|
|
279
|
+
'card with the brief, the DONE WHEN criteria and the budget. Call this only ' +
|
|
280
|
+
'once you and the human agree on what is being built.',
|
|
281
|
+
{
|
|
282
|
+
mission: z.string().describe(
|
|
283
|
+
'The brief, written for an agent that has not seen this conversation: ' +
|
|
284
|
+
'full context, paths, constraints, and what to leave alone'),
|
|
285
|
+
done_when: z.array(z.string()).describe(
|
|
286
|
+
'Completion criteria that can actually be checked by reading files or running commands'),
|
|
287
|
+
budget_usd: z.number().describe('Suggested cap in US dollars, justified by the size of the work'),
|
|
288
|
+
rationale: z.string().optional().describe('One short paragraph: why this shape and this budget'),
|
|
289
|
+
browser: z.boolean().optional().describe(
|
|
290
|
+
'True when the mission needs a browser: any DONE WHEN criterion about pages ' +
|
|
291
|
+
'loading, rendering, console errors or screenshots. The card starts with it on.'),
|
|
292
|
+
director_model: z.string().optional().describe(
|
|
293
|
+
'Recommended director, an exact id from MODELS AVAILABLE. Omit to inherit the project default.'),
|
|
294
|
+
worker_model: z.string().optional().describe(
|
|
295
|
+
'Recommended worker model, an exact id from MODELS AVAILABLE. Omit to inherit.'),
|
|
296
|
+
model_rationale: z.string().optional().describe(
|
|
297
|
+
'One line: why these two, in terms of the work (e.g. "workers on a fast model: mostly HTML/CSS authoring")'),
|
|
298
|
+
},
|
|
299
|
+
async ({ mission, done_when, budget_usd, rationale, browser, director_model, worker_model, model_rationale }) => {
|
|
300
|
+
// Real ids only. A recommendation the machine cannot run becomes
|
|
301
|
+
// "inherit", never a mission that fails at dispatch.
|
|
302
|
+
const director = pickKnownModel(director_model, turn.models);
|
|
303
|
+
const worker = pickKnownModel(worker_model, turn.models);
|
|
304
|
+
proposal = {
|
|
305
|
+
id: `mp-${Date.now().toString(36)}`,
|
|
306
|
+
mission,
|
|
307
|
+
doneWhen: done_when,
|
|
308
|
+
budgetUsd: budget_usd,
|
|
309
|
+
rationale,
|
|
310
|
+
// The planner's explicit flag, with a deterministic net under it: the
|
|
311
|
+
// criteria it just wrote are read back for "screenshot", "console
|
|
312
|
+
// error", "renders correctly" and the like. A mission whose DONE WHEN
|
|
313
|
+
// said "screenshots saved" once started with the browser off and lost
|
|
314
|
+
// an hour; that cannot depend on the model remembering a flag.
|
|
315
|
+
browser: browser === true || needsBrowser(mission, done_when) ? true : undefined,
|
|
316
|
+
directorModel: director?.id,
|
|
317
|
+
workerModel: worker?.id,
|
|
318
|
+
directorProviderId: director?.providerId,
|
|
319
|
+
workerProviderId: worker?.providerId,
|
|
320
|
+
modelRationale: director || worker ? model_rationale : undefined,
|
|
321
|
+
createdAt: Date.now(),
|
|
322
|
+
};
|
|
323
|
+
turn.emit('mission_proposed', proposal);
|
|
324
|
+
return {
|
|
325
|
+
content: [{
|
|
326
|
+
type: 'text' as const,
|
|
327
|
+
text: 'Proposal shown to the human. Tell them briefly what you proposed and ' +
|
|
328
|
+
'wait — they start it, or ask you to change it. Do not propose again unless asked.',
|
|
329
|
+
}],
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A question with options, rendered as a picker in place of the input box.
|
|
336
|
+
*
|
|
337
|
+
* Blocks inside the turn until the human answers or the timeout fires. That
|
|
338
|
+
* is safe here where it would not be in a mission: the POST that started
|
|
339
|
+
* this turn already returned, the SSE stream carries the question, and the
|
|
340
|
+
* one cost of waiting is that the planner cannot start a second turn — which
|
|
341
|
+
* is also true while it is thinking.
|
|
342
|
+
*/
|
|
343
|
+
const askUser = tool(
|
|
344
|
+
'ask_user',
|
|
345
|
+
'Ask the human one to three questions that each have a small set of sensible ' +
|
|
346
|
+
'answers. They see clickable options instead of a paragraph to reply to, and ' +
|
|
347
|
+
'you get exact answers back. Put your recommended option first. Not for ' +
|
|
348
|
+
'open-ended questions — ask those in prose.',
|
|
349
|
+
{
|
|
350
|
+
questions: z.array(z.object({
|
|
351
|
+
question: z.string().describe('One clear question'),
|
|
352
|
+
options: z.array(z.union([
|
|
353
|
+
z.string(),
|
|
354
|
+
z.object({
|
|
355
|
+
label: z.string(),
|
|
356
|
+
hint: z.string().optional().describe('One line: what choosing this implies'),
|
|
357
|
+
}),
|
|
358
|
+
])).min(2).max(6).describe('Sensible answers, recommended first'),
|
|
359
|
+
multi: z.boolean().optional().describe('Allow choosing several'),
|
|
360
|
+
})).min(1).max(3),
|
|
361
|
+
},
|
|
362
|
+
async ({ questions: raw }) => {
|
|
363
|
+
const questions: AskQuestion[] = normaliseQuestions(raw);
|
|
364
|
+
const id = `q-${Date.now().toString(36)}`;
|
|
365
|
+
// A second question from the same turn replaces the first: the model
|
|
366
|
+
// moved on, and a stale card the human answers into nothing is worse
|
|
367
|
+
// than one that quietly disappeared.
|
|
368
|
+
dropPendingAsk(turn.projectId);
|
|
369
|
+
|
|
370
|
+
const answers = await new Promise<AskAnswers | null>((resolve) => {
|
|
371
|
+
const timer = armAskTimeout(PLANNER_ASK_TIMEOUT_MS, () => {
|
|
372
|
+
pendingAsks.delete(turn.projectId);
|
|
373
|
+
turn.emit('chat_question_timeout', { id, afterMs: PLANNER_ASK_TIMEOUT_MS });
|
|
374
|
+
resolve(null);
|
|
375
|
+
});
|
|
376
|
+
pendingAsks.set(turn.projectId, {
|
|
377
|
+
id, questions, askedAt: Date.now(), resolve, cancel: () => timer.cancel(),
|
|
378
|
+
});
|
|
379
|
+
turn.emit('chat_question', { id, questions });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const text = answers
|
|
383
|
+
? `The human answered:\n${formatAnswers(questions, answers)}`
|
|
384
|
+
: `No answer after ${PLANNER_ASK_TIMEOUT_MS / 60_000} minutes — the human stepped away. ` +
|
|
385
|
+
'Proceed on your recommended options, and state each assumption plainly in your ' +
|
|
386
|
+
'reply and in any proposal so they can correct it when they return.';
|
|
387
|
+
return { content: [{ type: 'text' as const, text }] };
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
let sessionId = turn.sessionId;
|
|
392
|
+
let costUsd = 0;
|
|
393
|
+
let q: ReturnType<typeof query> | undefined;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
q = query({
|
|
397
|
+
prompt: turn.text,
|
|
398
|
+
options: {
|
|
399
|
+
cwd: turn.folder,
|
|
400
|
+
resume: turn.sessionId,
|
|
401
|
+
model: turn.model || DEFAULT_PLANNER_MODEL,
|
|
402
|
+
maxTurns: MAX_TURNS,
|
|
403
|
+
tools: PLANNER_TOOLS,
|
|
404
|
+
permissionMode: 'default',
|
|
405
|
+
// The model list rides on the system prompt rather than a tool: the
|
|
406
|
+
// planner should know what it can recommend before it starts
|
|
407
|
+
// thinking about the proposal, not discover it by asking.
|
|
408
|
+
systemPrompt: {
|
|
409
|
+
type: 'preset', preset: 'claude_code',
|
|
410
|
+
append: PLANNER_CHARTER + modelsSection(turn.models),
|
|
411
|
+
},
|
|
412
|
+
mcpServers: { foreman: createSdkMcpServer({ name: 'foreman', tools: [proposeMission, askUser] }) },
|
|
413
|
+
canUseTool,
|
|
414
|
+
abortController: turn.abort,
|
|
415
|
+
...turn.agentEnv,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
let failed = false;
|
|
420
|
+
for await (const msg of q as AsyncIterable<SDKMessage>) {
|
|
421
|
+
const m = msg as Record<string, unknown>;
|
|
422
|
+
if (typeof m.session_id === 'string') sessionId = m.session_id;
|
|
423
|
+
if (m.type === 'result') {
|
|
424
|
+
// Cumulative across the turn, and a turn is one query() call, so the
|
|
425
|
+
// reported total is this turn's cost outright — no delta to track.
|
|
426
|
+
if (typeof m.total_cost_usd === 'number') costUsd = m.total_cost_usd;
|
|
427
|
+
failed = Boolean(m.is_error);
|
|
428
|
+
}
|
|
429
|
+
turn.emit('message', { agent: 'foreman', msg });
|
|
430
|
+
}
|
|
431
|
+
if (turn.abort?.signal.aborted) return { sessionId, costUsd, proposal, stopped: true };
|
|
432
|
+
return { sessionId, costUsd, proposal, error: failed ? 'the turn ended with an error' : undefined };
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (turn.abort?.signal.aborted) return { sessionId, costUsd, proposal, stopped: true };
|
|
435
|
+
return { sessionId, costUsd, proposal, error: String(err) };
|
|
436
|
+
} finally {
|
|
437
|
+
// A turn that ended with a question still open — crash, interrupt, SDK
|
|
438
|
+
// error — must not leave a card the human can answer into nothing.
|
|
439
|
+
dropPendingAsk(turn.projectId);
|
|
440
|
+
// The turn is over: dispose the subprocess rather than leaving one warm
|
|
441
|
+
// per project. Continuity comes from the session id, not from a process.
|
|
442
|
+
await (q as AsyncGenerator<SDKMessage> | undefined)?.return?.(undefined as never)
|
|
443
|
+
.catch(() => {});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Forking a finished mission
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/** What the planner is handed when the human asks for the next step after a run. */
|
|
452
|
+
export interface ForkSource {
|
|
453
|
+
title?: string;
|
|
454
|
+
mission: string;
|
|
455
|
+
status: string;
|
|
456
|
+
endedAt?: number;
|
|
457
|
+
/** `.foreman/MISSION.md` as the run left it; null when it is gone. */
|
|
458
|
+
missionDoc?: string | null;
|
|
459
|
+
/** The director's closing message; null when the run has none. */
|
|
460
|
+
report?: string | null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const clip = (text: string, max: number): string =>
|
|
464
|
+
text.length <= max ? text : `${text.slice(0, max).trimEnd()}\n\n[… ${text.length - max} more characters not shown]`;
|
|
465
|
+
|
|
466
|
+
/** The run's short name for a sentence: its title, else the brief's first line. */
|
|
467
|
+
export function forkLabel(src: Pick<ForkSource, 'title' | 'mission'>): string {
|
|
468
|
+
const t = (src.title || src.mission.split('\n').find((l) => l.trim()) || 'the previous mission').trim();
|
|
469
|
+
return t.length > 80 ? `${t.slice(0, 79).trimEnd()}…` : t;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The first turn of a planning conversation that builds on a finished run.
|
|
474
|
+
*
|
|
475
|
+
* `shown` is the one line the transcript shows as the human's message — it
|
|
476
|
+
* is what the click meant. `prompt` is what the planner actually receives:
|
|
477
|
+
* the previous brief, its mission doc and the director's report, so the
|
|
478
|
+
* conversation starts from what was built instead of from an empty folder
|
|
479
|
+
* and a guess. A fork is a new mission with its own budget, title and deck
|
|
480
|
+
* baseline; the seed says so, so the planner does not "continue" the old one.
|
|
481
|
+
*/
|
|
482
|
+
export function forkSeed(src: ForkSource): { shown: string; prompt: string } {
|
|
483
|
+
const label = forkLabel(src);
|
|
484
|
+
const when = src.endedAt
|
|
485
|
+
? new Date(src.endedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
486
|
+
: undefined;
|
|
487
|
+
const parts = [
|
|
488
|
+
'We are planning the NEXT step for this project, building on a mission that already ran. ' +
|
|
489
|
+
'Read what follows before you read the folder. Do not re-propose or redo that mission; ' +
|
|
490
|
+
'the new one will be a separate run with its own brief, DONE WHEN and budget, and it ' +
|
|
491
|
+
'starts from what that run left in place.',
|
|
492
|
+
`## Previous mission — ${label} (${src.status}${when ? `, ${when}` : ''})\n\n${clip(src.mission.trim(), 4000)}`,
|
|
493
|
+
];
|
|
494
|
+
if (src.missionDoc?.trim()) {
|
|
495
|
+
parts.push(`## Its mission doc (.foreman/MISSION.md as the run left it)\n\n${clip(src.missionDoc.trim(), 6000)}`);
|
|
496
|
+
}
|
|
497
|
+
if (src.report?.trim()) {
|
|
498
|
+
parts.push(`## The director's final report\n\n${clip(src.report.trim(), 4000)}`);
|
|
499
|
+
}
|
|
500
|
+
parts.push(
|
|
501
|
+
'Start with three lines on what that mission left in place and what it did not do. ' +
|
|
502
|
+
'Then ask me what comes next — with ask_user and concrete options where the previous ' +
|
|
503
|
+
'work suggests obvious candidates. Propose a mission only once we agree.');
|
|
504
|
+
return { shown: `Plan the next step after “${label}”.`, prompt: parts.join('\n\n') };
|
|
505
|
+
}
|