@boxxy/contract 0.0.0-stage → 0.1.3

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/src/access.ts ADDED
@@ -0,0 +1,98 @@
1
+ // `caller` says who the procedure is for, and the root middleware of the server checks it. `actor`
2
+ // is the actor that the fact of a write records. `tool` is the MCP endpoint that lists the
3
+ // procedure: `owner` is `/mcp`, `session` is `/mcp/session`.
4
+
5
+ export type Caller = 'owner' | 'session' | 'hook';
6
+
7
+ export interface Access {
8
+ readonly caller: Caller;
9
+ readonly actor: 'boxxy:owner:local' | 'boxxy:attempt:<id>' | 'boxxy:hook:<id>' | 'none';
10
+ readonly tool: 'owner' | 'session' | 'none';
11
+ readonly cli: string;
12
+ }
13
+
14
+ export const access = {
15
+ // The owner's agent files and steers work on the owner's behalf, so it gets every command on an
16
+ // item and every read: 17 tools. `rules.dryRun` is a read that is CLI only.
17
+ 'item.file': buildOwnerWrite(
18
+ 'boxxy item file --repo --title --outcome [--acceptance]… [--kind] [--design-mode] [--blocked-by]… [--label]…',
19
+ ),
20
+ 'item.edit': buildOwnerWrite(
21
+ 'boxxy item edit <item> [--title] [--outcome] [--acceptance]… [--design-mode] [--label]…',
22
+ ),
23
+ 'item.close': buildOwnerWrite('boxxy item close <item> --reason [--of]'),
24
+ 'item.retry': buildOwnerWrite('boxxy item retry <item> [--phase]'),
25
+ 'item.wake': buildOwnerWrite('boxxy item wake <item>'),
26
+ 'item.note': buildOwnerWrite('boxxy item note <item> --text'),
27
+ 'relation.add': buildOwnerWrite('boxxy relation add <source> <type> <target>'),
28
+ 'relation.remove': buildOwnerWrite('boxxy relation remove <source> <type> <target>'),
29
+ 'plan.defer': buildOwnerWrite('boxxy defer <item> [--until] --reason'),
30
+ 'plan.undefer': buildOwnerWrite('boxxy undefer <item>'),
31
+ 'plan.expedite': buildOwnerWrite('boxxy expedite <item>… --reason [--expires]'),
32
+ 'plan.unexpedite': buildOwnerWrite('boxxy unexpedite <item>'),
33
+ 'item.get': buildOwnerRead('boxxy item show <item>'),
34
+ 'item.list': buildOwnerRead('boxxy item list [--state] [--repo] [--phase] [--text] [--label]'),
35
+ 'runner.status': buildOwnerRead('boxxy status'),
36
+ 'report.cost': buildOwnerRead('boxxy cost <item>'),
37
+ 'report.stageTimes': buildOwnerRead('boxxy times <item>'),
38
+
39
+ // An agent in a session needs three things: what it works on, a place for evidence, and the
40
+ // close-out. Three tools, on their own endpoint, so a session's tool list holds no command on
41
+ // another item.
42
+ 'attempt.current': {
43
+ caller: 'session',
44
+ actor: 'none',
45
+ tool: 'session',
46
+ cli: 'boxxy attempt current',
47
+ },
48
+ 'attempt.evidence': {
49
+ caller: 'session',
50
+ actor: 'boxxy:attempt:<id>',
51
+ tool: 'session',
52
+ cli: 'boxxy attempt evidence --kind --ref [--observed]',
53
+ },
54
+ 'attempt.closeOut': {
55
+ caller: 'session',
56
+ actor: 'boxxy:attempt:<id>',
57
+ tool: 'session',
58
+ cli: 'boxxy attempt close-out (JSON on stdin)',
59
+ },
60
+
61
+ // Not tools: a hook program calls them with a hook payload.
62
+ 'attempt.bindSession': {
63
+ caller: 'hook',
64
+ actor: 'boxxy:hook:<id>',
65
+ tool: 'none',
66
+ cli: 'boxxy hook session-start (hook payload on stdin)',
67
+ },
68
+ 'attempt.recordPullRequest': {
69
+ caller: 'hook',
70
+ actor: 'boxxy:hook:<id>',
71
+ tool: 'none',
72
+ cli: 'boxxy hook post-tool-use (hook payload on stdin)',
73
+ },
74
+
75
+ // Not tools: each one switches the server itself, not one item, and writes no fact that would
76
+ // show who did it. They are for a person at the CLI.
77
+ 'runner.pause': { caller: 'owner', actor: 'none', tool: 'none', cli: 'boxxy runner pause' },
78
+ 'runner.resume': { caller: 'owner', actor: 'none', tool: 'none', cli: 'boxxy runner resume' },
79
+ 'outbox.retry': { caller: 'owner', actor: 'none', tool: 'none', cli: 'boxxy outbox retry' },
80
+
81
+ // Not a tool, by choice: its input is a whole rules file, and it writes nothing.
82
+ 'rules.dryRun': {
83
+ caller: 'owner',
84
+ actor: 'none',
85
+ tool: 'none',
86
+ cli: 'boxxy rules dry-run <file>',
87
+ },
88
+ } as const satisfies Record<string, Access>;
89
+
90
+ export type ProcedureName = keyof typeof access;
91
+
92
+ function buildOwnerWrite(cli: string): Access {
93
+ return { caller: 'owner', actor: 'boxxy:owner:local', tool: 'owner', cli };
94
+ }
95
+
96
+ function buildOwnerRead(cli: string): Access {
97
+ return { caller: 'owner', actor: 'none', tool: 'owner', cli };
98
+ }
@@ -0,0 +1,5 @@
1
+ import type { ContractRouterClient } from '@orpc/contract';
2
+ import type { contract } from './contract.ts';
3
+
4
+ // The typed client of the contract, as `createORPCClient` builds it.
5
+ export type BoxxyClient = ContractRouterClient<typeof contract>;
@@ -0,0 +1,518 @@
1
+ import {
2
+ attemptRowSchema,
3
+ closeOutAnswerSchema,
4
+ costToLandSchema,
5
+ evidenceRowSchema,
6
+ itemViewSchema,
7
+ limitFillSchema,
8
+ loadRejectionSchema,
9
+ noteRowSchema,
10
+ positionSchema,
11
+ pullRequestRowSchema,
12
+ relationRowSchema,
13
+ stageTimeSchema,
14
+ } from '@boxxy/model';
15
+ import { oc } from '@orpc/contract';
16
+ import { mcp } from '@orpc/mcp';
17
+ import { z } from 'zod';
18
+
19
+ // The wire rules are in section B of the API sketch: no transform in an input, times as ISO 8601,
20
+ // a maximum on every string and list, and a release that removes or renames nothing.
21
+
22
+ const id = z.string().min(1).max(64);
23
+ const itemId = id.describe('The item ID, such as i-12.');
24
+ const time = z.iso.datetime({ offset: true });
25
+
26
+ const reason = z
27
+ .string()
28
+ .min(1)
29
+ .max(500)
30
+ .describe('Why, in one sentence. It is stored with the change.');
31
+
32
+ const designMode = z.enum(['agent', 'attended']);
33
+ const instantOrNull = z.number().nullable();
34
+ const count = z.number().int();
35
+ const text50 = z.string().min(1).max(50);
36
+ const text100 = z.string().min(1).max(100);
37
+ const text200 = z.string().min(1).max(200);
38
+ const text1000 = z.string().min(1).max(1000);
39
+ const itemOutput = z.object({ item: id });
40
+ const leverOutput = z.object({ lever: count });
41
+ const itemInput = z.object({ item: itemId });
42
+ const emptyInput = z.object({});
43
+
44
+ // No field of a session or hook call names a session: the server takes the session from the
45
+ // proven caller. The attempt ID says which attempt the call is for, so that a call made for an
46
+ // older attempt of a kept session is refused.
47
+ const sessionRef = z.object({
48
+ attempt: id.describe('The ID of the attempt this call is for, as attempt_current gave it.'),
49
+ agentSession: id.optional().describe("The agent's own session ID, when the caller knows it."),
50
+ product: z.enum(['claude_code', 'codex']).optional(),
51
+ });
52
+
53
+ const notFoundData = z.object({ item: id });
54
+ const notFound = { NOT_FOUND: { message: 'no such item', data: notFoundData } };
55
+ const pepysUnavailableData = z.object({ reason: z.string() });
56
+
57
+ const pepysErrors = {
58
+ ...notFound,
59
+ PEPYS_NOT_CONFIGURED: { message: 'this boxxy server has no pepys address' },
60
+ PEPYS_UNAVAILABLE: { message: 'pepys did not answer', data: pepysUnavailableData },
61
+ };
62
+
63
+ // An entry that names the `id` of a criterion keeps that criterion's evidence; an entry with no
64
+ // `id` is a new criterion.
65
+ const criterionInput = z.object({
66
+ id: id.optional().describe('The ID of an existing criterion to keep.'),
67
+ text: text1000,
68
+ });
69
+
70
+ const itemDetail = z.object({
71
+ item: itemViewSchema,
72
+ notes: z.array(noteRowSchema),
73
+ attempts: z.array(attemptRowSchema),
74
+ relations: z.array(relationRowSchema),
75
+ evidence: z.array(evidenceRowSchema),
76
+ pullRequests: z.array(pullRequestRowSchema),
77
+ });
78
+
79
+ const outboxLastError = z.object({
80
+ kind: z.enum(['retry', 'refused-batch', 'refused-sink']),
81
+ status: count.nullable(),
82
+ message: z.string(),
83
+ at: z.number(),
84
+ });
85
+
86
+ const outboxStatus = z.object({
87
+ sink: z.enum(['pepys', 'none']),
88
+ state: z.enum(['idle', 'sending', 'backing-off', 'blocked', 'off']),
89
+ unsent: count,
90
+ parked: count,
91
+ lag: instantOrNull,
92
+ failingSince: instantOrNull,
93
+ nextTryAt: instantOrNull,
94
+ lastError: outboxLastError.nullable(),
95
+ lastStoredAt: instantOrNull,
96
+ });
97
+
98
+ // How many facts of one item the outbox has not sent yet.
99
+ const behind = z.object({ facts: count, oldestAt: instantOrNull });
100
+
101
+ const attemptRefusal = z.object({
102
+ kind: z.literal('refused'),
103
+ reason: z.enum(['no-attempt', 'not-current']),
104
+ });
105
+
106
+ const itemFileInput = z.object({
107
+ repo: text200.describe('The repository, as owner/name.'),
108
+ title: text200,
109
+ outcome: z.string().min(1).max(4000).describe('What is true when the item is done.'),
110
+ acceptance: z
111
+ .array(text1000)
112
+ .max(30)
113
+ .default([])
114
+ .describe('One check per entry. Triage writes the list when it is empty.'),
115
+ kind: id.optional().describe('The item kind. Triage sets it when absent.'),
116
+ designMode: designMode
117
+ .optional()
118
+ .describe('attended: the owner works with the agent in the design session.'),
119
+ blockedBy: z.array(itemId).max(20).default([]),
120
+ labels: z.array(text50).max(20).default([]).describe('Free labels, such as "infra".'),
121
+ });
122
+
123
+ const itemEditInput = z.object({
124
+ item: itemId,
125
+ title: text200.optional(),
126
+ outcome: z.string().min(1).max(4000).optional(),
127
+ acceptance: z.array(criterionInput).max(30).optional(),
128
+ designMode: designMode.optional(),
129
+ labels: z.array(text50).max(20).optional().describe('The whole new list of labels.'),
130
+ });
131
+
132
+ const itemCloseInput = z.object({
133
+ item: itemId,
134
+ reason,
135
+ of: itemId.optional().describe('The item that this one duplicates.'),
136
+ });
137
+
138
+ const endedAttempt = z.object({ attempt: id, how: z.enum(['settled', 'closing']) });
139
+ const itemCloseOutput = z.object({ item: id, ended: z.array(endedAttempt) });
140
+
141
+ const itemRetryInput = z.object({
142
+ item: itemId,
143
+ phase: id.optional().describe('triage, design, build or review.'),
144
+ });
145
+
146
+ const itemWakeOutput = z.discriminatedUnion('kind', [
147
+ z.object({ kind: z.literal('requested'), attempt: id, atcSession: id.nullable() }),
148
+ z.object({ kind: z.literal('not-sleeping') }),
149
+ ]);
150
+
151
+ const itemNoteInput = z.object({ item: itemId, text: z.string().min(1).max(8000) });
152
+ const itemNoteOutput = z.object({ item: id, seq: count });
153
+
154
+ const itemListInput = z.object({
155
+ state: z.enum(['idea', 'ready', 'blocked', 'running', 'waiting', 'done', 'archived']).optional(),
156
+ repo: text200.optional(),
157
+ phase: id.optional().describe('Only items that stand at this phase.'),
158
+ text: text200.optional().describe('Only items whose title, outcome or notes hold this text.'),
159
+ label: text50.optional().describe('Only items with this label.'),
160
+ limit: z.number().int().min(1).max(50).default(20),
161
+ after: id.optional().describe('The `next` value of the call before.'),
162
+ });
163
+
164
+ const itemListOutput = z.object({ items: z.array(itemViewSchema), next: id.nullable() });
165
+
166
+ const relationAddInput = z.object({
167
+ source: itemId,
168
+ type: id.describe('The relation type: "blocks", "duplicate-of" or "relates-to".'),
169
+ target: itemId,
170
+ });
171
+
172
+ const relationAddOutput = z.discriminatedUnion('kind', [
173
+ z.object({ kind: z.literal('added') }),
174
+ z.object({ kind: z.literal('refused'), reason: z.enum(['cycle', 'exists', 'unknown-type']) }),
175
+ ]);
176
+
177
+ const relationRemoveInput = z.object({ source: itemId, type: id, target: itemId });
178
+
179
+ const attemptSkills = z.object({
180
+ pin: z.object({ source: z.string(), ref: z.string() }).nullable(),
181
+ names: z.array(z.string()),
182
+ });
183
+
184
+ const attemptNote = z.object({ seq: count, text: z.string(), at: z.number() });
185
+
186
+ const attemptCloseOutShape = z.object({
187
+ phase: id,
188
+ schema: z.json().describe('The JSON Schema of the close-out body of this phase.'),
189
+ outcomes: z.array(z.string()),
190
+ });
191
+
192
+ const attemptCurrentOutput = z.discriminatedUnion('kind', [
193
+ z.object({
194
+ kind: z.literal('attempt'),
195
+ attempt: attemptRowSchema,
196
+ item: itemViewSchema,
197
+ input: z.json(),
198
+ closeOut: attemptCloseOutShape,
199
+ skills: attemptSkills,
200
+ notes: z.array(attemptNote),
201
+ }),
202
+ attemptRefusal,
203
+ ]);
204
+
205
+ const attemptCloseOutInput = z.object({
206
+ session: sessionRef,
207
+ closeOut: z.unknown().describe('The close-out body. Get its schema from attempt_current.'),
208
+ });
209
+
210
+ const attemptEvidenceInput = z.object({
211
+ session: sessionRef,
212
+ kind: z.enum(['test', 'ci-run', 'live-check']),
213
+ ref: text1000.describe(
214
+ 'What was checked: a test file and name, a CI run URL, or the command of a live check.',
215
+ ),
216
+ observed: z.string().min(1).max(2000).optional().describe('What was seen, such as "42 passed".'),
217
+ });
218
+
219
+ const attemptEvidenceOutput = z.discriminatedUnion('kind', [
220
+ z.object({ kind: z.literal('recorded'), factId: id }),
221
+ attemptRefusal,
222
+ ]);
223
+
224
+ const bindSessionInput = z.object({
225
+ session: sessionRef.required(),
226
+ seenAt: time,
227
+ missingSkills: z.array(text100).max(50).optional(),
228
+ });
229
+
230
+ const bindSessionOutput = z.discriminatedUnion('kind', [
231
+ z.object({ kind: z.enum(['bound', 'held', 'no-attempt', 'not-current', 'skill-missing']) }),
232
+ z.object({ kind: z.literal('fault'), fault: z.literal('agent-session-owned'), holder: id }),
233
+ ]);
234
+
235
+ const recordPullRequestInput = z.object({ session: sessionRef, url: z.url().max(500) });
236
+
237
+ const recordPullRequestOutput = z.discriminatedUnion('kind', [
238
+ z.object({ kind: z.literal('linked'), repo: z.string(), number: count, repeat: z.boolean() }),
239
+ z.object({ kind: z.literal('retry') }),
240
+ z.object({
241
+ kind: z.literal('refused'),
242
+ reason: z.enum(['no-attempt', 'not-current', 'not-a-pull-request-url', 'linked-elsewhere']),
243
+ }),
244
+ ]);
245
+
246
+ const deferInput = z.object({ item: itemId, until: time.optional(), reason });
247
+
248
+ const expediteInput = z.object({
249
+ items: z.array(itemId).min(1).max(20),
250
+ reason,
251
+ expires: time.optional(),
252
+ });
253
+
254
+ const runnerAttempt = z.object({
255
+ attempt: attemptRowSchema,
256
+ title: z.string(),
257
+ waitingSince: instantOrNull,
258
+ });
259
+
260
+ const rulesLastRejected = z.object({
261
+ commit: z.string(),
262
+ rejections: z.array(loadRejectionSchema),
263
+ });
264
+
265
+ const rulesStatus = z.object({
266
+ version: z.string(),
267
+ commit: z.string(),
268
+ lastRejected: rulesLastRejected.nullable(),
269
+ });
270
+
271
+ const runnerStatusOutput = z.object({
272
+ paused: z.boolean(),
273
+ limits: z.array(limitFillSchema),
274
+ attempts: z.array(runnerAttempt),
275
+ strays: z.array(attemptRowSchema),
276
+
277
+ // hold notices that the notifier has not sent
278
+ notices: z.object({ unsent: count }),
279
+ outbox: outboxStatus,
280
+ rules: rulesStatus,
281
+ });
282
+
283
+ const dryRunInput = z.object({
284
+ rules: z.string().min(1).max(200_000).describe('The text of the rules file to try.'),
285
+ });
286
+
287
+ const dryRunItem = z.object({
288
+ item: id,
289
+ now: positionSchema,
290
+
291
+ // `then` is the wire name of the position the item would get
292
+ // oxlint-disable-next-line unicorn/no-thenable -- a plain wire field, never awaited
293
+ then: positionSchema,
294
+ moved: z.boolean(),
295
+ });
296
+
297
+ const dryRunOutput = z.discriminatedUnion('kind', [
298
+ z.object({
299
+ kind: z.literal('loaded'),
300
+ version: z.string(),
301
+ moved: count,
302
+ items: z.array(dryRunItem),
303
+ }),
304
+ z.object({ kind: z.literal('rejected'), rejections: z.array(loadRejectionSchema) }),
305
+ ]);
306
+
307
+ const reportCostOutput = costToLandSchema.extend({ item: id, behind });
308
+ const reportStageTimesOutput = stageTimeSchema.extend({ item: id, behind });
309
+
310
+ export const contract = {
311
+ item: {
312
+ // Writes `boxxy.item.filed`, and one `boxxy.relation.added` per `blockedBy`.
313
+ file: tool(
314
+ 'item_file',
315
+ 'Files a new item of work. boxxy runs it through triage, design, build and review. Returns the item ID.',
316
+ )
317
+ .errors({ NOT_FOUND: { message: 'a blockedBy item does not exist', data: notFoundData } })
318
+ .input(itemFileInput)
319
+ .output(itemOutput),
320
+
321
+ // Writes `boxxy.item.edited`. The edit does not change a field that is absent.
322
+ edit: tool(
323
+ 'item_edit',
324
+ 'Replaces the given fields of an item. A field that is left out does not change.',
325
+ )
326
+ .errors(notFound)
327
+ .input(itemEditInput)
328
+ .output(itemOutput),
329
+
330
+ // Writes `boxxy.item.closed` and ends the item's live attempt in the same change. It is also
331
+ // the owner's confirm of a triage `declined` or `duplicate`. With `of`, the same change adds
332
+ // the relation `duplicate-of` from this item to `of`.
333
+ close: tool(
334
+ 'item_close',
335
+ 'Closes an item so that boxxy starts no more work on it, and ends the attempt that runs or sleeps on it.',
336
+ )
337
+ .errors(notFound)
338
+ .input(itemCloseInput)
339
+ .output(itemCloseOutput),
340
+
341
+ // Writes `boxxy.lever.recorded`. With no `phase`, on an item that triage holds as `declined`
342
+ // or `duplicate`, it sends the item back to triage.
343
+ retry: tool(
344
+ 'item_retry',
345
+ 'Lifts a hold on an item and resets its attempt counts. With phase, also sends the item back to that phase.',
346
+ )
347
+ .errors({
348
+ ...notFound,
349
+ UNKNOWN_PHASE: { message: 'the pipeline of the item has no such phase' },
350
+ })
351
+ .input(itemRetryInput)
352
+ .output(leverOutput),
353
+
354
+ // The owner knows the item, so this door takes the item only. Writes
355
+ // `boxxy.attempt.wake_requested`.
356
+ wake: tool(
357
+ 'item_wake',
358
+ 'Asks boxxy to wake the sleeping session of an item. The session resumes when a seat is free. Returns the atc session to attach.',
359
+ )
360
+ .errors(notFound)
361
+ .input(itemInput)
362
+ .output(itemWakeOutput),
363
+
364
+ // Notes are append-only. Writes `boxxy.item.noted` with the full text.
365
+ note: tool(
366
+ 'item_note',
367
+ 'Appends a note to an item. A note cannot be changed or removed. The next attempt of the item reads every note.',
368
+ )
369
+ .errors(notFound)
370
+ .input(itemNoteInput)
371
+ .output(itemNoteOutput),
372
+
373
+ get: tool(
374
+ 'item_get',
375
+ 'Returns one item with its position in the pipeline, its labels, notes, attempts, relations, evidence and pull requests.',
376
+ )
377
+ .errors(notFound)
378
+ .input(itemInput)
379
+ .output(itemDetail),
380
+
381
+ // 50 items per call at most, so one answer stays small for a model. `next` is the cursor.
382
+ list: tool('item_list', 'Lists items with their derived state and position, newest first.')
383
+ .input(itemListInput)
384
+ .output(itemListOutput),
385
+ },
386
+
387
+ relation: {
388
+ // Writes `boxxy.relation.added`. A `refused` answer is normal flow, so it is in the output.
389
+ add: tool(
390
+ 'relation_add',
391
+ 'Adds a relation from one item to another, such as "blocks". Refuses a relation that closes a cycle.',
392
+ )
393
+ .errors(notFound)
394
+ .input(relationAddInput)
395
+ .output(relationAddOutput),
396
+
397
+ // Writes `boxxy.relation.removed`.
398
+ remove: tool('relation_remove', 'Removes a relation between two items.')
399
+ .errors(notFound)
400
+ .input(relationRemoveInput)
401
+ .output(z.object({ removed: z.boolean() })),
402
+ },
403
+
404
+ attempt: {
405
+ // It takes no input: the server finds the session from the proven caller and answers with
406
+ // the latest attempt that the session carries. This is how every session learns its attempt
407
+ // ID. `closeOut` tells the agent which of the four close-outs its attempt takes.
408
+ current: tool(
409
+ 'attempt_current',
410
+ 'Returns the attempt that this session carries: its item, its phase, its input, and the schema of the close-out it must send.',
411
+ )
412
+ .input(emptyInput)
413
+ .output(attemptCurrentOutput),
414
+
415
+ // One procedure takes the close-out of every phase. The phase comes from the attempt row, and
416
+ // that phase kind's parser reports every problem at once, so `closeOut` is `unknown` on the
417
+ // wire. Writes `boxxy.attempt.closed_out`, and the attempt moves to `closing`.
418
+ closeOut: tool(
419
+ 'attempt_close_out',
420
+ 'Sends the close-out of the attempt that this session carries. The first valid close-out is final. A rejected answer lists every problem; fix them and send again.',
421
+ )
422
+ .input(attemptCloseOutInput)
423
+ .output(closeOutAnswerSchema),
424
+
425
+ // Writes `boxxy.evidence.recorded`. The fact ID is what a build close-out cites per criterion.
426
+ evidence: tool(
427
+ 'attempt_evidence',
428
+ 'Records one piece of evidence for the attempt of this session: a test, a CI run or a live check. Returns the evidence ID to cite in the close-out.',
429
+ )
430
+ .input(attemptEvidenceInput)
431
+ .output(attemptEvidenceOutput),
432
+
433
+ // A hook program calls it, as `boxxy hook session-start`, after it has fetched the skills.
434
+ // `missingSkills` names each listed skill that is absent after the fetch.
435
+ bindSession: oc.input(bindSessionInput).output(bindSessionOutput),
436
+
437
+ // A hook program calls it, as `boxxy hook post-tool-use`. It reads the forge before it
438
+ // writes; `retry` means the forge did not answer and the call wrote nothing.
439
+ recordPullRequest: oc.input(recordPullRequestInput).output(recordPullRequestOutput),
440
+ },
441
+
442
+ // Each lever writes one `boxxy.lever.recorded` per item.
443
+ plan: {
444
+ defer: tool(
445
+ 'plan_defer',
446
+ 'Holds an item back from dispatch until a time, or until plan_undefer.',
447
+ )
448
+ .errors(notFound)
449
+ .input(deferInput)
450
+ .output(leverOutput),
451
+
452
+ undefer: tool('plan_undefer', 'Ends the deferral of an item.')
453
+ .errors(notFound)
454
+ .input(itemInput)
455
+ .output(leverOutput),
456
+
457
+ expedite: tool(
458
+ 'plan_expedite',
459
+ 'Puts items at the front of their pools, in the order given. It stops no running attempt.',
460
+ )
461
+ .errors(notFound)
462
+ .input(expediteInput)
463
+ .output(z.object({ levers: z.array(count) })),
464
+
465
+ unexpedite: tool('plan_unexpedite', 'Ends the expedite of an item.')
466
+ .errors(notFound)
467
+ .input(itemInput)
468
+ .output(leverOutput),
469
+ },
470
+
471
+ runner: {
472
+ status: tool(
473
+ 'runner_status',
474
+ 'Returns what boxxy does now: each limit and its seats, each live or sleeping attempt with its atc session, stray sessions, the paused flag, the rules version, and how far the fact outbox is behind.',
475
+ )
476
+ .input(emptyInput)
477
+ .output(runnerStatusOutput),
478
+
479
+ // These two write the runner row and no fact.
480
+ pause: oc.input(emptyInput).output(z.object({ paused: z.literal(true) })),
481
+ resume: oc.input(emptyInput).output(z.object({ paused: z.literal(false) })),
482
+ },
483
+
484
+ outbox: {
485
+ // Clears the park mark of every parked outbox row, and wakes the sender.
486
+ retry: oc.input(emptyInput).output(z.object({ unparked: count })),
487
+ },
488
+
489
+ rules: {
490
+ // The server loads the given text with the phase kinds it runs, and answers, for each open
491
+ // item, the position it has now and the position it would get. It writes nothing. A file that
492
+ // does not load is normal flow, so `rejected` is in the output.
493
+ dryRun: oc.input(dryRunInput).output(dryRunOutput),
494
+ },
495
+
496
+ // Both read pepys, and only these two do. `item` is boxxy's own item ID.
497
+ report: {
498
+ cost: tool(
499
+ 'report_cost',
500
+ 'Returns what one item cost: one line per phase, product and model, with rework apart, and totals. The numbers come from pepys and can be behind; `behind` says by how many facts. A cost of null is not known.',
501
+ )
502
+ .errors(pepysErrors)
503
+ .input(itemInput)
504
+ .output(reportCostOutput),
505
+
506
+ stageTimes: tool(
507
+ 'report_stage_times',
508
+ 'Returns how long one item took in each of seven steps: triage, design, build, review, merge, release, deploy. Also the rework count and time, the wait for review, and the time its sessions slept. A step that has not happened is null.',
509
+ )
510
+ .errors(pepysErrors)
511
+ .input(itemInput)
512
+ .output(reportStageTimesOutput),
513
+ },
514
+ };
515
+
516
+ function tool(name: string, description: string) {
517
+ return oc.meta(mcp.tool({ name, description }));
518
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { type Access, access, type Caller, type ProcedureName } from './access.ts';
2
+ export type { BoxxyClient } from './boxxy-client.ts';
3
+ export { contract } from './contract.ts';