@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.
- package/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- 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,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,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(', ');
|