@amenophis1er/foreman 0.1.15 → 0.1.17

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
@@ -151,6 +151,16 @@ a human's judgement is required *and* being wrong is expensive.
151
151
  temp directory is denied on the spot and redirected to `.foreman/work/`.
152
152
  A path that is knowably wrong is denied. A path only you can judge asks —
153
153
  with a default, because "no answer" is also an answer the run must survive.
154
+ - **A worktree comes with its repository.** A mission whose folder is a git
155
+ worktree is given the repository it was made from, once, at the start — the
156
+ build config, the shared declarations and the parent's `node_modules` live
157
+ there, so without it the crew asks the same question all day and you learn
158
+ to click Allow without reading. The transcript records the grant, and
159
+ withdraws it in the open if the reason stops holding on a resume. Sibling
160
+ worktrees stay closed — and when they live *inside* the repository the
161
+ parent is not opened at all, because a grant is a subtree and would carry
162
+ them along. A parent another mission is working in stays closed too.
163
+ Turn it off per project with **Worktree parent** in Settings.
154
164
  - **Every ask has a deadline and a default.** An approval nobody answers is
155
165
  denied with a message that says where to go instead; a question nobody
156
166
  answers is handed back to the director with "decide and record". You set
@@ -161,6 +171,34 @@ a human's judgement is required *and* being wrong is expensive.
161
171
  phone with buttons. The default is what happens when you truly cannot
162
172
  answer, not what happens because you never knew.
163
173
 
174
+ ## Schedules
175
+
176
+ A schedule is a mission that starts itself. It lives in Foreman, not in the
177
+ repository: a name, a brief, a cadence — daily, weekly, every N hours, or a
178
+ five-field cron expression — and its own budget. When it fires, the mission
179
+ runs in the project's folder on its own branch and leaves a run you cannot
180
+ tell apart from one you started by hand: same transcript, same files, same
181
+ report, same place in the fleet.
182
+
183
+ Standing spend is the thing that can hurt you while you are away, so three
184
+ guards bound it, all in the server, none of them the agent's to interpret:
185
+
186
+ - **The per-run cap**, the same budget every mission has.
187
+ - **A monthly ceiling on scheduled spend, per project** —
188
+ `scheduledMonthlyCapUsd`, $25 by default. Foreman pauses the schedule
189
+ *before* the run that would cross it, rather than stopping one halfway.
190
+ - **Two failed scheduled runs in a row pause it.** A schedule that has started
191
+ failing keeps failing, and it should stop costing money until someone reads
192
+ why.
193
+
194
+ A paused schedule says which of the three paused it, and what would undo it.
195
+
196
+ Schedules are created and edited in the dashboard's project view. The phone
197
+ and `foreman mcp` list them — cadence, next run, whether they are paused and
198
+ why — and can do nothing else: resuming a paused schedule is a decision at the
199
+ desk, because a schedule is standing configuration, and Foreman grants no
200
+ standing changes from a remote surface.
201
+
164
202
  ## From your phone
165
203
 
166
204
  With the Telegram bot linked (Settings → Notifications, scan the QR):
@@ -230,7 +268,7 @@ result.
230
268
  ```sh
231
269
  npm ci && npm run setup # dependencies, then the dashboard build
232
270
  npm start # serves http://localhost:4177
233
- npm test # 379 tests, node:test
271
+ npm test # 424 tests, node:test
234
272
  npm run typecheck # server and dashboard
235
273
  npm run dev # API + Vite together
236
274
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
@@ -251,12 +289,14 @@ Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds` and
251
289
  `until`: one call that blocks until the run changes, finishes, or needs you),
252
290
  `run_report` (a finished run in one call: the director's report, DONE WHEN,
253
291
  changed files, branch and pull request), `run_transcript`, `mission_doc`,
254
- `project_memory`, `search_runs`, `doctor`, `link_project` (folder or Git URL),
292
+ `project_memory`, `list_schedules` (read-only: what starts itself and when),
293
+ `search_runs`, `doctor`, `link_project` (folder or Git URL),
255
294
  `start_mission`, `steer`. Start, wait until finished, read the report: three calls. It talks to the running server at `FOREMAN_URL`
256
295
  (default `http://localhost:4177`) and has no logic of its own.
257
296
 
258
297
  Deliberately absent: approving or denying, answering the director's questions,
