@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.
@@ -3,9 +3,10 @@ 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, defaultBranch, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
6
+ import { mkdir, mkdtemp, realpath, symlink, unlink, writeFile } from 'node:fs/promises';
7
+ import { changeFingerprint, closeMissionBranch, defaultBranch, worktreeGrant, worktreeParent, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
8
8
  } from './gitwork.js';
9
+ import type { ReviewVerdict } from './crew.js';
9
10
 
10
11
  const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
11
12
 
@@ -93,6 +94,26 @@ test('prDraft: the run title, the brief, the boxes as the mission left them, and
93
94
  assert.match(d.body, /## Mission\n\nAdd a footer to the page\.\nKeep it small\./);
94
95
  assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
95
96
  assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
97
+ assert.ok(!/## Review/.test(d.body), 'a run nobody reviewed says nothing about review');
98
+ });
99
+
100
+ test('prDraft: the reviewers and their verdicts, with the head of the findings', async () => {
101
+ const { prDraft } = await import('./gitwork.js');
102
+ const verdict = (over: Partial<ReviewVerdict>): ReviewVerdict => ({
103
+ presetId: 'reviewer', name: 'Reviewer', pass: true, findings: '', diffHash: 'h', workerId: 'w1', at: 1, ...over,
104
+ });
105
+ const d = prDraft(
106
+ { mission: 'Add a footer.', costUsd: 1, costBasis: 'priced', git: { branch: 'b', base: 'main', baseHead: null } },
107
+ null,
108
+ [
109
+ verdict({ findings: '- footer.html:12 the year is hard-coded' }),
110
+ verdict({ presetId: 'security-review', name: 'Security review', pass: false, findings: `x${'y'.repeat(2000)}` }),
111
+ ],
112
+ );
113
+ assert.match(d.body, /## Review\n\n\*\*Reviewer: PASS\*\*\n\n- footer\.html:12 the year is hard-coded/);
114
+ assert.match(d.body, /\*\*Security review: FAIL\*\*/);
115
+ assert.match(d.body, /… the rest is in the run's record\./, 'a long findings list is cut, not pasted whole');
116
+ assert.ok(d.body.length < 2000, 'the body stays a pull request, not an archive');
96
117
  });
97
118
 
98
119
 
@@ -146,3 +167,113 @@ test('a pull request targets the default branch when the mission was branched fr
146
167
  sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
147
168
  assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
148
169
  });
170
+
171
+ test('worktreeParent: a linked worktree knows its repository, and nothing else claims one', async () => {
172
+ const dir = await repo();
173
+ assert.equal(await worktreeParent(dir), null, 'the main worktree has no parent');
174
+ assert.equal(await worktreeParent(os.tmpdir()), null, 'a plain directory is not a worktree');
175
+
176
+ const wt = path.join(await mkdtemp(path.join(os.tmpdir(), 'gitwork-wt-')), 'feature');
177
+ sh(dir, 'worktree', 'add', '-q', '-b', 'feature', wt);
178
+ const shape = await worktreeParent(wt);
179
+ assert.equal(shape && await realpath(shape.parent), await realpath(dir), 'the linked worktree points back at the repository');
180
+ assert.deepEqual(shape?.siblings, [], 'and it is the only linked worktree');
181
+ assert.equal(await worktreeParent(dir), null, 'and the main worktree still has none');
182
+ });
183
+
184
+ test('worktreeGrant opens the parent unless a live run is working in it', () => {
185
+ const shape = (siblings: string[] = []) => ({ parent: '/repos/app', siblings });
186
+ assert.deepEqual(worktreeGrant(shape(), []), { grant: '/repos/app' });
187
+ assert.deepEqual(worktreeGrant(shape(['/elsewhere/wt-a']), ['/repos/other']), { grant: '/repos/app' },
188
+ 'a sibling outside the parent is not opened by opening the parent');
189
+ // Two crews in one checkout is the thing the dirty-checkout guard exists to
190
+ // prevent; opening the parent into a live mission would arrange it.
191
+ assert.deepEqual(worktreeGrant(shape(), ['/repos/app']), {
192
+ grant: null, reason: 'its parent repository /repos/app is held by another running mission',
193
+ });
194
+ // A grant is a subtree: worktrees kept inside the repository would ride
195
+ // along with it, so the parent is not opened at all.
196
+ const nested = worktreeGrant(shape(['/repos/app/.worktrees/a', '/repos/app/.worktrees/b']), []);
197
+ assert.equal(nested.grant, null);
198
+ assert.match(nested.reason ?? '', /would also open 2 other worktrees inside it/);
199
+ assert.deepEqual(worktreeGrant(null, []), { grant: null }, 'not a worktree: nothing to say');
200
+ });
201
+
202
+ test('a worktree kept inside the repository is not opened by opening the repository', async () => {
203
+ const dir = await repo();
204
+ // The common layout codex flagged: linked worktrees under the main checkout.
205
+ const inside = path.join(dir, '.worktrees', 'a');
206
+ sh(dir, 'worktree', 'add', '-q', '-b', 'inside-a', inside);
207
+ const other = path.join(dir, '.worktrees', 'b');
208
+ sh(dir, 'worktree', 'add', '-q', '-b', 'inside-b', other);
209
+
210
+ const shape = await worktreeParent(inside);
211
+ assert.ok(shape, 'it is a linked worktree');
212
+ assert.equal(await realpath(shape.parent), await realpath(dir));
213
+ assert.ok(shape.siblings.some((s) => s.endsWith(path.join('.worktrees', 'b'))), 'and it can see its sibling');
214
+
215
+ const decision = worktreeGrant(shape, []);
216
+ assert.equal(decision.grant, null, 'so the parent is not opened automatically');
217
+ assert.match(decision.reason ?? '', /would also open/);
218
+ });
219
+
220
+ test('the fingerprint copes with awkward filenames and with a repository that has no commit', async () => {
221
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'fingerprint-git-'));
222
+ sh(dir, 'init', '-q', '-b', 'main');
223
+ sh(dir, 'config', 'user.email', 'me@example.com');
224
+ sh(dir, 'config', 'user.name', 'Me');
225
+
226
+ // No commit yet: `git diff HEAD` has nothing to diff against, and a run that
227
+ // starts a repository from nothing is an ordinary mission.
228
+ await writeFile(path.join(dir, 'first.txt'), 'work\n');
229
+ const empty = await changeFingerprint(dir);
230
+ assert.ok(empty, 'a repository with no HEAD still has a fingerprint');
231
+
232
+ // A name git C-quotes in `ls-files`: a quoted path handed to hash-object
233
+ // fails, which used to silently drop the file's content from the hash.
234
+ const awkward = path.join(dir, 'répertoire "odd" name.txt');
235
+ await writeFile(awkward, 'one\n');
236
+ const withAwkward = await changeFingerprint(dir);
237
+ assert.ok(withAwkward);
238
+ assert.notEqual(withAwkward, empty);
239
+
240
+ await writeFile(awkward, 'two\n');
241
+ assert.notEqual(await changeFingerprint(dir), withAwkward, 'editing it moves the fingerprint');
242
+
243
+ // And once there is a commit, the same file is tracked and still counts.
244
+ sh(dir, 'add', '-A'); sh(dir, 'commit', '-q', '-m', 'one');
245
+ const committed = await changeFingerprint(dir);
246
+ await writeFile(awkward, 'three\n');
247
+ assert.notEqual(await changeFingerprint(dir), committed);
248
+ });
249
+
250
+ test('the fingerprint is scoped to the project folder, and sees a retargeted symlink', async () => {
251
+ // A project that is a subdirectory of a bigger repository: a sibling's
252
+ // changes are not this mission's, and must not invalidate its review.
253
+ const repoRoot = await repo();
254
+ const project = path.join(repoRoot, 'packages', 'app');
255
+ await mkdir(project, { recursive: true });
256
+ await writeFile(path.join(project, 'index.ts'), 'export const a = 1;\n');
257
+ await mkdir(path.join(repoRoot, 'packages', 'other'), { recursive: true });
258
+ await writeFile(path.join(repoRoot, 'packages', 'other', 'index.ts'), 'export const b = 1;\n');
259
+ sh(repoRoot, 'add', '-A'); sh(repoRoot, 'commit', '-q', '-m', 'two packages');
260
+
261
+ const reviewed = await changeFingerprint(project);
262
+ assert.ok(reviewed);
263
+ await writeFile(path.join(repoRoot, 'packages', 'other', 'index.ts'), 'export const b = 2;\n');
264
+ assert.equal(await changeFingerprint(project), reviewed, 'a sibling package is not this mission');
265
+ await writeFile(path.join(project, 'index.ts'), 'export const a = 2;\n');
266
+ assert.notEqual(await changeFingerprint(project), reviewed, 'its own change still counts');
267
+
268
+ // Non-git folder: a symlink is neither file nor directory, and retargeting
269
+ // one changes the project without changing any content.
270
+ const plain = await mkdtemp(path.join(os.tmpdir(), 'links-'));
271
+ await writeFile(path.join(plain, 'one.txt'), 'one\n');
272
+ await writeFile(path.join(plain, 'two.txt'), 'two\n');
273
+ await symlink('one.txt', path.join(plain, 'current'));
274
+ const linked = await changeFingerprint(plain);
275
+ assert.ok(linked);
276
+ await unlink(path.join(plain, 'current'));
277
+ await symlink('two.txt', path.join(plain, 'current'));
278
+ assert.notEqual(await changeFingerprint(plain), linked, 'a retargeted link moves the fingerprint');
279
+ });
package/src/gitwork.ts CHANGED
@@ -8,7 +8,11 @@
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';
12
+ import { readdir, readFile, readlink, stat } from 'node:fs/promises';
13
+ import { createHash } from 'node:crypto';
11
14
  import { execFile } from 'node:child_process';
