@amenophis1er/foreman 0.1.16 → 0.1.18
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 +74 -3
- package/package.json +1 -1
- package/src/crew.test.ts +376 -0
- package/src/crew.ts +330 -0
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +133 -2
- package/src/gitwork.ts +211 -2
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +86 -5
- package/src/notify/commands.test.ts +2 -0
- package/src/notify/commands.ts +6 -0
- package/src/notify/telegram.ts +1 -0
- package/src/notify.test.ts +61 -0
- package/src/notify.ts +45 -1
- package/src/orchestrator.test.ts +516 -2
- package/src/orchestrator.ts +514 -69
- package/src/run-crew.test.ts +99 -0
- package/src/run-crew.ts +101 -0
- package/src/schedule-guards.test.ts +236 -0
- package/src/schedule-guards.ts +149 -0
- package/src/schedule.test.ts +240 -0
- package/src/schedule.ts +343 -0
- package/src/server.ts +674 -11
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +92 -0
- package/ui/dist/assets/index-0QuGXbFg.js +76 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-DOVnExqF.js +0 -68
package/src/store.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { RunStore, newRunId } from './store.js';
|
|
7
|
-
import type { RunMeta } from './types.js';
|
|
7
|
+
import type { RunMeta, Schedule } from './types.js';
|
|
8
8
|
|
|
9
9
|
function meta(id: string, over: Partial<RunMeta> = {}): RunMeta {
|
|
10
10
|
return {
|
|
@@ -151,3 +151,83 @@ test('sweepOrphans leaves a running run alone while another live process owns it
|
|
|
151
151
|
assert.equal((await store.readMeta(theirs))!.status, 'running');
|
|
152
152
|
await rm(root, { recursive: true, force: true });
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
function schedule(over: Partial<Schedule> = {}): Omit<Schedule, 'id' | 'createdAt'> & { createdAt?: number } {
|
|
156
|
+
return {
|
|
157
|
+
projectId: 'p-1', name: 'nightly', brief: 'tidy the tests', budgetUsd: 3,
|
|
158
|
+
cadence: { kind: 'daily', at: '03:00' } as unknown as Schedule['cadence'],
|
|
159
|
+
enabled: true, nextRunAt: 1000, consecutiveFailures: 0, pausedReason: null,
|
|
160
|
+
...over,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test('schedules: add generates an id, list is per project and oldest first', async () => {
|
|
165
|
+
const { store, root } = await tmpStore();
|
|
166
|
+
const a = await store.addSchedule(schedule({ createdAt: 2000 }));
|
|
167
|
+
const b = await store.addSchedule(schedule({ createdAt: 1000, name: 'weekly' }));
|
|
168
|
+
const other = await store.addSchedule(schedule({ projectId: 'p-2' }));
|
|
169
|
+
|
|
170
|
+
assert.match(a.id, /^s-[0-9a-f]{12}$/);
|
|
171
|
+
assert.notEqual(a.id, b.id);
|
|
172
|
+
assert.deepEqual((await store.listSchedules()).map((s) => s.id), [b.id, a.id, other.id]);
|
|
173
|
+
assert.deepEqual((await store.listSchedules('p-1')).map((s) => s.name), ['weekly', 'nightly']);
|
|
174
|
+
assert.deepEqual(await store.getSchedule(a.id), a);
|
|
175
|
+
assert.equal(await store.getSchedule('s-missing'), null);
|
|
176
|
+
await rm(root, { recursive: true, force: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('schedules: update merges, and null clears where undefined leaves alone', async () => {
|
|
180
|
+
const { store, root } = await tmpStore();
|
|
181
|
+
const s = await store.addSchedule(schedule({ pausedReason: 'failures', consecutiveFailures: 3 }));
|
|
182
|
+
|
|
183
|
+
const paused = await store.updateSchedule(s.id, { lastOutcome: 'error' });
|
|
184
|
+
assert.equal(paused?.pausedReason, 'failures', 'an absent key is left alone');
|
|
185
|
+
assert.equal(paused?.lastOutcome, 'error');
|
|
186
|
+
|
|
187
|
+
const resumed = await store.updateSchedule(s.id, {
|
|
188
|
+
pausedReason: null, nextRunAt: null, consecutiveFailures: 0,
|
|
189
|
+
});
|
|
190
|
+
assert.equal(resumed?.pausedReason, null);
|
|
191
|
+
assert.equal(resumed?.nextRunAt, null);
|
|
192
|
+
assert.equal(resumed?.consecutiveFailures, 0);
|
|
193
|
+
// And it survived the write, not just the returned object.
|
|
194
|
+
assert.equal((await store.getSchedule(s.id))?.nextRunAt, null);
|
|
195
|
+
|
|
196
|
+
assert.equal(await store.updateSchedule('s-missing', { enabled: false }), null);
|
|
197
|
+
await rm(root, { recursive: true, force: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('schedules: remove reports whether it existed', async () => {
|
|
201
|
+
const { store, root } = await tmpStore();
|
|
202
|
+
const a = await store.addSchedule(schedule());
|
|
203
|
+
const b = await store.addSchedule(schedule({ name: 'other' }));
|
|
204
|
+
assert.equal(await store.removeSchedule(a.id), true);
|
|
205
|
+
assert.equal(await store.removeSchedule(a.id), false);
|
|
206
|
+
assert.deepEqual((await store.listSchedules()).map((s) => s.id), [b.id]);
|
|
207
|
+
await rm(root, { recursive: true, force: true });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('schedules: unlinking a project takes its schedules with it', async () => {
|
|
211
|
+
const { store, root } = await tmpStore();
|
|
212
|
+
const p = await store.addProject('/tmp/sched-proj');
|
|
213
|
+
const q = await store.addProject('/tmp/other-proj');
|
|
214
|
+
await store.addSchedule(schedule({ projectId: p.id }));
|
|
215
|
+
await store.addSchedule(schedule({ projectId: p.id, name: 'second' }));
|
|
216
|
+
const keep = await store.addSchedule(schedule({ projectId: q.id }));
|
|
217
|
+
|
|
218
|
+
assert.equal(await store.removeProject(p.id), true);
|
|
219
|
+
assert.deepEqual((await store.listSchedules()).map((s) => s.id), [keep.id]);
|
|
220
|
+
assert.deepEqual(await store.listSchedules(p.id), []);
|
|
221
|
+
await rm(root, { recursive: true, force: true });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('schedules: a corrupt schedules.json reads as empty rather than throwing', async () => {
|
|
225
|
+
const { store, root } = await tmpStore();
|
|
226
|
+
const { writeFile } = await import('node:fs/promises');
|
|
227
|
+
await writeFile(path.join(root, 'schedules.json'), '{not json');
|
|
228
|
+
assert.deepEqual(await store.listSchedules(), []);
|
|
229
|
+
// And a write over it recovers the file.
|
|
230
|
+
const s = await store.addSchedule(schedule());
|
|
231
|
+
assert.deepEqual((await store.listSchedules()).map((x) => x.id), [s.id]);
|
|
232
|
+
await rm(root, { recursive: true, force: true });
|
|
233
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* <root>/runs/<runId>/events.jsonl — one ForemanEvent per line, append-only
|
|
8
8
|
* <root>/chats/<projectId>/… — same two files for a project's
|
|
9
9
|
* planning conversation
|
|
10
|
+
* <root>/schedules.json — every Schedule, one JSON array
|
|
10
11
|
*
|
|
11
12
|
* Design notes for reviewers:
|
|
12
13
|
* - The event log is the source of truth for the UI; meta.json is a derived
|
|
@@ -24,7 +25,7 @@ import os from 'node:os';
|
|
|
24
25
|
import path from 'node:path';
|
|
25
26
|
import crypto from 'node:crypto';
|
|
26
27
|
import type {
|
|
27
|
-
ChatMeta, ForemanEvent, Project, ProviderRef, RunMeta, RunSummary, SettingsFile,
|
|
28
|
+
ChatMeta, ForemanEvent, Project, ProviderRef, RunMeta, RunSummary, Schedule, SettingsFile,
|
|
28
29
|
} from './types.js';
|
|
29
30
|
|
|
30
31
|
const RUN_ID_RE = /^[0-9]{13}-[0-9a-f]{8}$/;
|
|
@@ -142,11 +143,16 @@ export class RunStore {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
/** Unlinks a project (run history is kept). Returns whether it existed. */
|
|
145
|
-
removeProject(projectId: string): Promise<boolean> {
|
|
146
|
-
|
|
146
|
+
async removeProject(projectId: string): Promise<boolean> {
|
|
147
|
+
const existed = await this.mutateProjects((projects) => {
|
|
147
148
|
const rest = projects.filter((p) => p.id !== projectId);
|
|
148
149
|
return { projects: rest, result: rest.length !== projects.length };
|
|
149
150
|
});
|
|
151
|
+
// Its schedules go with it: a standing instruction to run missions in a
|
|
152
|
+
// folder Foreman no longer knows about has nowhere to fire. Sequential and
|
|
153
|
+
// not nested inside the mutate, because both writes share one chain.
|
|
154
|
+
await this.removeProjectSchedules(projectId);
|
|
155
|
+
return existed;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
async getProject(projectId: string): Promise<Project | null> {
|
|
@@ -183,6 +189,114 @@ export class RunStore {
|
|
|
183
189
|
return task;
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
// -- schedules ------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every schedule lives in one small file, not a directory per schedule:
|
|
196
|
+
* there are a handful of them, the ticker reads all of them on every tick to
|
|
197
|
+
* decide what is due, and a single array is one read and one atomic write.
|
|
198
|
+
* It shares `projectsChain` with projects.json and settings.json so a write
|
|
199
|
+
* here can never interleave with one of those.
|
|
200
|
+
*/
|
|
201
|
+
private get schedulesFile(): string {
|
|
202
|
+
return path.join(this.root, 'schedules.json');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** All schedules, or one project's; oldest first so the list never reorders
|
|
206
|
+
* itself under the human between visits. A missing or unparseable file is
|
|
207
|
+
* an empty list — the ticker must keep running, not crash on a bad byte. */
|
|
208
|
+
async listSchedules(projectId?: string): Promise<Schedule[]> {
|
|
209
|
+
const raw = await readFile(this.schedulesFile, 'utf8').catch(() => null);
|
|
210
|
+
let all: Schedule[] = [];
|
|
211
|
+
if (raw) {
|
|
212
|
+
try {
|
|
213
|
+
const parsed: unknown = JSON.parse(raw);
|
|
214
|
+
if (Array.isArray(parsed)) all = parsed as Schedule[];
|
|
215
|
+
} catch {
|
|
216
|
+
all = [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const wanted = projectId ? all.filter((s) => s.projectId === projectId) : all;
|
|
220
|
+
return [...wanted].sort((a, b) => a.createdAt - b.createdAt);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Atomically rewrites schedules.json through `mutate`; returns its result. */
|
|
224
|
+
private mutateSchedules<T>(
|
|
225
|
+
mutate: (schedules: Schedule[]) => { schedules: Schedule[]; result: T },
|
|
226
|
+
): Promise<T> {
|
|
227
|
+
const task = this.projectsChain.then(async () => {
|
|
228
|
+
const { schedules, result } = mutate(await this.listSchedules());
|
|
229
|
+
await mkdir(this.root, { recursive: true });
|
|
230
|
+
const tmp = path.join(this.root, `.schedules.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
231
|
+
await writeFile(tmp, JSON.stringify(schedules, null, 2));
|
|
232
|
+
await rename(tmp, this.schedulesFile);
|
|
233
|
+
return result;
|
|
234
|
+
});
|
|
235
|
+
this.projectsChain = task.catch(() => {});
|
|
236
|
+
return task;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async getSchedule(id: string): Promise<Schedule | null> {
|
|
240
|
+
return (await this.listSchedules()).find((s) => s.id === id) ?? null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Records a new schedule. The id and creation time are the store's to give,
|
|
244
|
+
* like a project's; callers may pass them when restoring a known record. */
|
|
245
|
+
addSchedule(
|
|
246
|
+
s: Omit<Schedule, 'id' | 'createdAt'> & { id?: string; createdAt?: number },
|
|
247
|
+
): Promise<Schedule> {
|
|
248
|
+
return this.mutateSchedules((schedules) => {
|
|
249
|
+
const schedule: Schedule = {
|
|
250
|
+
...s,
|
|
251
|
+
id: s.id ?? `s-${crypto.randomBytes(6).toString('hex')}`,
|
|
252
|
+
createdAt: s.createdAt ?? Date.now(),
|
|
253
|
+
};
|
|
254
|
+
return { schedules: [...schedules, schedule], result: schedule };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Merges a partial change. `undefined` means "leave it alone" and `null` is
|
|
260
|
+
* a value in its own right — `pausedReason` and `nextRunAt` are both cleared
|
|
261
|
+
* by writing null, and a spread alone would let an absent key erase them.
|
|
262
|
+
* Identity (id, project, creation) is not patchable; a schedule that moved
|
|
263
|
+
* project would silently start running missions in another folder.
|
|
264
|
+
*/
|
|
265
|
+
updateSchedule(
|
|
266
|
+
id: string,
|
|
267
|
+
patch: Partial<Omit<Schedule, 'id' | 'projectId' | 'createdAt'>>,
|
|
268
|
+
): Promise<Schedule | null> {
|
|
269
|
+
return this.mutateSchedules((schedules) => {
|
|
270
|
+
const i = schedules.findIndex((s) => s.id === id);
|
|
271
|
+
if (i === -1) return { schedules, result: null };
|
|
272
|
+
const next: Schedule = { ...schedules[i] };
|
|
273
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
274
|
+
if (value === undefined) continue;
|
|
275
|
+
(next as unknown as Record<string, unknown>)[key] = value;
|
|
276
|
+
}
|
|
277
|
+
const updated = [...schedules];
|
|
278
|
+
updated[i] = next;
|
|
279
|
+
return { schedules: updated, result: next };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Forgets one schedule. Returns whether it existed. */
|
|
284
|
+
removeSchedule(id: string): Promise<boolean> {
|
|
285
|
+
return this.mutateSchedules((schedules) => {
|
|
286
|
+
const rest = schedules.filter((s) => s.id !== id);
|
|
287
|
+
return { schedules: rest, result: rest.length !== schedules.length };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Forgets a project's schedules; returns how many went. Called when a
|
|
292
|
+
* project is unlinked, so no schedule outlives the project it fires in. */
|
|
293
|
+
removeProjectSchedules(projectId: string): Promise<number> {
|
|
294
|
+
return this.mutateSchedules((schedules) => {
|
|
295
|
+
const rest = schedules.filter((s) => s.projectId !== projectId);
|
|
296
|
+
return { schedules: rest, result: schedules.length - rest.length };
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
186
300
|
// -- runs -----------------------------------------------------------------
|
|
187
301
|
|
|
188
302
|
private runDir(runId: string): string {
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* broadcast to connected SSE clients *and* appended to the run's event log,
|
|
6
6
|
* so replaying a log reproduces exactly what a live client observed.
|
|
7
7
|
*/
|
|
8
|
+
import type { Cadence } from './schedule.js';
|
|
9
|
+
import type { CrewPreset, ReviewVerdict } from './crew.js';
|
|
10
|
+
|
|
11
|
+
/** Re-exported so callers can name a schedule's cadence without reaching past
|
|
12
|
+
* this module for it; the rules that interpret one live in schedule.ts. */
|
|
13
|
+
export type { Cadence };
|
|
14
|
+
|
|
15
|
+
/** Re-exported on the same principle: a record can be described without
|
|
16
|
+
* reaching past this module, while crew.ts stays the definition site and
|
|
17
|
+
* keeps the rules that read them. */
|
|
18
|
+
export type { CrewPreset, ReviewVerdict };
|
|
8
19
|
|
|
9
20
|
export type RunStatus = 'running' | 'done' | 'error' | 'interrupted';
|
|
10
21
|
|
|
@@ -27,6 +38,44 @@ export interface Project {
|
|
|
27
38
|
claudeInstance?: ClaudeInstanceRef;
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A standing instruction to start a mission on a cadence. Foreman's own
|
|
43
|
+
* record, never the project's repo: a schedule is an operator's decision about
|
|
44
|
+
* a machine, not something a checkout should carry to whoever clones it.
|
|
45
|
+
*
|
|
46
|
+
* The fields after `enabled` are the ticker's memory. It is stateless between
|
|
47
|
+
* ticks — every question it asks ("is this one due?", "has it been failing?")
|
|
48
|
+
* is answered from here, so nothing is lost across a restart.
|
|
49
|
+
*/
|
|
50
|
+
export interface Schedule {
|
|
51
|
+
/** `s-<hex>`, generated by the store like a project's id. */
|
|
52
|
+
id: string;
|
|
53
|
+
projectId: string;
|
|
54
|
+
name: string;
|
|
55
|
+
/** The mission text, sent verbatim to the director on every firing. */
|
|
56
|
+
brief: string;
|
|
57
|
+
cadence: Cadence;
|
|
58
|
+
/** Budget for one run, not for the schedule's life. */
|
|
59
|
+
budgetUsd: number;
|
|
60
|
+
directorModel?: ModelChoice;
|
|
61
|
+
workerModel?: ModelChoice;
|
|
62
|
+
directorProviderId?: string;
|
|
63
|
+
workerProviderId?: string;
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
createdAt: number;
|
|
66
|
+
lastRunId?: string;
|
|
67
|
+
lastRunAt?: number;
|
|
68
|
+
lastOutcome?: 'done' | 'error' | 'interrupted' | 'skipped';
|
|
69
|
+
/** Why the last tick did nothing, when it did nothing ("project busy"). */
|
|
70
|
+
lastNote?: string;
|
|
71
|
+
/** Next firing, ms epoch; null when disabled or nothing is scheduled. */
|
|
72
|
+
nextRunAt: number | null;
|
|
73
|
+
/** Failures in a row, reset by a success. What auto-pausing counts. */
|
|
74
|
+
consecutiveFailures: number;
|
|
75
|
+
/** Not running, and why — so the UI can say what would un-pause it. */
|
|
76
|
+
pausedReason: null | 'failures' | 'monthly-cap' | 'human';
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
/**
|
|
31
80
|
* @deprecated The pre-provider shape, still read from records written before
|
|
32
81
|
* {@link ProviderRef} existed. Never written. See providerFromLegacy().
|
|
@@ -206,6 +255,14 @@ export interface WorkerMeta {
|
|
|
206
255
|
status: WorkerStatus;
|
|
207
256
|
costUsd: number;
|
|
208
257
|
sessionId?: string;
|
|
258
|
+
/**
|
|
259
|
+
* The crew preset this worker IS, when it is a reviewer rather than an
|
|
260
|
+
* ordinary worker. Persisted — unlike the launch overrides, which hold a
|
|
261
|
+
* live credential — so a resumed run can rebuild the read-only policy, the
|
|
262
|
+
* model and the provider from the frozen crew instead of resuming a
|
|
263
|
+
* reviewer as a worker that may write.
|
|
264
|
+
*/
|
|
265
|
+
crewPresetId?: string;
|
|
209
266
|
/** First 500 chars of the task brief, for run-history display. */
|
|
210
267
|
task: string;
|
|
211
268
|
/**
|
|
@@ -274,6 +331,20 @@ export interface RunMeta {
|
|
|
274
331
|
title?: string;
|
|
275
332
|
/** Owning project; absent on runs recorded before projects existed. */
|
|
276
333
|
projectId?: string;
|
|
334
|
+
/**
|
|
335
|
+
* The schedule that started this run, when one did. Kept on the run rather
|
|
336
|
+
* than only on the schedule because a schedule has many runs and one
|
|
337
|
+
* `lastRunId`: this is what lets the history page group them.
|
|
338
|
+
*/
|
|
339
|
+
scheduleId?: string;
|
|
340
|
+
/**
|
|
341
|
+
* How the run began. Absent means 'human' — every record made before
|
|
342
|
+
* schedules existed, and the reason this is not required: an unattended run
|
|
343
|
+
* and one someone is watching deserve different treatment (timeouts,
|
|
344
|
+
* notifications), and guessing from the presence of other fields would get
|
|
345
|
+
* it wrong for the old records.
|
|
346
|
+
*/
|
|
347
|
+
startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
|
|
277
348
|
/** Model override for the director session. */
|
|
278
349
|
directorModel?: ModelChoice;
|
|
279
350
|
/** Model override for worker sessions (cost lever). */
|
|
@@ -296,6 +367,19 @@ export interface RunMeta {
|
|
|
296
367
|
provider?: ProviderRef;
|
|
297
368
|
/** @deprecated Pre-provider pin, still read for runs recorded before providers. */
|
|
298
369
|
claudeInstance?: ClaudeInstanceRef;
|
|
370
|
+
/**
|
|
371
|
+
* Crew presets frozen onto the run at dispatch: a later edit of a preset
|
|
372
|
+
* must not change a running or past run. Absent means the run was dispatched
|
|
373
|
+
* with no crew chosen — which is every run recorded before presets existed,
|
|
374
|
+
* and the reason the gate reads an absent field as "nothing required".
|
|
375
|
+
*/
|
|
376
|
+
crew?: CrewPreset[];
|
|
377
|
+
/**
|
|
378
|
+
* Review verdicts this run collected, appended in order. Kept whole rather
|
|
379
|
+
* than reduced to a pass/fail: the gate needs the diff each verdict was
|
|
380
|
+
* about, and the human reading the record afterwards needs the findings.
|
|
381
|
+
*/
|
|
382
|
+
reviews?: ReviewVerdict[];
|
|
299
383
|
/** Number of times this run was resumed after an interruption. */
|
|
300
384
|
resumes?: number;
|
|
301
385
|
/**
|
|
@@ -314,6 +398,12 @@ export interface RunMeta {
|
|
|
314
398
|
* human already answered.
|
|
315
399
|
*/
|
|
316
400
|
allowedRoots?: string[];
|
|
401
|
+
/**
|
|
402
|
+
* The subset of `allowedRoots` Foreman opened by itself rather than the
|
|
403
|
+
* human (today: a worktree's parent repository). Kept apart so it can be
|
|
404
|
+
* withdrawn when the reason for it stops holding — a human's grant never is.
|
|
405
|
+
*/
|
|
406
|
+
autoRoots?: string[];
|
|
317
407
|
/** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
|
|
318
408
|
services?: Array<{ port: number; label: string; path: string; since: number; pid?: number }>;
|
|
319
409
|
/**
|
|
@@ -463,6 +553,8 @@ export interface MissionProposal {
|
|
|
463
553
|
directorProviderId?: string;
|
|
464
554
|
workerProviderId?: string;
|
|
465
555
|
modelRationale?: string;
|
|
556
|
+
/** Preset ids the planner may suggest; the human's toggles decide. */
|
|
557
|
+
crew?: string[];
|
|
466
558
|
createdAt: number;
|
|
467
559
|
}
|
|
468
560
|
|