@amenophis1er/foreman 0.1.16 → 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 +43 -3
- package/package.json +1 -1
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +51 -2
- package/src/gitwork.ts +68 -0
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +75 -3
- 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/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 +540 -7
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +63 -0
- package/ui/dist/assets/index-Bcc4KMtO.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-DOVnExqF.js +0 -68
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 #
|
|
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`, `
|
|
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.
|
|
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);
|
package/src/fleet-planner.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/gitwork.test.ts
CHANGED
|
@@ -3,8 +3,8 @@ 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 { mkdtemp, realpath, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { closeMissionBranch, defaultBranch, worktreeGrant, worktreeParent, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
|
|
8
8
|
} from './gitwork.js';
|
|
9
9
|
|
|
10
10
|
const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
|
|
@@ -146,3 +146,52 @@ test('a pull request targets the default branch when the mission was branched fr
|
|
|
146
146
|
sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
|
|
147
147
|
assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
|
|
148
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 {
|
|
@@ -92,6 +93,73 @@ export function missionBranchName(mission: string, runId: string): string {
|
|
|
92
93
|
* own commit — so this is what a human stands to have committed under a
|
|
93
94
|
* mission's name without noticing.
|
|
94
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
|
+
|
|
95
163
|
export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
|
|
96
164
|
try {
|
|
97
165
|
const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
|
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,20 @@
|
|
|
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,
|
|
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
|
|
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';
|
|
24
27
|
|
|
25
28
|
export interface ToolResult {
|
|
26
29
|
/** What the model reads. */
|
|
@@ -70,6 +73,45 @@ interface ProjectCard {
|
|
|
70
73
|
pendingPermissions: number; pendingQuestions: number; needs?: Need[]; git?: { branch?: string; dirty?: boolean } | null;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/** A schedule as `GET /projects/{id}/schedules` reports it. */
|
|
77
|
+
interface ScheduleSummary {
|
|
78
|
+
id: string; name: string; cadence: Cadence; budgetUsd: number; enabled: boolean;
|
|
79
|
+
nextRunAt: number | null; pausedReason: null | 'failures' | 'monthly-cap' | 'human';
|
|
80
|
+
consecutiveFailures?: number; lastOutcome?: string; lastRunId?: string; lastRunAt?: number; lastNote?: string;
|
|
81
|
+
}
|
|
82
|
+
interface SchedulePayload { schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }
|
|
83
|
+
|
|
84
|
+
/** A moment as a reader would say it: "in 15 h", "3 days ago". */
|
|
85
|
+
export function relativeTime(ms: number): string {
|
|
86
|
+
const s = Math.round(ms / 1000);
|
|
87
|
+
const a = Math.abs(s);
|
|
88
|
+
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`;
|
|
89
|
+
return s >= 0 ? `in ${span}` : `${span} ago`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Why a schedule is not going to fire, in the words that say what would undo it. */
|
|
93
|
+
function pausedPhrase(s: ScheduleSummary): string {
|
|
94
|
+
switch (s.pausedReason) {
|
|
95
|
+
case 'failures': return `paused after ${s.consecutiveFailures ?? 2} failed scheduled runs in a row`;
|
|
96
|
+
case 'monthly-cap': return 'paused at the project\'s monthly cap for scheduled spend';
|
|
97
|
+
case 'human': return 'paused by hand';
|
|
98
|
+
default: return s.enabled ? 'enabled' : 'disabled';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One line for a schedule. The next run is said twice — absolutely, because a
|
|
104
|
+
* schedule is a wall-clock promise, and relatively, because "in 15 h" is what
|
|
105
|
+
* the reader actually wanted to know.
|
|
106
|
+
*/
|
|
107
|
+
function scheduleLine(s: ScheduleSummary): string {
|
|
108
|
+
const next = s.pausedReason || !s.enabled ? 'no next run while paused'
|
|
109
|
+
: s.nextRunAt ? `next ${new Date(s.nextRunAt).toLocaleString()} (${relativeTime(s.nextRunAt - Date.now())})`
|
|
110
|
+
: 'next never — this cadence has no future firing';
|
|
111
|
+
const last = s.lastOutcome ? `last ${s.lastOutcome}${s.lastRunId ? ` (${s.lastRunId})` : ''}${s.lastRunAt ? ` ${relativeTime(s.lastRunAt - Date.now())}` : ''}` : 'never run yet';
|
|
112
|
+
return `${s.name} · ${describeCadence(s.cadence)} · ${next} · ${pausedPhrase(s)} · ${usd(s.budgetUsd)} per run · ${last}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
/** One line for a run, the way the fleet board says it. */
|
|
74
116
|
function runLine(r: RunSummary): string {
|
|
75
117
|
const cost = r.costBasis && r.costBasis !== 'priced' ? `${r.costBasis}` : `${usd(r.costUsd)} of ${usd(r.budgetUsd)}`;
|
|
@@ -373,6 +415,36 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
|
|
|
373
415
|
},
|
|
374
416
|
};
|
|
375
417
|
|
|
418
|
+
const schedules: ToolDef = {
|
|
419
|
+
name: 'list_schedules',
|
|
420
|
+
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.',
|
|
421
|
+
schema: { projectId: z.string().optional() },
|
|
422
|
+
run: async ({ projectId }) => {
|
|
423
|
+
const all = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
|
|
424
|
+
const ref = projectId === undefined ? '' : String(projectId).trim();
|
|
425
|
+
const wanted = ref ? all.filter((p) => p.id === ref || p.name.toLowerCase() === ref.toLowerCase()) : all;
|
|
426
|
+
if (ref && !wanted.length) return { text: `No project ${ref}. fleet_status lists them by id and name.` };
|
|
427
|
+
const blocks: string[] = [];
|
|
428
|
+
const data: Array<{ projectId: string; project: string; schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }> = [];
|
|
429
|
+
for (const p of wanted) {
|
|
430
|
+
const d = await get<SchedulePayload>(`/projects/${encodeURIComponent(p.id)}/schedules`).catch(() => null);
|
|
431
|
+
if (!d) continue;
|
|
432
|
+
const list = d.schedules ?? [];
|
|
433
|
+
data.push({ projectId: p.id, project: p.name, schedules: list, monthSpendUsd: d.monthSpendUsd, monthlyCapUsd: d.monthlyCapUsd });
|
|
434
|
+
if (!list.length) {
|
|
435
|
+
if (ref) blocks.push(`${p.name} (${p.id}) — no schedules.`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const month = typeof d.monthSpendUsd === 'number' && typeof d.monthlyCapUsd === 'number'
|
|
439
|
+
? ` · scheduled this month ${usd(d.monthSpendUsd)} of ${usd(d.monthlyCapUsd)}` : '';
|
|
440
|
+
blocks.push(`${p.name} (${p.id}) — ${list.length} schedule${list.length === 1 ? '' : 's'}${month}\n${list.map((s) => ` ${scheduleLine(s)}`).join('\n')}`);
|
|
441
|
+
}
|
|
442
|
+
if (!blocks.length) return { text: 'No schedules. They are created on the dashboard, in a project\'s view.', data: { projects: data } };
|
|
443
|
+
blocks.push('Read-only here: a schedule is created, edited, paused or resumed on the dashboard.');
|
|
444
|
+
return { text: blocks.join('\n'), data: { projects: data } };
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
376
448
|
const search: ToolDef = {
|
|
377
449
|
name: 'search_runs',
|
|
378
450
|
description: 'Runs across the fleet whose title, brief, project or folder match.',
|
|
@@ -456,7 +528,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
|
|
|
456
528
|
},
|
|
457
529
|
};
|
|
458
530
|
|
|
459
|
-
return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, search, doctor, link, start, steer];
|
|
531
|
+
return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, schedules, search, doctor, link, start, steer];
|
|
460
532
|
}
|
|
461
533
|
|
|
462
534
|
/** 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);
|
package/src/notify/commands.ts
CHANGED
|
@@ -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 <project> <what you want> — talk to that project\'s planner',
|
|
71
76
|
'/run <project> <brief> — 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.',
|
package/src/notify/telegram.ts
CHANGED
|
@@ -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
|
];
|