15
+ import type { ReviewVerdict } from './crew.js';
12
16
 
13
17
  export interface GitInfo {
14
18
  repo: boolean;
@@ -92,6 +96,183 @@ export function missionBranchName(mission: string, runId: string): string {
92
96
  * own commit — so this is what a human stands to have committed under a
93
97
  * mission's name without noticing.
94
98
  */
99
+ /**
100
+ * When this folder is a linked git worktree, the repository it was made from:
101
+ * the main worktree's root. Null when the folder is that main worktree, is
102
+ * not a repository, or git is too old to say.
103
+ *
104
+ * Worktrees are the reason a mission asks the human the same question all
105
+ * day. A worktree holds a branch's files but not the repository's shared
106
+ * scaffolding — the build config, the type declarations, the parent package's
107
+ * node_modules — so a crew working in one steps up to the parent constantly,
108
+ * and every step is a boundary crossing.
109
+ */
110
+ export interface WorktreeShape {
111
+ /** The main worktree's root: the repository this folder was made from. */
112
+ parent: string;
113
+ /** Every other worktree of the same repository, this folder excluded. */
114
+ siblings: string[];
115
+ }
116
+
117
+ export async function worktreeParent(folder: string): Promise<WorktreeShape | null> {
118
+ try {
119
+ const out = await git(['worktree', 'list', '--porcelain'], folder);
120
+ // The first entry is always the main worktree; the rest are the linked ones.
121
+ const roots = out.split('\n').filter((l) => l.startsWith('worktree '))
122
+ .map((l) => path.resolve(l.slice('worktree '.length).trim()));
123
+ const main = roots[0];
124
+ if (!main || roots.length < 2) return null;
125
+ const here = (await git(['rev-parse', '--show-toplevel'], folder)).trim();
126
+ if (!here || path.resolve(here) === main) return null;
127
+ return { parent: main, siblings: roots.slice(1).filter((r) => r !== path.resolve(here)) };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Should this run be given its parent repository, and why not when not.
135
+ * Pure so the rule is testable: the parent is opened unless another live run
136
+ * is working in it, because two crews in one checkout is the situation the
137
+ * dirty-checkout guard exists to prevent.
138
+ */
139
+ export function worktreeGrant(
140
+ shape: WorktreeShape | null,
141
+ busyFolders: Iterable<string>,
142
+ ): { grant: string | null; reason?: string } {
143
+ if (!shape) return { grant: null };
144
+ const { parent, siblings } = shape;
145
+ for (const f of busyFolders) {
146
+ if (path.resolve(f) === parent) {
147
+ return { grant: null, reason: `its parent repository ${parent} is held by another running mission` };
148
+ }
149
+ }
150
+ // A grant is a subtree, so a worktree that lives *inside* the repository
151
+ // (the common `/repo/.worktrees/x` layout) would be opened along with the
152
+ // parent — and one of those may be another mission's workspace. The promise
153
+ // that siblings stay closed cannot be kept by granting the parent here, so
154
+ // the grant is declined and the human keeps deciding, one command at a time.
155
+ const nested = siblings.filter((s) => s === parent || s.startsWith(parent + path.sep));
156
+ if (nested.length) {
157
+ return {
158
+ grant: null,
159
+ reason: `opening ${parent} would also open ${nested.length} other worktree${nested.length === 1 ? '' : 's'} inside it `
160
+ + `(${nested.slice(0, 2).map((s) => path.basename(s)).join(', ')}${nested.length > 2 ? ', …' : ''})`,
161
+ };
162
+ }
163
+ return { grant: parent };
164
+ }
165
+
166
+ /**
167
+ * A fingerprint of everything this checkout has changed, for pinning a
168
+ * reviewer's PASS to the code it actually read.
169
+ *
170
+ * Deliberately not computed from the deck: the deck is a *view* — it stops at
171
+ * 200 files and carries no binary content — so a change to the 201st file, or
172
+ * a swapped image, would leave a deck-derived hash identical and a stale PASS
173
+ * looking current. This asks git instead: the full diff against HEAD including
174
+ * binary deltas, plus the blob hash of every untracked file. Null when the
175
+ * folder is not a repository or git will not answer, which callers must treat
176
+ * as "cannot verify", never as "nothing changed".
177
+ */
178
+ /** git's empty tree, for diffing a repository that has no commit yet. */
179
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
180
+ const FINGERPRINT_FILE_CAP = 20_000;
181
+ const FINGERPRINT_HASH_MAX_BYTES = 1024 * 1024;
182
+ const FINGERPRINT_SKIP = new Set(['.git', '.foreman', 'node_modules']);
183
+
184
+ /**
185
+ * The same fingerprint for a folder that is not a repository — Foreman links
186
+ * plain folders too, and a gate that only worked in git would make every
187
+ * mission in one impossible to finish.
188
+ *
189
+ * Content-hashed up to a megabyte a file, size and mtime beyond that, since
190
+ * reading a large binary on every gate check costs more than it proves.
191
+ * Dependency trees and Foreman's own directory are skipped: they are not the
192
+ * work under review. Null past the file cap, which the caller reads as
193
+ * "cannot verify" — the honest answer for a tree too large to pin.
194
+ */
195
+ async function walkFingerprint(folder: string): Promise<string | null> {
196
+ const parts: string[] = [];
197
+ const walk = async (dir: string, rel: string): Promise<boolean> => {
198
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
199
+ // A directory we cannot read may be where the change is. Failing open
200
+ // would let a PASS stand over work nobody could see.
201
+ if (!entries) return false;
202
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
203
+ if (FINGERPRINT_SKIP.has(e.name)) continue;
204
+ const full = path.join(dir, e.name);
205
+ const here = rel ? `${rel}/${e.name}` : e.name;
206
+ if (e.isDirectory()) {
207
+ if (!await walk(full, here)) return false;
208
+ continue;
209
+ }
210
+ // A symlink is neither a file nor a directory to readdir, and retargeting
211
+ // one changes what the project is without touching a byte of content.
212
+ if (e.isSymbolicLink()) {
213
+ const target = await readlink(full).catch(() => null);
214
+ if (target === null) return false;
215
+ parts.push(`${here}\0link\0${target}`);
216
+ continue;
217
+ }
218
+ if (!e.isFile()) continue;
219
+ if (parts.length >= FINGERPRINT_FILE_CAP) return false;
220
+ const st = await stat(full).catch(() => null);
221
+ if (!st) continue;
222
+ if (st.size <= FINGERPRINT_HASH_MAX_BYTES) {
223
+ const buf = await readFile(full).catch(() => null);
224
+ parts.push(`${here}\0${st.size}\0${buf ? createHash('sha256').update(buf).digest('hex') : 'unreadable'}`);
225
+ } else {
226
+ parts.push(`${here}\0${st.size}\0${st.mtimeMs}`);
227
+ }
228
+ }
229
+ return true;
230
+ };
231
+ if (!await walk(folder, '')) return null;
232
+ return createHash('sha256').update(parts.join('\n')).digest('hex');
233
+ }
234
+
235
+ export async function changeFingerprint(folder: string): Promise<string | null> {
236
+ try {
237
+ const inside = await git(['rev-parse', '--is-inside-work-tree'], folder).catch(() => '');
238
+ if (inside.trim() !== 'true') return walkFingerprint(folder);
239
+ // HEAD is part of the fingerprint, not just the dirty tree: a director
240
+ // that commits its work after a PASS leaves `git diff HEAD` empty, and a
241
+ // fingerprint of the diff alone would call the new commit unchanged and
242
+ // let the old PASS stand.
243
+ // A repository with no commit yet has no HEAD to diff against, and a
244
+ // mission that starts one is ordinary — so the comparison falls back to
245
+ // git's empty tree rather than failing, which would make every run with a
246
+ // required reviewer impossible to finish until someone committed.
247
+ const head = (await git(['rev-parse', 'HEAD'], folder).catch(() => '')).trim();
248
+ // Scoped to this folder, like the deck: a project linked as a subdirectory
249
+ // of a bigger repository must not have its review invalidated because a
250
+ // sibling project changed. `ls-files` below is already limited to the cwd.
251
+ const tracked = await git(
252
+ ['diff', head || EMPTY_TREE, '--binary', '--no-color', '--no-ext-diff', '--', '.'], folder, 60_000,
253
+ );
254
+ // -z, because `ls-files` C-quotes any path with a quote, a tab or a
255
+ // non-ASCII character, and a quoted path handed back to `hash-object`
256
+ // fails — which used to leave those files with no content in the hash at
257
+ // all, so edits to them were invisible to the gate.
258
+ const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'], folder))
259
+ .split('\0')
260
+ // The mission doc and the crew's scratch space are Foreman's own and
261
+ // change constantly; they are not the work under review.
262
+ .filter((p) => p && !p.startsWith('.foreman/'));
263
+ const parts: string[] = [`HEAD\0${head || 'none'}`, tracked];
264
+ for (const p of untracked.sort()) {
265
+ // No catch: a file whose hash cannot be read is a fingerprint that
266
+ // cannot be trusted, and the honest answer is "cannot verify".
267
+ const blob = await git(['hash-object', '--', p], folder);
268
+ parts.push(`${p}\0${blob.trim()}`);
269
+ }
270
+ return createHash('sha256').update(parts.join('\n')).digest('hex');
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
95
276
  export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
96
277
  try {
97
278
  const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
@@ -207,8 +388,22 @@ export function compareUrl(remote: string | undefined, base: string, branch: str
207
388
  return `${r.web}`;
208
389
  }
209
390
 
210
- /** The pull request as Foreman drafts it: the run's title, and a body a reviewer can read without opening Foreman. */
211
- export function prDraft(run: { title?: string; mission: string; costUsd: number; costBasis?: string; git?: MissionGit }, missionDoc: string | null): { title: string; body: string } {
391
+ /** How much of a reviewer's findings go in the body; the rest is in the run's record. */
392
+ const FINDINGS_HEAD = 800;
393
+
394
+ /**
395
+ * The pull request as Foreman drafts it: the run's title, and a body a reviewer
396
+ * can read without opening Foreman.
397
+ *
398
+ * `reviews` is passed in rather than read from the run's record here, because
399
+ * this module knows about git and nothing else — and because the caller is the
400
+ * only one that knows which verdicts are the ones this branch was judged by.
401
+ */
402
+ export function prDraft(
403
+ run: { title?: string; mission: string; costUsd: number; costBasis?: string; git?: MissionGit },
404
+ missionDoc: string | null,
405
+ reviews?: readonly ReviewVerdict[],
406
+ ): { title: string; body: string } {
212
407
  const first = run.mission.split('\n').find((l) => l.trim())?.trim() ?? 'Mission';
213
408
  const title = (run.title || first).slice(0, 120);
214
409
  const boxes = (missionDoc ?? '').split('\n').filter((l) => /^\s*[-*] \[[ xX]\]/.test(l)).map((l) => l.trim());
@@ -217,6 +412,20 @@ export function prDraft(run: { title?: string; mission: string; costUsd: number;
217
412
  '## Mission', '', run.mission.trim(), '',
218
413
  ];
219
414
  if (boxes.length) parts.push('## Done when', '', ...boxes, '');
415
+ // Who reviewed this before it was offered to a human, and what they said.
416
+ // The whole point of the reviewer gate is that the answer travels with the
417
+ // work; a PASS nobody outside Foreman can see is worth nothing on a branch.
418
+ if (reviews?.length) {
419
+ parts.push('## Review', '');
420
+ for (const v of reviews) {
421
+ parts.push(`**${v.name}: ${v.pass ? 'PASS' : 'FAIL'}**`);
422
+ const head = v.findings.trim();
423
+ if (head) {
424
+ parts.push('', head.length > FINDINGS_HEAD ? `${head.slice(0, FINDINGS_HEAD).trimEnd()}\n\n… the rest is in the run's record.` : head);
425
+ }
426
+ parts.push('');
427
+ }
428
+ }
220
429
  parts.push('---', `Run by [Foreman](https://github.com/amenophis1er/foreman) on branch \`${run.git?.branch ?? ''}\` from \`${run.git?.base ?? ''}\` · spend ${spend}.`);
221
430
  return { title, body: parts.join('\n') };
222
431
  }
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 () => {
package/src/mcp.ts CHANGED
@@ -10,17 +10,22 @@
10
10
  * What it offers is what an agent watching or launching missions needs: the
11
11
  * fleet, runs, a run's status with a `wait` (one call that returns when
12
12
  * something changes, instead of a polling loop), the transcript, the mission
13
- * doc and memory, linking a project, starting a mission, steering a director.
13
+ * doc and memory, the schedules, linking a project, starting a mission,
14
+ * steering a director.
14
15
  *
15
16
  * What it does not offer, on purpose: approving or denying, answering the
16
17
  * director's questions, interrupt, resume, raising a budget, opening a pull
17
- * request, settings and keys. Those are the moments Foreman exists to put a
18
+ * request, settings and keys, and any change to a schedule. Those are the
19
+ * moments Foreman exists to put a
18
20
  * human in; `run_status` says when a run needs one, and with what, so the
19
21
  * agent's job is to send the human to decide, not to decide.
20
22
  */
21
23
  import fs from 'node:fs';
22
24
  import path from 'node:path';
23
25
  import { z } from 'zod';
26
+ import { describeCadence, type Cadence } from './schedule.js';
27
+ import type { CrewPreset, ReviewVerdict } from './crew.js';
28
+ import { reviewReportLines } from './run-crew.js';
24
29
 
25
30
  export interface ToolResult {
26
31
  /** What the model reads. */
@@ -62,6 +67,9 @@ interface RunSummary {
62
67
  workers?: Array<{ id: string; status: string; costUsd: number; task: string }>;
63
68
  git?: { branch: string; base: string; commits?: number; pr?: string; prState?: string };
64
69
  usage?: { inputTokens: number; outputTokens: number };
70
+ /** Frozen at dispatch; read here only to say who was meant to review. */
71
+ crew?: CrewPreset[];
72
+ reviews?: ReviewVerdict[];
65
73
  }
66
74
  interface Need { kind: string; id: string; runId?: string; text: string; options?: string[]; toolName?: string; since?: number }
67
75
  interface ProjectCard {
@@ -70,6 +78,45 @@ interface ProjectCard {
70
78
  pendingPermissions: number; pendingQuestions: number; needs?: Need[]; git?: { branch?: string; dirty?: boolean } | null;
71
79
  }
72
80
 
81
+ /** A schedule as `GET /projects/{id}/schedules` reports it. */
82
+ interface ScheduleSummary {
83
+ id: string; name: string; cadence: Cadence; budgetUsd: number; enabled: boolean;
84
+ nextRunAt: number | null; pausedReason: null | 'failures' | 'monthly-cap' | 'human';
85
+ consecutiveFailures?: number; lastOutcome?: string; lastRunId?: string; lastRunAt?: number; lastNote?: string;
86
+ }
87
+ interface SchedulePayload { schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }
88
+
89
+ /** A moment as a reader would say it: "in 15 h", "3 days ago". */
90
+ export function relativeTime(ms: number): string {
91
+ const s = Math.round(ms / 1000);
92
+ const a = Math.abs(s);
93
+ const span = a < 90 ? 'a minute' : a < 5400 ? `${Math.round(a / 60)} min` : a < 172800 ? `${Math.round(a / 3600)} h` : `${Math.round(a / 86400)} days`;
94
+ return s >= 0 ? `in ${span}` : `${span} ago`;
95
+ }
96
+
97
+ /** Why a schedule is not going to fire, in the words that say what would undo it. */
98
+ function pausedPhrase(s: ScheduleSummary): string {
99
+ switch (s.pausedReason) {
100
+ case 'failures': return `paused after ${s.consecutiveFailures ?? 2} failed scheduled runs in a row`;
101
+ case 'monthly-cap': return 'paused at the project\'s monthly cap for scheduled spend';
102
+ case 'human': return 'paused by hand';
103
+ default: return s.enabled ? 'enabled' : 'disabled';
104
+ }
105
+ }
106
+
107
+ /**
108
+ * One line for a schedule. The next run is said twice — absolutely, because a
109
+ * schedule is a wall-clock promise, and relatively, because "in 15 h" is what
110
+ * the reader actually wanted to know.
111
+ */
112
+ function scheduleLine(s: ScheduleSummary): string {
113
+ const next = s.pausedReason || !s.enabled ? 'no next run while paused'
114
+ : s.nextRunAt ? `next ${new Date(s.nextRunAt).toLocaleString()} (${relativeTime(s.nextRunAt - Date.now())})`
115
+ : 'next never — this cadence has no future firing';
116
+ const last = s.lastOutcome ? `last ${s.lastOutcome}${s.lastRunId ? ` (${s.lastRunId})` : ''}${s.lastRunAt ? ` ${relativeTime(s.lastRunAt - Date.now())}` : ''}` : 'never run yet';
117
+ return `${s.name} · ${describeCadence(s.cadence)} · ${next} · ${pausedPhrase(s)} · ${usd(s.budgetUsd)} per run · ${last}`;
118
+ }
119
+
73
120
  /** One line for a run, the way the fleet board says it. */
74
121
  function runLine(r: RunSummary): string {
75
122
  const cost = r.costBasis && r.costBasis !== 'priced' ? `${r.costBasis}` : `${usd(r.costUsd)} of ${usd(r.budgetUsd)}`;
@@ -297,7 +344,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
297
344
 
298
345
  const runReport: ToolDef = {
299
346
  name: 'run_report',
300
- description: 'What a finished run produced, in one call: the director\'s final report, DONE WHEN ticks, the files it changed with +/− counts, the branch, commit and pull request, spend and crew. For a running run it reports the state so far.',
347
+ description: 'What a finished run produced, in one call: the director\'s final report, DONE WHEN ticks, the files it changed with +/− counts, the branch, commit and pull request, spend, crew and any review verdicts. For a running run it reports the state so far.',
301
348
  schema: { runId: z.string() },
302
349
  run: async ({ runId }) => {
303
350
  const id = String(runId);
@@ -327,9 +374,13 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
327
374
  deck ? `changed: ${own.length} file${own.length === 1 ? '' : 's'} · +${deck.totals.additions} −${deck.totals.deletions}${images ? ` · ${images} screenshot${images === 1 ? '' : 's'}` : ''}${files.length > own.length ? ` · ${files.length - own.length} already dirty before the run` : ''}` : null,
328
375
  fileLines.length ? fileLines.join('\n') + (own.length > 40 ? `\n … ${own.length - 40} more` : '') : null,
329
376
  `crew: ${(r.workers ?? []).length} worker${(r.workers ?? []).length === 1 ? '' : 's'} · ${(r.workers ?? []).filter((w) => w.status === 'done').length} done`,
377
+ // The verdicts, and any required reviewer standing between this run and
378
+ // done. Read-only, like everything else here: presets are configuration
379
+ // and configuration is edited on the dashboard.
380
+ ...reviewReportLines(r.crew, r.reviews),
330
381
  report ? `\nDirector's report:\n${report.slice(0, 4000)}` : '\nNo final report from the director yet.',
331
382
  ].filter(Boolean);
332
- return { text: lines.join('\n'), data: { run: r, doneWhen: dw, files: own, totals: deck?.totals, report } };
383
+ return { text: lines.join('\n'), data: { run: r, doneWhen: dw, files: own, totals: deck?.totals, report, reviews: r.reviews ?? [] } };
333
384
  },
334
385
  };
335
386
 
@@ -373,6 +424,36 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
373
424
  },
374
425
  };
375
426
 
427
+ const schedules: ToolDef = {
428
+ name: 'list_schedules',
429
+ description: 'The standing schedules: missions that start themselves in their project on a cadence. Per schedule — the project, the name, the cadence in words, the next run, enabled or paused and why, the per-run cap, and how the last firing ended with its run id. Read-only, and the only schedule tool there is: creating, editing, pausing, resuming or running one now happens on the dashboard, because a schedule is standing configuration and remote surfaces never grant standing changes. Do not look for another tool.',
430
+ schema: { projectId: z.string().optional() },
431
+ run: async ({ projectId }) => {
432
+ const all = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
433
+ const ref = projectId === undefined ? '' : String(projectId).trim();
434
+ const wanted = ref ? all.filter((p) => p.id === ref || p.name.toLowerCase() === ref.toLowerCase()) : all;
435
+ if (ref && !wanted.length) return { text: `No project ${ref}. fleet_status lists them by id and name.` };
436
+ const blocks: string[] = [];
437
+ const data: Array<{ projectId: string; project: string; schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }> = [];
438
+ for (const p of wanted) {
439
+ const d = await get<SchedulePayload>(`/projects/${encodeURIComponent(p.id)}/schedules`).catch(() => null);
440
+ if (!d) continue;
441
+ const list = d.schedules ?? [];
442
+ data.push({ projectId: p.id, project: p.name, schedules: list, monthSpendUsd: d.monthSpendUsd, monthlyCapUsd: d.monthlyCapUsd });
443
+ if (!list.length) {
444
+ if (ref) blocks.push(`${p.name} (${p.id}) — no schedules.`);
445
+ continue;
446
+ }
447
+ const month = typeof d.monthSpendUsd === 'number' && typeof d.monthlyCapUsd === 'number'
448
+ ? ` · scheduled this month ${usd(d.monthSpendUsd)} of ${usd(d.monthlyCapUsd)}` : '';
449
+ blocks.push(`${p.name} (${p.id}) — ${list.length} schedule${list.length === 1 ? '' : 's'}${month}\n${list.map((s) => ` ${scheduleLine(s)}`).join('\n')}`);
450
+ }
451
+ if (!blocks.length) return { text: 'No schedules. They are created on the dashboard, in a project\'s view.', data: { projects: data } };
452
+ blocks.push('Read-only here: a schedule is created, edited, paused or resumed on the dashboard.');
453
+ return { text: blocks.join('\n'), data: { projects: data } };
454
+ },
455
+ };
456
+
376
457
  const search: ToolDef = {
377
458
  name: 'search_runs',
378
459
  description: 'Runs across the fleet whose title, brief, project or folder match.',
@@ -456,7 +537,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
456
537
  },
457
538
  };
458
539
 
459
- return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, search, doctor, link, start, steer];
540
+ return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, schedules, search, doctor, link, start, steer];
460
541
  }
461
542
 
462
543
  /** Runs the MCP server over stdio until the client goes away. Nothing may be written to stdout but the protocol. */
@@ -11,6 +11,8 @@ test('parseCommand: one shape per command, bot suffix tolerated, junk is null',
11
11
  assert.deepEqual(parseCommand('/run lp1 ship it'), { cmd: 'run', project: 'lp1', text: 'ship it' });
12
12
  assert.deepEqual(parseCommand('/stop'), { cmd: 'stop' });
13
13
  assert.deepEqual(parseCommand('/stop lp1'), { cmd: 'stop', project: 'lp1' });
14
+ assert.deepEqual(parseCommand('/schedules'), { cmd: 'schedules' });
15
+ assert.deepEqual(parseCommand('/schedules@ForemanBot lp1'), { cmd: 'schedules', project: 'lp1' });
14
16
  assert.equal(parseCommand('/plan test-4'), null);
15
17
  assert.equal(parseCommand('/new'), null);
16
18
  assert.equal(parseCommand('/dance'), null);
@@ -17,6 +17,8 @@ export type Command =
17
17
  | { cmd: 'plan'; project: string; text: string }
18
18
  | { cmd: 'run'; project: string; text: string }
19
19
  | { cmd: 'stop'; project?: string }
20
+ /** Read-only: what stands, for one project or the whole fleet. */
21
+ | { cmd: 'schedules'; project?: string }
20
22
  | { cmd: 'fleet'; text: string };
21
23
 
22
24
  /** `/plan@ForemanBot test-4 add a footer` → { cmd: 'plan', project: 'test-4', text: 'add a footer' }. */
@@ -37,6 +39,9 @@ export function parseCommand(text: string): Command | null {
37
39
  case 'plan': { const [project, t] = split(); return project && t ? { cmd: 'plan', project, text: t } : null; }
38
40
  case 'run': { const [project, t] = split(); return project && t ? { cmd: 'run', project, text: t } : null; }
39
41
  case 'stop': return { cmd: 'stop', ...(rest ? { project: rest } : {}) };
42
+ // Listing only. A schedule is standing configuration, and remote surfaces
43
+ // never grant standing changes — see the reply in server.ts.
44
+ case 'schedules': return { cmd: 'schedules', ...(rest ? { project: rest } : {}) };
40
45
  case 'fleet': case 'f': return { cmd: 'fleet', text: rest };
41
46
  default: return null;
42
47
  }
@@ -70,6 +75,7 @@ export const HELP_TEXT = [
70
75
  '/plan &lt;project&gt; &lt;what you want&gt; — talk to that project\'s planner',
71
76
  '/run &lt;project&gt; &lt;brief&gt; — skip the talk: start a mission at the project\'s default cap',
72
77
  '/stop [project] — stop the planner reply in flight',
78
+ '/schedules [project] — the standing schedules and when they next run (reading only; they are changed in the dashboard)',
73
79
  '/fleet [anything] — the front desk: ask how things are going, or say what you want started where',
74
80
  '',
75
81
  'Anything else you type answers the open question, continues the planning conversation you were just in, or goes to the front desk.',
@@ -98,6 +98,7 @@ export const BOT_COMMANDS: Array<{ command: string; description: string }> = [
98
98
  { command: 'plan', description: 'Talk to a planner: /plan <project> <what you want>' },
99
99
  { command: 'run', description: 'Skip the talk: /run <project> <brief>' },
100
100
  { command: 'stop', description: 'Stop the planner reply in flight' },
101
+ { command: 'schedules', description: 'Standing schedules and when they next run (read-only)' },
101
102
  { command: 'fleet', description: 'The front desk: /fleet how is everything going?' },
102
103
  { command: 'help', description: 'What you can say here' },
103
104
  ];