259
- interrupt, resume, raising a budget, opening a pull request, settings and keys.
298
+ interrupt, resume, raising a budget, opening a pull request, settings and keys,
299
+ and any change to a schedule.
260
300
  Those are the moments Foreman exists to put a human in; `run_status` says when
261
301
  a run needs one, and with what, so the agent's job is to send you to decide.
262
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",
@@ -1,6 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { FLEET_CHAT_ID, PHONE_CONTEXT_MS, fleetSummary, phoneRoute, situation } from './fleet-planner.js';
3
+ import { FLEET_CHAT_ID, PHONE_CONTEXT_MS, fleetSummary, phoneRoute, scheduleSummary, scheduleTools, situation, type FleetHost, type FleetScheduleView } from './fleet-planner.js';
4
+ import type { Schedule } from './types.js';
4
5
 
5
6
  test('plain phone text goes to the fleet planner when no project conversation is open', () => {
6
7
  assert.equal(phoneRoute(null), 'fleet');
@@ -41,6 +42,43 @@ test('fleetSummary says what is running, what is waiting, and what finished last
41
42
  assert.equal(fleetSummary([]), 'No projects are linked yet.');
42
43
  });
43
44
 
45
+ const schedule = (o: Partial<Schedule> = {}): Schedule => ({
46
+ id: 's1', projectId: 'a', name: 'nightly deps', brief: 'Update the dependencies',
47
+ cadence: { kind: 'daily', at: '07:30' }, budgetUsd: 3, enabled: true, createdAt: 0,
48
+ nextRunAt: null, consecutiveFailures: 0, pausedReason: null, ...o,
49
+ });
50
+
51
+ test('scheduleSummary says the cadence in words, the next run both ways, and why one is paused', () => {
52
+ const now = 1_700_000_000_000;
53
+ const views: FleetScheduleView[] = [
54
+ { projectName: 'P5', monthSpendUsd: 4.2, monthlyCapUsd: 25, schedule: schedule({ nextRunAt: now + 15 * 3_600_000, lastOutcome: 'done', lastRunId: 'r9', lastRunAt: now - 9 * 3_600_000 }) },
55
+ { projectName: 'P7', schedule: schedule({ id: 's2', name: 'audit', cadence: { kind: 'interval', everyMinutes: 360 }, budgetUsd: 2, enabled: false, pausedReason: 'failures', consecutiveFailures: 2 }) },
56
+ { projectName: 'P7', schedule: schedule({ id: 's3', name: 'weekly report', cadence: { kind: 'weekly', day: 1, at: '09:00' }, enabled: false, pausedReason: 'monthly-cap' }) },
57
+ ];
58
+ const text = scheduleSummary(views, now);
59
+ assert.match(text, /- P5: "nightly deps" · daily 07:30 · next .* \(in 15 h\)\n enabled · \$3\.00 per run · last done \(r9\) 9 h ago\n scheduled spend this month: \$4\.20 of \$25\.00/);
60
+ assert.match(text, /- P7: "audit" · every 6 hours · no next run while paused\n paused after 2 failed scheduled runs in a row · \$2\.00 per run · never run yet/);
61
+ assert.match(text, /- P7: "weekly report" · every Monday 09:00 .*\n paused at the project's monthly cap for scheduled spend/);
62
+ assert.match(text, /read-only from here: created, edited, paused and resumed on the dashboard/);
63
+ assert.match(scheduleSummary([]), /No schedules\. They are created on the dashboard/);
64
+ });
65
+
66
+ test('the front desk reads schedules and has no tool that changes one', async () => {
67
+ const asked: Array<string | undefined> = [];
68
+ const host = {
69
+ listSchedules: async (ref?: string) => { asked.push(ref); return [{ projectName: 'P5', schedule: schedule({ nextRunAt: Date.now() + 3_600_000 }) }]; },
70
+ } as unknown as FleetHost;
71
+ const tools = scheduleTools(host);
72
+ assert.deepEqual(tools.map((t) => t.name), ['list_schedules']);
73
+ assert.match(tools[0].description, /Read-only.*dashboard/s);
74
+ const out = await tools[0].handler({ project: 'P5' } as never, undefined);
75
+ assert.deepEqual(asked, ['P5']);
76
+ assert.match(String((out.content as Array<{ text: string }>)[0].text), /"nightly deps" · daily 07:30/);
77
+ // A host from before schedules existed says so instead of guessing.
78
+ const bare = await scheduleTools({} as FleetHost)[0].handler({} as never, undefined);
79
+ assert.match(String((bare.content as Array<{ text: string }>)[0].text), /cannot list schedules/);
80
+ });
81
+
44
82
  test('situation carries the clock, the channel, and the news since the last message', () => {
45
83
  const now = new Date(2026, 8, 6, 14, 5);
46
84
  const s = situation({ via: 'telegram', sinceMs: 12 * 60_000, news: ['13:58 P7 — mission ended: done ($0.78)'] }, now);
@@ -14,8 +14,9 @@
14
14
  * - **No resident process.** A message resumes a stored session, runs one
15
15
  * turn, exits. Continuity is the session id on disk.
16
16
  * - **Read-only, always.** Its tools are the fleet's verbs — list, inspect,
17
- * create or link a project, open a planning conversation, propose, steer.
18
- * No shell, no file access, no starting missions.
17
+ * create or link a project, open a planning conversation, propose, steer,
18
+ * read the schedules. No shell, no file access, no starting missions — and
19
+ * no change to a schedule, which is standing configuration.
19
20
  * - **It never answers for the human.** Open approvals and questions are
20
21
  * described, not resolved. The buttons on the card are the human's, and an
21
22
  * agent that presses them is a hole through `canUseTool`.
@@ -27,7 +28,8 @@ import {
27
28
  } from '@anthropic-ai/claude-agent-sdk';
28
29
  import type { AgentEnv } from './provider.js';
29
30
  import { modelsSection, needsBrowser, pickKnownModel, type PlannerModel } from './planner.js';
30
- import type { MissionProposal } from './types.js';
31
+ import { describeCadence } from './schedule.js';
32
+ import type { MissionProposal, Schedule } from './types.js';
31
33
 
32
34
  /** Where the fleet conversation is stored, beside the project chats. The underscore keeps it out of project listings. */
33
35
  export const FLEET_CHAT_ID = '_fleet';
@@ -85,6 +87,9 @@ const ago = (ms: number): string => {
85
87
  return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
86
88
  };
87
89
 
90
+ /** The same distance, forwards: "in 15 h". */
91
+ const ahead = (ms: number): string => (ms < 60_000 ? 'in under a minute' : `in ${ago(ms).replace(/ ago$/, '')}`);
92
+
88
93
  /** The fleet in plain lines, as the list_projects tool returns it. */
89
94
  export function fleetSummary(views: FleetProjectView[], now = Date.now()): string {
90
95
  if (!views.length) return 'No projects are linked yet.';
@@ -104,6 +109,68 @@ export function fleetSummary(views: FleetProjectView[], now = Date.now()): strin
104
109
  }).join('\n');
105
110
  }
106
111
 
112
+ /** One schedule as the front desk sees it: the record, plus whose it is. */
113
+ export interface FleetScheduleView {
114
+ projectName: string;
115
+ schedule: Schedule;
116
+ /** Scheduled spend this month against the project's ceiling, when the host knows it. */
117
+ monthSpendUsd?: number;
118
+ monthlyCapUsd?: number;
119
+ }
120
+
121
+ /** Why a schedule is not going to fire, in the words that say what would undo it. */
122
+ function pausedPhrase(s: Schedule): string {
123
+ switch (s.pausedReason) {
124
+ case 'failures': return `paused after ${s.consecutiveFailures || 2} failed scheduled runs in a row`;
125
+ case 'monthly-cap': return 'paused at the project\'s monthly cap for scheduled spend';
126
+ case 'human': return 'paused by hand';
127
+ default: return s.enabled ? 'enabled' : 'disabled';
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The schedules in plain lines, as the list_schedules tool returns them. The
133
+ * next run is said absolutely and relatively both: a schedule is a wall-clock
134
+ * promise, and "in 15 h" is what the person on the phone actually asked.
135
+ */
136
+ export function scheduleSummary(views: FleetScheduleView[], now = Date.now()): string {
137
+ if (!views.length) return 'No schedules. They are created on the dashboard, in a project\'s view.';
138
+ const lines = views.map((v) => {
139
+ const s = v.schedule;
140
+ const next = s.pausedReason || !s.enabled ? 'no next run while paused'
141
+ : s.nextRunAt ? `next ${new Date(s.nextRunAt).toLocaleString()} (${ahead(s.nextRunAt - now)})`
142
+ : 'next never — this cadence has no future firing';
143
+ const last = s.lastOutcome
144
+ ? `last ${s.lastOutcome}${s.lastRunId ? ` (${s.lastRunId})` : ''}${s.lastRunAt ? ` ${ago(now - s.lastRunAt)}` : ''}`
145
+ : 'never run yet';
146
+ const month = typeof v.monthSpendUsd === 'number' && typeof v.monthlyCapUsd === 'number'
147
+ ? `\n scheduled spend this month: $${v.monthSpendUsd.toFixed(2)} of $${v.monthlyCapUsd.toFixed(2)}` : '';
148
+ return `- ${v.projectName}: "${s.name}" · ${describeCadence(s.cadence)} · ${next}\n ${pausedPhrase(s)} · $${s.budgetUsd.toFixed(2)} per run · ${last}${month}`;
149
+ });
150
+ lines.push('Schedules are read-only from here: created, edited, paused and resumed on the dashboard.');
151
+ return lines.join('\n');
152
+ }
153
+
154
+ /**
155
+ * The front desk's one schedule verb. Listing only, and there is no sibling:
156
+ * a schedule is standing configuration, and the phone never grants standing
157
+ * changes — the same rule that keeps "always allow" off the buttons.
158
+ */
159
+ export function scheduleTools(host: FleetHost) {
160
+ return [
161
+ tool('list_schedules', 'The standing schedules — missions that start themselves on a cadence — for the whole fleet or one project: the cadence in words, the next run, enabled or paused and why, the per-run cap, and how the last firing ended. Read-only, and the only schedule tool: creating, editing, pausing, resuming or running one now is done on the dashboard, never from here.',
162
+ { project: z.string().optional().describe('Project name, id, or folder name; omit for the whole fleet') },
163
+ async ({ project }) => {
164
+ if (!host.listSchedules) return text('This Foreman cannot list schedules.');
165
+ try {
166
+ return text(scheduleSummary(await host.listSchedules(project)));
167
+ } catch (err) {
168
+ return text(`That failed: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ }),
171
+ ];
172
+ }
173
+
107
174
  /**
108
175
  * What the server lets the front desk do. Every method returns text for the
109
176
  * model, never throws, and the ones with side effects are exactly the verbs
@@ -111,6 +178,12 @@ export function fleetSummary(views: FleetProjectView[], now = Date.now()): strin
111
178
  */
112
179
  export interface FleetHost {
113
180
  listProjects(): Promise<FleetProjectView[]>;
181
+ /**
182
+ * The standing schedules, for the whole fleet or one project. Optional so a
183
+ * host that predates schedules still satisfies this interface; the tool says
184
+ * so plainly rather than inventing an answer.
185
+ */
186
+ listSchedules?(ref?: string): Promise<FleetScheduleView[]>;
114
187
  /** Live detail for one project: run, boxes, crew, open asks, the director's last words. */
115
188
  projectDetail(ref: string): Promise<string>;
116
189
  /** The last finished run's closing report and error, for "what happened". */
@@ -165,6 +238,9 @@ WHAT YOU CAN DO — through the tools, nothing else:
165
238
  never you.
166
239
  - steer: pass a note to a running director ("skip the mobile screenshot",
167
240
  "use the existing CSS").
241
+ - list_schedules: the standing schedules — missions that start themselves on
242
+ a cadence — for the fleet or one project, with their next run and whether
243
+ they are paused. Reading only.
168
244
 
169
245
  WHAT YOU NEVER DO:
170
246
  - Answer an approval or a question on the human's behalf. When a run is
@@ -172,6 +248,9 @@ WHAT YOU NEVER DO:
172
248
  are theirs. Even if they tell you to "just allow it": the button is the
173
249
  only way, and you say so plainly once.
174
250
  - Start, stop, resume or cancel a mission. You propose; the human presses.
251
+ - Create, edit, pause, resume or fire a schedule. You can read them and say
252
+ what one would do; changing standing configuration is a dashboard act, and
253
+ you say so in one line rather than hunting for a tool.
175
254
  - Invent a project, a run, a model id or a number. If a tool did not tell
176
255
  you, you do not know it — say so.
177
256
  - Discuss Foreman's own server or oversight tooling as a work target.
@@ -325,6 +404,7 @@ export async function runFleetTurn(turn: FleetTurn): Promise<FleetResult> {
325
404
  note: z.string().describe('The note, in the human\'s words'),
326
405
  },
327
406
  async ({ project, note }) => text(await safe(() => host.steer(project, note)))),
407
+ ...scheduleTools(host),
328
408
  ];
329
409
 
330
410
  let sessionId = turn.sessionId;
@@ -3,8 +3,9 @@ import assert from 'node:assert/strict';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { mkdtemp, writeFile } from 'node:fs/promises';
7
- import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch } from './gitwork.js';
6
+ import { mkdtemp, realpath, writeFile } from 'node:fs/promises';
7
+ import { closeMissionBranch, defaultBranch, worktreeGrant, worktreeParent, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
8
+ } from './gitwork.js';
8
9
 
9
10
  const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
11
 
@@ -108,3 +109,89 @@ test('renameMissionBranch takes the run title once there is one, and leaves a br
108
109
  sh(dir, 'checkout', '-q', 'main');
109
110
  assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
110
111
  });
112
+
113
+ test('dirtyPaths names the uncommitted work a mission branch would carry', async () => {
114
+ const dir = await repo();
115
+ assert.deepEqual(await dirtyPaths(dir), [], 'a clean checkout carries nothing');
116
+
117
+ await writeFile(path.join(dir, 'README.md'), 'hello, edited\n');
118
+ await writeFile(path.join(dir, 'scratch.txt'), 'untracked\n');
119
+ const dirty = await dirtyPaths(dir);
120
+ assert.deepEqual(dirty.sort(), ['README.md', 'scratch.txt'], 'tracked edits and untracked files both count');
121
+
122
+ // The cap is for a message, not for the truth of it.
123
+ assert.equal((await dirtyPaths(dir, 1)).length, 1);
124
+ // Not a repository at all: nothing to report, and no throw.
125
+ assert.deepEqual(await dirtyPaths(os.tmpdir()), []);
126
+ });
127
+
128
+ test('a pull request targets the default branch when the mission was branched from a local-only branch', async () => {
129
+ const dir = await repo();
130
+ // A bare origin, the way a real clone has one.
131
+ const origin = await mkdtemp(path.join(os.tmpdir(), 'gitwork-origin-'));
132
+ execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { stdio: 'pipe' });
133
+ sh(dir, 'remote', 'add', 'origin', origin);
134
+ sh(dir, 'push', '-q', '-u', 'origin', 'main');
135
+
136
+ assert.equal(await defaultBranch(dir), 'main');
137
+ assert.equal(await remoteHasBranch(dir, 'main'), true);
138
+ assert.equal(await remoteHasBranch(dir, 'foreman/earlier-1234'), false);
139
+
140
+ // Branched from main: the base is real and is left alone.
141
+ assert.deepEqual(await resolvePrBase(dir, 'main'), { base: 'main', fellBack: false });
142
+
143
+ // The case from the field: the checkout was left on the previous mission's
144
+ // branch, so this mission recorded that as its base and it exists nowhere
145
+ // but here. gh would fail on it; the default branch is the honest target.
146
+ sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
147
+ assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
148
+ });
149
+
150
+ test('worktreeParent: a linked worktree knows its repository, and nothing else claims one', async () => {
151
+ const dir = await repo();
152
+ assert.equal(await worktreeParent(dir), null, 'the main worktree has no parent');
153
+ assert.equal(await worktreeParent(os.tmpdir()), null, 'a plain directory is not a worktree');
154
+
155
+ const wt = path.join(await mkdtemp(path.join(os.tmpdir(), 'gitwork-wt-')), 'feature');
156
+ sh(dir, 'worktree', 'add', '-q', '-b', 'feature', wt);
157
+ const shape = await worktreeParent(wt);
158
+ assert.equal(shape && await realpath(shape.parent), await realpath(dir), 'the linked worktree points back at the repository');
159
+ assert.deepEqual(shape?.siblings, [], 'and it is the only linked worktree');
160
+ assert.equal(await worktreeParent(dir), null, 'and the main worktree still has none');
161
+ });
162
+
163
+ test('worktreeGrant opens the parent unless a live run is working in it', () => {
164
+ const shape = (siblings: string[] = []) => ({ parent: '/repos/app', siblings });
165
+ assert.deepEqual(worktreeGrant(shape(), []), { grant: '/repos/app' });
166
+ assert.deepEqual(worktreeGrant(shape(['/elsewhere/wt-a']), ['/repos/other']), { grant: '/repos/app' },
167
+ 'a sibling outside the parent is not opened by opening the parent');
168
+ // Two crews in one checkout is the thing the dirty-checkout guard exists to
169
+ // prevent; opening the parent into a live mission would arrange it.
170
+ assert.deepEqual(worktreeGrant(shape(), ['/repos/app']), {
171
+ grant: null, reason: 'its parent repository /repos/app is held by another running mission',
172
+ });
173
+ // A grant is a subtree: worktrees kept inside the repository would ride
174
+ // along with it, so the parent is not opened at all.
175
+ const nested = worktreeGrant(shape(['/repos/app/.worktrees/a', '/repos/app/.worktrees/b']), []);
176
+ assert.equal(nested.grant, null);
177
+ assert.match(nested.reason ?? '', /would also open 2 other worktrees inside it/);
178
+ assert.deepEqual(worktreeGrant(null, []), { grant: null }, 'not a worktree: nothing to say');
179
+ });
180
+
181
+ test('a worktree kept inside the repository is not opened by opening the repository', async () => {
182
+ const dir = await repo();
183
+ // The common layout codex flagged: linked worktrees under the main checkout.
184
+ const inside = path.join(dir, '.worktrees', 'a');
185
+ sh(dir, 'worktree', 'add', '-q', '-b', 'inside-a', inside);
186
+ const other = path.join(dir, '.worktrees', 'b');
187
+ sh(dir, 'worktree', 'add', '-q', '-b', 'inside-b', other);
188
+
189
+ const shape = await worktreeParent(inside);
190
+ assert.ok(shape, 'it is a linked worktree');
191
+ assert.equal(await realpath(shape.parent), await realpath(dir));
192
+ assert.ok(shape.siblings.some((s) => s.endsWith(path.join('.worktrees', 'b'))), 'and it can see its sibling');
193
+
194
+ const decision = worktreeGrant(shape, []);
195
+ assert.equal(decision.grant, null, 'so the parent is not opened automatically');
196
+ assert.match(decision.reason ?? '', /would also open/);
197
+ });
package/src/gitwork.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * commits on it. It never merges, and it pushes only when the human presses
9
9
  * the button that says so — once, for that branch, to open the pull request.
10
10
  */
11
+ import path from 'node:path';
11
12
  import { execFile } from 'node:child_process';
12
13
 
13
14
  export interface GitInfo {
@@ -85,6 +86,89 @@ export function missionBranchName(mission: string, runId: string): string {
85
86
  * Uncommitted changes come along, as `checkout -b` always does. Resolves to
86
87
  * the record for the run, or to one sentence on why it could not.
87
88
  */
89
+ /**
90
+ * The paths with uncommitted changes, tracked or untracked, capped for a
91
+ * message. `checkout -b` carries all of them onto the mission's branch, and
92
+ * the closing commit sweeps whatever is still uncommitted into the mission's
93
+ * own commit — so this is what a human stands to have committed under a
94
+ * mission's name without noticing.
95
+ */
96
+ /**
97
+ * When this folder is a linked git worktree, the repository it was made from:
98
+ * the main worktree's root. Null when the folder is that main worktree, is
99
+ * not a repository, or git is too old to say.
100
+ *
101
+ * Worktrees are the reason a mission asks the human the same question all
102
+ * day. A worktree holds a branch's files but not the repository's shared
103
+ * scaffolding — the build config, the type declarations, the parent package's
104
+ * node_modules — so a crew working in one steps up to the parent constantly,
105
+ * and every step is a boundary crossing.
106
+ */
107
+ export interface WorktreeShape {
108
+ /** The main worktree's root: the repository this folder was made from. */
109
+ parent: string;
110
+ /** Every other worktree of the same repository, this folder excluded. */
111
+ siblings: string[];
112
+ }
113
+
114
+ export async function worktreeParent(folder: string): Promise<WorktreeShape | null> {
115
+ try {
116
+ const out = await git(['worktree', 'list', '--porcelain'], folder);
117
+ // The first entry is always the main worktree; the rest are the linked ones.
118
+ const roots = out.split('\n').filter((l) => l.startsWith('worktree '))
119
+ .map((l) => path.resolve(l.slice('worktree '.length).trim()));
120
+ const main = roots[0];
121
+ if (!main || roots.length < 2) return null;
122
+ const here = (await git(['rev-parse', '--show-toplevel'], folder)).trim();
123
+ if (!here || path.resolve(here) === main) return null;
124
+ return { parent: main, siblings: roots.slice(1).filter((r) => r !== path.resolve(here)) };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Should this run be given its parent repository, and why not when not.
132
+ * Pure so the rule is testable: the parent is opened unless another live run
133
+ * is working in it, because two crews in one checkout is the situation the
134
+ * dirty-checkout guard exists to prevent.
135
+ */
136
+ export function worktreeGrant(
137
+ shape: WorktreeShape | null,
138
+ busyFolders: Iterable<string>,
139
+ ): { grant: string | null; reason?: string } {
140
+ if (!shape) return { grant: null };
141
+ const { parent, siblings } = shape;
142
+ for (const f of busyFolders) {
143
+ if (path.resolve(f) === parent) {
144
+ return { grant: null, reason: `its parent repository ${parent} is held by another running mission` };
145
+ }
146
+ }
147
+ // A grant is a subtree, so a worktree that lives *inside* the repository
148
+ // (the common `/repo/.worktrees/x` layout) would be opened along with the
149
+ // parent — and one of those may be another mission's workspace. The promise
150
+ // that siblings stay closed cannot be kept by granting the parent here, so
151
+ // the grant is declined and the human keeps deciding, one command at a time.
152
+ const nested = siblings.filter((s) => s === parent || s.startsWith(parent + path.sep));
153
+ if (nested.length) {
154
+ return {
155
+ grant: null,
156
+ reason: `opening ${parent} would also open ${nested.length} other worktree${nested.length === 1 ? '' : 's'} inside it `
157
+ + `(${nested.slice(0, 2).map((s) => path.basename(s)).join(', ')}${nested.length > 2 ? ', …' : ''})`,
158
+ };
159
+ }
160
+ return { grant: parent };
161
+ }
162
+
163
+ export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
164
+ try {
165
+ const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
166
+ return out.split('\n').map((l) => l.slice(3).trim()).filter(Boolean).slice(0, limit);
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
88
172
  export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
89
173
  const info = await gitInfo(folder);
90
174
  if (!info.repo) return { error: 'not a git repository' };
@@ -245,6 +329,56 @@ export function pullRequestState(folder: string, url: string): Promise<{ state:
245
329
  });
246
330
  }
247
331
 
332
+ /**
333
+ * The repository's default branch as origin sees it, from the remote HEAD git
334
+ * recorded at clone time, then from the usual names, and `main` as the last
335
+ * word. Local only: no network, so it works offline and cannot hang.
336
+ */
337
+ export async function defaultBranch(folder: string): Promise<string> {
338
+ try {
339
+ const ref = (await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], folder)).trim();
340
+ const name = ref.replace(/^origin\//, '');
341
+ if (name) return name;
342
+ } catch { /* no remote HEAD recorded */ }
343
+ for (const name of ['main', 'master']) {
344
+ try {
345
+ await git(['rev-parse', '--verify', `refs/remotes/origin/${name}`], folder);
346
+ return name;
347
+ } catch { /* not this one */ }
348
+ }
349
+ return 'main';
350
+ }
351
+
352
+ /** Does origin have this branch? Asks the remote, and says no if it cannot ask. */
353
+ export async function remoteHasBranch(folder: string, branch: string): Promise<boolean> {
354
+ try {
355
+ const out = await git(['ls-remote', '--heads', 'origin', branch], folder, 10_000);
356
+ return out.trim().length > 0;
357
+ } catch {
358
+ // Offline or unauthenticated: fall back to what the last fetch recorded.
359
+ try {
360
+ await git(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], folder);
361
+ return true;
362
+ } catch { return false; }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * What a pull request from this mission should actually target.
368
+ *
369
+ * A finished mission leaves the checkout on its own branch, so the next
370
+ * mission is branched from *that* — and records it as its base. That base
371
+ * lives only on this machine, so `gh pr create --base foreman/…` fails and a
372
+ * compare URL built from it 404s. When the recorded base is not on the
373
+ * remote, the default branch is the honest target, and the caller says so
374
+ * rather than quietly retargeting the request.
375
+ */
376
+ export async function resolvePrBase(folder: string, recorded: string): Promise<{ base: string; fellBack: boolean }> {
377
+ if (recorded && await remoteHasBranch(folder, recorded)) return { base: recorded, fellBack: false };
378
+ const base = await defaultBranch(folder);
379
+ return { base, fellBack: base !== recorded };
380
+ }
381
+
248
382
  /** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
249
383
  export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
250
384
  return new Promise((resolve) => {
package/src/mcp.test.ts CHANGED
@@ -164,7 +164,44 @@ test('the tool set has no human-only actions', () => {
164
164
  for (const forbidden of ['approve', 'deny', 'permission', 'answer', 'interrupt', 'resume', 'budget', 'pull_request', 'open_pr', 'settings', 'key']) {
165
165
  assert.ok(!names.some((n) => n.split('_').includes(forbidden) || n === forbidden), `${forbidden} must not be a tool`);
166
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']);
167
+ assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_report', 'run_transcript', 'mission_doc', 'project_memory', 'list_schedules', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
168
+ });
169
+
170
+ test('list_schedules says the cadence in words, the next run both ways, and why one is paused', async () => {
171
+ const now = Date.now();
172
+ const { fetchImpl } = fakeServer({
173
+ 'GET /projects': { projects: [
174
+ { id: 'p1', name: 'app', folder: '/x/app', activeRun: null, lastRun: null, pendingPermissions: 0, pendingQuestions: 0 },
175
+ { id: 'p2', name: 'lib', folder: '/x/lib', activeRun: null, lastRun: null, pendingPermissions: 0, pendingQuestions: 0 },
176
+ ] },
177
+ 'GET /projects/p1/schedules': { monthSpendUsd: 4.2, monthlyCapUsd: 25, schedules: [
178
+ { id: 's1', name: 'nightly deps', cadence: { kind: 'daily', at: '07:30' }, budgetUsd: 3, enabled: true, nextRunAt: now + 15 * 3_600_000, pausedReason: null, consecutiveFailures: 0, lastOutcome: 'done', lastRunId: 'r9', lastRunAt: now - 9 * 3_600_000 },
179
+ { id: 's2', name: 'weekly audit', cadence: { kind: 'interval', everyMinutes: 360 }, budgetUsd: 2, enabled: false, nextRunAt: null, pausedReason: 'failures', consecutiveFailures: 2 },
180
+ ] },
181
+ 'GET /projects/p2/schedules': { monthSpendUsd: 0, monthlyCapUsd: 25, schedules: [] },
182
+ });
183
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'list_schedules').run({});
184
+ assert.match(r.text, /app \(p1\) — 2 schedules · scheduled this month \$4\.20 of \$25\.00/);
185
+ assert.match(r.text, /nightly deps · daily 07:30 · next .* \(in 15 h\) · enabled · \$3\.00 per run · last done \(r9\) 9 h ago/);
186
+ assert.match(r.text, /weekly audit · every 6 hours · no next run while paused · paused after 2 failed scheduled runs in a row/);
187
+ assert.ok(!r.text.includes('lib'), 'a project with no schedules is not listed when the whole fleet was asked');
188
+ assert.match(r.text, /created, edited, paused or resumed on the dashboard/);
189
+ });
190
+
191
+ test('list_schedules takes a project by id or name, and no tool changes a schedule', async () => {
192
+ const { fetchImpl, calls } = fakeServer({
193
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, lastRun: null, pendingPermissions: 0, pendingQuestions: 0 }] },
194
+ 'GET /projects/p1/schedules': { schedules: [] },
195
+ });
196
+ const tools = foremanTools({ base: 'http://f', fetchImpl });
197
+ assert.match((await tool(tools, 'list_schedules').run({ projectId: 'App' })).text, /app \(p1\) — no schedules/);
198
+ assert.match((await tool(tools, 'list_schedules').run({ projectId: 'nope' })).text, /No project nope\./);
199
+ assert.ok(!calls.some((c) => c.method !== 'GET'), 'listing schedules only reads');
200
+ const names = tools.map((t) => t.name);
201
+ for (const forbidden of ['create_schedule', 'edit_schedule', 'update_schedule', 'pause_schedule', 'resume_schedule', 'run_schedule', 'run_schedule_now', 'delete_schedule']) {
202
+ assert.ok(!names.includes(forbidden), `${forbidden} must not be a tool`);
203
+ }
204
+ assert.match(tool(tools, 'list_schedules').description, /dashboard/);
168
205
  });
169
206
 
170
207
  test('a server that is not there is said in one sentence with the start command', async () => {