@ianwremmel/dispatch 0.32.1-bootstrap.0

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.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,60 @@
1
+ import type {AbstractCommand, Io} from '../command/index.mts';
2
+ import {parseOptions, assertEnv} from '../command/index.mts';
3
+ import type {Logger} from '../logger/index.mts';
4
+ import type {ChannelSink} from '../command/index.mts';
5
+ import {DispatchError} from '../errors/index.mts';
6
+
7
+ export interface ToolResult {
8
+ readonly content: readonly {readonly type: 'text'; readonly text: string}[];
9
+ readonly isError?: boolean;
10
+ }
11
+
12
+ export interface CallToolContext {
13
+ readonly env: NodeJS.ProcessEnv;
14
+ readonly log: Logger;
15
+ readonly channel?: ChannelSink | undefined;
16
+ }
17
+
18
+ /**
19
+ * Run one command from JSON tool input. Output written to `io` becomes the
20
+ * result text; a `DispatchError` (bad input, missing env) becomes an `isError`
21
+ * result rather than throwing, mirroring how the cli maps it to an exit code.
22
+ */
23
+ export async function callTool(
24
+ command: AbstractCommand,
25
+ args: Readonly<Record<string, unknown>>,
26
+ ctx: CallToolContext
27
+ ): Promise<ToolResult> {
28
+ let captured = '';
29
+ const io: Io = {
30
+ write: (chunk) => {
31
+ captured += chunk;
32
+ },
33
+ };
34
+
35
+ try {
36
+ const raw: Record<string, string | boolean> = {};
37
+ for (const [key, value] of Object.entries(args)) {
38
+ if (value === undefined || value === null) continue;
39
+ raw[key] =
40
+ typeof value === 'boolean'
41
+ ? value
42
+ : typeof value === 'number' || typeof value === 'string'
43
+ ? String(value)
44
+ : JSON.stringify(value);
45
+ }
46
+ const parsed = parseOptions(command.options, raw);
47
+ assertEnv(command.env, ctx.env);
48
+ await command.run(parsed, {
49
+ log: ctx.log,
50
+ env: ctx.env,
51
+ io,
52
+ channel: ctx.channel,
53
+ });
54
+ return {content: [{type: 'text', text: captured}]};
55
+ } catch (error) {
56
+ const text =
57
+ error instanceof DispatchError ? error.toString() : String(error);
58
+ return {content: [{type: 'text', text}], isError: true};
59
+ }
60
+ }
@@ -0,0 +1,83 @@
1
+ import {nowIso} from '../db/time.mts';
2
+ import {withDatabase} from '../db/index.mts';
3
+ import {FetchRequestStore, RefreshStore} from '../stores/index.mts';
4
+ import type {ScanPayload, TicketPayload} from '../stores/index.mts';
5
+ import type {ChannelWriter} from './channel.mts';
6
+
7
+ /**
8
+ * Push every instruction the graph owes the session, then every completion.
9
+ * Returns how many events went out. Delivery is recorded in the database, so a
10
+ * restart re-derives what is still owed rather than assuming a push landed.
11
+ */
12
+ export async function drainInstructions(
13
+ channel: ChannelWriter,
14
+ env: NodeJS.ProcessEnv,
15
+ now: () => string = nowIso
16
+ ): Promise<number> {
17
+ return withDatabase(undefined, env, async (db) => {
18
+ const requests = new FetchRequestStore(db);
19
+ const refreshes = new RefreshStore(db);
20
+ const at = now();
21
+ let sent = 0;
22
+
23
+ for (const request of await requests.undelivered()) {
24
+ if (request.kind === 'scan_project') {
25
+ const payload = request.payload as ScanPayload;
26
+ channel.push(
27
+ 'scan_project',
28
+ {
29
+ tracker: request.source,
30
+ projects: payload.projects.join(','),
31
+ cursor: payload.cursor,
32
+ },
33
+ scanBody(request.source, payload)
34
+ );
35
+ } else if (request.kind === 'fetch_ticket') {
36
+ const {ticket} = request.payload as TicketPayload;
37
+ channel.push(
38
+ 'fetch_ticket',
39
+ {tracker: request.source, ticket},
40
+ ticketBody(request.source, ticket)
41
+ );
42
+ } else {
43
+ const {ticket} = request.payload as TicketPayload;
44
+ channel.push(
45
+ 'refresh_ticket',
46
+ {tracker: request.source, ticket},
47
+ `Re-fetch ticket ${ticket} from ${request.source} and record it with dispatch ticket set — the graph's copy may be stale. If ${request.source} no longer has it, run: dispatch ticket missing --id ${ticket}`
48
+ );
49
+ }
50
+ await requests.markDelivered(request.id, at);
51
+ sent += 1;
52
+ }
53
+
54
+ for (const source of await refreshes.pendingCompletions()) {
55
+ channel.push(
56
+ 'refresh_complete',
57
+ {tracker: source},
58
+ `The ${source} project graph is complete. Stop fetching and report it built.`
59
+ );
60
+ await refreshes.markCompletionEmitted(source, at);
61
+ sent += 1;
62
+ }
63
+
64
+ return sent;
65
+ });
66
+ }
67
+
68
+ function scanBody(source: string, payload: ScanPayload): string {
69
+ const since =
70
+ payload.cursor === null ? '' : ` updated since ${payload.cursor}`;
71
+ return [
72
+ `Scan every ticket in ${payload.projects.join(', ')} on ${source}${since}.`,
73
+ 'Record each project, milestone, ticket, and dependency with the dispatch',
74
+ `commands, then run: dispatch refresh done --tracker ${source} --cursor <token>`,
75
+ ].join(' ');
76
+ }
77
+
78
+ function ticketBody(source: string, ticket: string): string {
79
+ return [
80
+ `Fetch ticket ${ticket} from ${source} and record it with dispatch ticket set.`,
81
+ `If ${source} has no such ticket, run: dispatch ticket missing --id ${ticket}`,
82
+ ].join(' ');
83
+ }
@@ -0,0 +1,5 @@
1
+ export * from './tools.mts';
2
+ export * from './dispatch.mts';
3
+ export * from './mcp.mts';
4
+ export * from './channel.mts';
5
+ export * from './drain.mts';
@@ -0,0 +1,267 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import readline from 'node:readline';
3
+ import type {Readable, Writable} from 'node:stream';
4
+
5
+ import type {CommandNode} from '../command/index.mts';
6
+ import {createLogger, streamSink} from '../logger/index.mts';
7
+ import type {Logger} from '../logger/index.mts';
8
+ import {buildTools} from './tools.mts';
9
+ import type {BuiltTools} from './tools.mts';
10
+ import {callTool} from './dispatch.mts';
11
+ import {JsonRpcError} from '../errors/index.mts';
12
+ import {ChannelWriter} from './channel.mts';
13
+ import {drainInstructions} from './drain.mts';
14
+
15
+ const PROTOCOL_VERSION = '2025-06-18';
16
+
17
+ /**
18
+ * The handshake reports the shipped plugin version, read from the manifest so
19
+ * the two can never disagree. A missing or unreadable manifest (a stripped-down
20
+ * install) degrades to 0.0.0 rather than failing the handshake.
21
+ */
22
+ async function serverInfo(): Promise<{name: string; version: string}> {
23
+ try {
24
+ const manifest = new URL(
25
+ '../../../.claude-plugin/plugin.json',
26
+ import.meta.url
27
+ );
28
+ const parsed = JSON.parse(await readFile(manifest, 'utf8')) as {
29
+ version?: unknown;
30
+ };
31
+ return {
32
+ name: 'dispatch',
33
+ version: typeof parsed.version === 'string' ? parsed.version : '0.0.0',
34
+ };
35
+ } catch {
36
+ return {name: 'dispatch', version: '0.0.0'};
37
+ }
38
+ }
39
+
40
+ interface JsonRpcRequest {
41
+ jsonrpc?: string;
42
+ id?: string | number | null;
43
+ method?: string;
44
+ params?: Record<string, unknown>;
45
+ }
46
+
47
+ interface RequestContext {
48
+ readonly tools: BuiltTools;
49
+ readonly env: NodeJS.ProcessEnv;
50
+ readonly log: Logger;
51
+ readonly serverInfo: {name: string; version: string};
52
+ readonly channel: ChannelWriter;
53
+ }
54
+
55
+ interface Handled {
56
+ readonly response: unknown;
57
+ readonly ranTool: boolean;
58
+ }
59
+
60
+ /**
61
+ * Serve MCP over newline-delimited JSON-RPC 2.0 on stdin/stdout until stdin
62
+ * closes. When `tick` is given, its `run` fires on the interval and after
63
+ * every tool call — the scheduling heartbeat riding the same channel.
64
+ */
65
+ export async function runMcpServer(opts: {
66
+ tree: CommandNode;
67
+ stdin: Readable;
68
+ stdout: Writable;
69
+ stderr: Writable;
70
+ env: NodeJS.ProcessEnv;
71
+ tick?: {
72
+ intervalMs: number;
73
+ run: (channel: ChannelWriter) => Promise<void>;
74
+ };
75
+ }): Promise<void> {
76
+ const channel = new ChannelWriter((payload) => {
77
+ opts.stdout.write(`${JSON.stringify(payload)}\n`);
78
+ });
79
+
80
+ const ctx: RequestContext = {
81
+ tools: buildTools(opts.tree),
82
+ env: opts.env,
83
+ log: createLogger(streamSink(opts.stderr)),
84
+ serverInfo: await serverInfo(),
85
+ channel,
86
+ };
87
+
88
+ // Every tick entrypoint — the timer and the post-tool-call run — shares one
89
+ // non-reentrancy guard: a tick requested while one runs coalesces into a
90
+ // single follow-up rather than overlapping DB work.
91
+ const tickState = {running: false, requested: false};
92
+ const tickQuietly = async (): Promise<void> => {
93
+ if (opts.tick === undefined) return;
94
+ if (tickState.running) {
95
+ tickState.requested = true;
96
+ return;
97
+ }
98
+ tickState.running = true;
99
+ try {
100
+ do {
101
+ tickState.requested = false;
102
+ try {
103
+ await opts.tick.run(channel);
104
+ } catch (error) {
105
+ ctx.log.error('scheduler tick failed', {
106
+ error: error instanceof Error ? error.message : String(error),
107
+ });
108
+ }
109
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the await above can re-enter tickQuietly, which sets `requested`; the analyzer cannot see that mutation
110
+ } while (tickState.requested);
111
+ } finally {
112
+ tickState.running = false;
113
+ }
114
+ };
115
+
116
+ // The timer drains as well as ticks: an idle session makes no tool calls
117
+ // while it waits on channel events, so an instruction the scheduler
118
+ // enqueues after the last call would otherwise sit undelivered until the
119
+ // session happens to call a tool — which, waiting on that very
120
+ // instruction, it never does. Tick first so the asks this beat enqueues
121
+ // are delivered in the same beat.
122
+ const timer =
123
+ opts.tick === undefined
124
+ ? undefined
125
+ : setInterval(() => {
126
+ void tickQuietly().then(() => drainQuietly(channel, ctx));
127
+ }, opts.tick.intervalMs);
128
+
129
+ try {
130
+ const rl = readline.createInterface({
131
+ input: opts.stdin,
132
+ crlfDelay: Infinity,
133
+ });
134
+ for await (const line of rl) {
135
+ if (line.trim() === '') continue;
136
+ const {response, ranTool} = await handleLine(line, ctx);
137
+ if (response !== undefined)
138
+ opts.stdout.write(`${JSON.stringify(response)}\n`);
139
+ if (ranTool) {
140
+ await drainQuietly(channel, ctx);
141
+ await tickQuietly();
142
+ }
143
+ }
144
+ } finally {
145
+ if (timer !== undefined) clearInterval(timer);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * A drain failure (an unwritable state dir, a locked file, a schema-version
151
+ * mismatch after an upgrade) must not take the read loop down with it — the
152
+ * graph is a rebuildable cache, and stdout is the JSON-RPC channel, so the
153
+ * failure goes to stderr and the undelivered rows stay undelivered for the
154
+ * next tool call to retry.
155
+ */
156
+ async function drainQuietly(
157
+ channel: ChannelWriter,
158
+ ctx: RequestContext
159
+ ): Promise<void> {
160
+ try {
161
+ await drainInstructions(channel, ctx.env);
162
+ } catch (error) {
163
+ ctx.log.error('channel drain failed', {
164
+ error: error instanceof Error ? error.message : String(error),
165
+ });
166
+ }
167
+ }
168
+
169
+ async function handleLine(line: string, ctx: RequestContext): Promise<Handled> {
170
+ let request: JsonRpcRequest;
171
+ try {
172
+ request = JSON.parse(line) as JsonRpcRequest;
173
+ } catch {
174
+ return {
175
+ response: errorResponse(null, new JsonRpcError(-32700, 'parse error')),
176
+ ranTool: false,
177
+ };
178
+ }
179
+
180
+ const ranTool = request.method === 'tools/call';
181
+
182
+ // A notification is defined by the absence of `id`, not by the method it
183
+ // names — an explicit `null` id is still a request per the current
184
+ // behavior below.
185
+ const isNotification = request.id === undefined;
186
+
187
+ if (isNotification) {
188
+ // Never reply to a notification, regardless of how dispatch fares.
189
+ try {
190
+ if (request.jsonrpc === '2.0' && typeof request.method === 'string') {
191
+ await dispatch(request.method, request.params ?? {}, ctx);
192
+ }
193
+ } catch {
194
+ // swallow: notifications get no response, even on failure
195
+ }
196
+ return {response: undefined, ranTool};
197
+ }
198
+
199
+ const id = request.id ?? null;
200
+ try {
201
+ if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
202
+ throw new JsonRpcError(-32600, 'invalid request');
203
+ }
204
+ const result = await dispatch(request.method, request.params ?? {}, ctx);
205
+ return {response: {jsonrpc: '2.0', id, result}, ranTool};
206
+ } catch (error) {
207
+ if (error instanceof JsonRpcError) {
208
+ return {response: errorResponse(id, error), ranTool};
209
+ }
210
+ // A non-JsonRpcError from a handler must not kill the read loop for
211
+ // later lines, so it becomes an internal-error response instead of a
212
+ // rethrow.
213
+ return {
214
+ response: errorResponse(id, new JsonRpcError(-32603, 'internal error')),
215
+ ranTool,
216
+ };
217
+ }
218
+ }
219
+
220
+ async function dispatch(
221
+ method: string,
222
+ params: Record<string, unknown>,
223
+ ctx: RequestContext
224
+ ): Promise<unknown> {
225
+ switch (method) {
226
+ case 'initialize':
227
+ return {
228
+ protocolVersion: PROTOCOL_VERSION,
229
+ capabilities: {tools: {}, experimental: {'claude/channel': {}}},
230
+ serverInfo: ctx.serverInfo,
231
+ };
232
+ case 'notifications/initialized':
233
+ return undefined;
234
+ case 'tools/list':
235
+ return {tools: ctx.tools.defs};
236
+ case 'tools/call': {
237
+ const name = params.name;
238
+ if (typeof name !== 'string') {
239
+ throw new JsonRpcError(-32602, 'invalid params: name is required');
240
+ }
241
+ const command = ctx.tools.byName.get(name);
242
+ if (command === undefined) {
243
+ throw new JsonRpcError(-32602, `unknown tool: ${name}`);
244
+ }
245
+ const args =
246
+ (params.arguments as Record<string, unknown> | undefined) ?? {};
247
+ return callTool(command, args, {
248
+ env: ctx.env,
249
+ log: ctx.log,
250
+ channel: ctx.channel,
251
+ });
252
+ }
253
+ default:
254
+ throw new JsonRpcError(-32601, `method not found: ${method}`);
255
+ }
256
+ }
257
+
258
+ function errorResponse(
259
+ id: string | number | null,
260
+ error: JsonRpcError
261
+ ): unknown {
262
+ return {
263
+ jsonrpc: '2.0',
264
+ id,
265
+ error: {code: error.code, message: error.message},
266
+ };
267
+ }
@@ -0,0 +1,77 @@
1
+ import type {CommandNode, AbstractCommand, Option} from '../command/index.mts';
2
+ import {resolveTransports} from '../command/index.mts';
3
+
4
+ interface PropertySchema {
5
+ type: 'string' | 'number' | 'boolean';
6
+ description: string;
7
+ enum?: readonly string[];
8
+ default?: string | number | boolean;
9
+ }
10
+
11
+ export interface JsonSchema {
12
+ readonly type: 'object';
13
+ readonly properties: Record<string, PropertySchema>;
14
+ readonly required?: readonly string[];
15
+ }
16
+
17
+ export interface ToolDef {
18
+ readonly name: string;
19
+ readonly description: string;
20
+ readonly inputSchema: JsonSchema;
21
+ }
22
+
23
+ export interface BuiltTools {
24
+ readonly defs: readonly ToolDef[];
25
+ readonly byName: ReadonlyMap<string, AbstractCommand>;
26
+ }
27
+
28
+ /** Walk the command tree into MCP tool defs plus a name -> command lookup. */
29
+ export function buildTools(tree: CommandNode): BuiltTools {
30
+ const defs: ToolDef[] = [];
31
+ const byName = new Map<string, AbstractCommand>();
32
+ walk(tree, [], defs, byName);
33
+ return {defs, byName};
34
+ }
35
+
36
+ function walk(
37
+ node: CommandNode,
38
+ path: string[],
39
+ defs: ToolDef[],
40
+ byName: Map<string, AbstractCommand>
41
+ ): void {
42
+ const command = node.command;
43
+ if (command !== undefined && resolveTransports(command).mcp) {
44
+ const name = path.join('_');
45
+ defs.push({
46
+ name,
47
+ description: command.summary,
48
+ inputSchema: inputSchema(command),
49
+ });
50
+ byName.set(name, command);
51
+ }
52
+ for (const [segment, child] of node.children) {
53
+ walk(child, [...path, segment], defs, byName);
54
+ }
55
+ }
56
+
57
+ function inputSchema(command: AbstractCommand): JsonSchema {
58
+ const properties: Record<string, PropertySchema> = {};
59
+ const required: string[] = [];
60
+ for (const [key, option] of Object.entries(command.options)) {
61
+ properties[key] = propertySchema(option);
62
+ if (option.required) required.push(key);
63
+ }
64
+ return required.length > 0
65
+ ? {type: 'object', properties, required}
66
+ : {type: 'object', properties};
67
+ }
68
+
69
+ function propertySchema(option: Option): PropertySchema {
70
+ const schema: PropertySchema = {
71
+ type: option.type,
72
+ description: option.description,
73
+ };
74
+ if (option.choices !== undefined) schema.enum = option.choices;
75
+ if (option.default !== undefined) schema.default = option.default;
76
+ return schema;
77
+ }
@@ -0,0 +1,8 @@
1
+ # model
2
+
3
+ Pure domain types and vocabulary. No SQL.
4
+
5
+ - `status.mts` — the tracker-neutral enums (`Status`, `TargetKind`, `PrOrigin`,
6
+ `Kind`, `OutcomeKind`), their guards, `GROUP_OF`, and `RESOLVED_STATUSES`.
7
+ - `types.mts` — the domain model interfaces (`Project`, `Ticket`, `Pr`, …) the
8
+ stores map rows to and from.
@@ -0,0 +1,3 @@
1
+ export * from './repo-caps.mts';
2
+ export * from './status.mts';
3
+ export * from './types.mts';
@@ -0,0 +1,95 @@
1
+ import {assertUsage} from '../errors/index.mts';
2
+
3
+ /**
4
+ * How many PRs one repo may have open at once when nothing overrides it. An
5
+ * open PR holds third-party resources — preview stacks, cloud quota — for as
6
+ * long as it stays open, which no claim-based budget bounds: a worker releases
7
+ * its claim the moment it yields to a wait.
8
+ */
9
+ export const DEFAULT_MAX_OPEN_PRS = 5;
10
+
11
+ /**
12
+ * How many of one repo's PRs may have CI running at once when nothing
13
+ * overrides it. Deliberately coarse: a PR with twenty parallel jobs counts as
14
+ * one, because the failure being prevented is "too much in flight at once"
15
+ * rather than exact job accounting.
16
+ */
17
+ export const DEFAULT_MAX_IN_FLIGHT_BUILDS = 3;
18
+
19
+ export const REPO_CAPS = ['open-prs', 'in-flight-builds'] as const;
20
+ export type RepoCap = (typeof REPO_CAPS)[number];
21
+
22
+ /** Both caps, each a global default plus per-repo overrides. */
23
+ export interface RepoCapPolicy {
24
+ readonly openPrs: number;
25
+ readonly inFlightBuilds: number;
26
+ readonly openPrsByRepo: Readonly<Record<string, number>>;
27
+ readonly inFlightBuildsByRepo: Readonly<Record<string, number>>;
28
+ }
29
+
30
+ export const DEFAULT_REPO_CAPS: RepoCapPolicy = Object.freeze({
31
+ openPrs: DEFAULT_MAX_OPEN_PRS,
32
+ inFlightBuilds: DEFAULT_MAX_IN_FLIGHT_BUILDS,
33
+ openPrsByRepo: Object.freeze({}),
34
+ inFlightBuildsByRepo: Object.freeze({}),
35
+ });
36
+
37
+ /** The limit in force for one repo: its override, else the global default. */
38
+ export function limitFor(
39
+ policy: RepoCapPolicy,
40
+ repo: string,
41
+ cap: RepoCap
42
+ ): number {
43
+ return cap === 'open-prs'
44
+ ? (policy.openPrsByRepo[repo] ?? policy.openPrs)
45
+ : (policy.inFlightBuildsByRepo[repo] ?? policy.inFlightBuilds);
46
+ }
47
+
48
+ /**
49
+ * A cap must be a whole number of 0 or more. A negative one is never reached
50
+ * from below, so it would refuse every PR item forever — and the build cap's
51
+ * only promise is that it drains without an agent.
52
+ */
53
+ export function assertCapLimit(value: number, option: string): number {
54
+ assertUsage(
55
+ Number.isInteger(value) && value >= 0,
56
+ `option ${option} expects a whole number of 0 or more, got "${String(value)}"`
57
+ );
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * Parse an override list — `owner/repo=2,owner/other=0` — into a limit per
63
+ * repo. The option name rides along so a malformed entry names the flag its
64
+ * writer has to fix.
65
+ */
66
+ export function parseRepoLimits(
67
+ spec: string,
68
+ option: string
69
+ ): Record<string, number> {
70
+ const limits: Record<string, number> = {};
71
+ for (const part of spec.split(',')) {
72
+ const entry = part.trim();
73
+ if (entry === '') continue;
74
+ const split = entry.indexOf('=');
75
+ assertUsage(
76
+ split > 0,
77
+ `option ${option} expects "owner/repo=<number>" entries, got "${entry}"`
78
+ );
79
+ const repo = entry.slice(0, split).trim();
80
+ const value = entry.slice(split + 1).trim();
81
+ assertUsage(
82
+ /^[^/\s]+\/[^/\s]+$/u.test(repo),
83
+ `option ${option} expects an owner/repo before "=", got "${repo}"`
84
+ );
85
+ // An explicit digits-only match, because `Number('')` is 0: an entry that
86
+ // forgot its limit would otherwise read as a cap of zero and wedge the
87
+ // repo it names.
88
+ assertUsage(
89
+ /^\d+$/u.test(value),
90
+ `option ${option} expects a whole number of 0 or more for ${repo}, got "${value}"`
91
+ );
92
+ limits[repo] = Number(value);
93
+ }
94
+ return limits;
95
+ }
@@ -0,0 +1,91 @@
1
+ export const KINDS = [
2
+ 'project',
3
+ 'milestone',
4
+ 'ticket',
5
+ 'pr',
6
+ 'unknown',
7
+ ] as const;
8
+ export type Kind = (typeof KINDS)[number];
9
+ export type ConcreteKind = Exclude<Kind, 'unknown'>;
10
+
11
+ export const STATUSES = [
12
+ 'backlog',
13
+ 'paused',
14
+ 'awaiting-external',
15
+ 'available',
16
+ 'in-progress',
17
+ 'in-review',
18
+ 'finished',
19
+ 'delivered',
20
+ 'verified',
21
+ 'canceled',
22
+ ] as const;
23
+ export type Status = (typeof STATUSES)[number];
24
+
25
+ export const STATUS_GROUPS = [
26
+ 'backlog',
27
+ 'unstarted',
28
+ 'started',
29
+ 'completed',
30
+ 'canceled',
31
+ ] as const;
32
+ export type StatusGroup = (typeof STATUS_GROUPS)[number];
33
+
34
+ export const GROUP_OF: Readonly<Record<Status, StatusGroup>> = Object.freeze({
35
+ backlog: 'backlog',
36
+ paused: 'backlog',
37
+ 'awaiting-external': 'backlog',
38
+ available: 'unstarted',
39
+ 'in-progress': 'started',
40
+ 'in-review': 'started',
41
+ finished: 'started',
42
+ delivered: 'started',
43
+ verified: 'completed',
44
+ canceled: 'canceled',
45
+ });
46
+
47
+ /** Statuses that stop a ticket blocking its dependents (effective blocking). */
48
+ export const RESOLVED_STATUSES: ReadonlySet<Status> = new Set<Status>([
49
+ 'verified',
50
+ 'canceled',
51
+ ]);
52
+
53
+ export function isStatus(value: string): value is Status {
54
+ return (STATUSES as readonly string[]).includes(value);
55
+ }
56
+
57
+ export function isResolved(status: Status): boolean {
58
+ return RESOLVED_STATUSES.has(status);
59
+ }
60
+
61
+ export const TARGET_KINDS = ['pr', 'verification', 'human-only'] as const;
62
+ export type TargetKind = (typeof TARGET_KINDS)[number];
63
+
64
+ export function isTargetKind(value: string): value is TargetKind {
65
+ return (TARGET_KINDS as readonly string[]).includes(value);
66
+ }
67
+
68
+ export const PR_ORIGINS = ['prompt', 'ticket', 'adopted', 'resumed'] as const;
69
+ export type PrOrigin = (typeof PR_ORIGINS)[number];
70
+
71
+ export function isPrOrigin(value: string): value is PrOrigin {
72
+ return (PR_ORIGINS as readonly string[]).includes(value);
73
+ }
74
+
75
+ export const OUTCOMES = [
76
+ 'verified',
77
+ 'canceled',
78
+ 'delivered',
79
+ 'human-blocked',
80
+ 'decomposed',
81
+ 'failed',
82
+ ] as const;
83
+ export type OutcomeKind = (typeof OUTCOMES)[number];
84
+
85
+ export function isOutcome(value: string): value is OutcomeKind {
86
+ return (OUTCOMES as readonly string[]).includes(value);
87
+ }
88
+
89
+ export const STATUS_LIST = STATUSES.join(', ');
90
+ export const TARGET_KIND_LIST = TARGET_KINDS.join(', ');
91
+ export const PR_ORIGIN_LIST = PR_ORIGINS.join(', ');