@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,88 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {DataError, ensure} from '../../lib/errors/index.mts';
|
|
5
|
+
import {correlateSession} from '../../lib/schedule/index.mts';
|
|
6
|
+
import {CoordinationStore} from '../../lib/stores/index.mts';
|
|
7
|
+
|
|
8
|
+
const options = {
|
|
9
|
+
node: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
description:
|
|
12
|
+
'The ticket, PR item, or milestone this agent was launched for.',
|
|
13
|
+
positional: false,
|
|
14
|
+
required: true,
|
|
15
|
+
},
|
|
16
|
+
db: DB_OPTION,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Prove that the scheduler actually dispatched this agent, and exit non-zero
|
|
21
|
+
* if it did not.
|
|
22
|
+
*
|
|
23
|
+
* A worker's authority to run comes from a claim: the scheduler takes one
|
|
24
|
+
* before it emits a work order, and the claim is also the admission that
|
|
25
|
+
* bounds how many agents run at once. An agent launched any other way holds
|
|
26
|
+
* no claim, so it spends no budget and nothing caps how many of them there are.
|
|
27
|
+
*
|
|
28
|
+
* That is not hypothetical. A session whose channel went unacknowledged
|
|
29
|
+
* received no work orders, decided on its own what to run, and launched
|
|
30
|
+
* twelve workers against a cap of three — every one of them unclaimed. The
|
|
31
|
+
* skill told it not to. Instructions are not a bound, so this is the bound:
|
|
32
|
+
* a worker asks first and exits when the answer is no, and an unbudgeted
|
|
33
|
+
* launch costs one command instead of an agent.
|
|
34
|
+
*/
|
|
35
|
+
export class Command extends AbstractCommand {
|
|
36
|
+
readonly name = 'check';
|
|
37
|
+
readonly summary =
|
|
38
|
+
'Verify this session holds a live claim on a node; exit non-zero if not.';
|
|
39
|
+
readonly env = [];
|
|
40
|
+
readonly options = options;
|
|
41
|
+
|
|
42
|
+
async run(
|
|
43
|
+
parsed: ParsedOptions<typeof options>,
|
|
44
|
+
ctx: CommandContext
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
47
|
+
// Deliberately no --session. The whole point is to establish who the
|
|
48
|
+
// caller *is*, and an id it supplies is an assertion, not evidence:
|
|
49
|
+
// `dispatch claim status` prints the holders, so any worker could name
|
|
50
|
+
// one and pass. Correlation from the environment is the only identity
|
|
51
|
+
// the caller does not choose.
|
|
52
|
+
const caller = await correlateSession(db, ctx.env, undefined);
|
|
53
|
+
ensure(
|
|
54
|
+
caller !== null,
|
|
55
|
+
() =>
|
|
56
|
+
new DataError(
|
|
57
|
+
`no live server correlates to this session, so nothing can hold a claim on "${parsed.node}"`,
|
|
58
|
+
{
|
|
59
|
+
hint: 'you were not dispatched by a scheduler — or the one that dispatched you has died, and its claim is about to be swept. Stop, and report that work was launched without a work order.',
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const held = (await new CoordinationStore(db).claims()).find(
|
|
65
|
+
(claim) => claim.node === parsed.node
|
|
66
|
+
);
|
|
67
|
+
ensure(
|
|
68
|
+
held !== undefined,
|
|
69
|
+
() =>
|
|
70
|
+
new DataError(`no claim is held on "${parsed.node}"`, {
|
|
71
|
+
hint: 'you were not dispatched for this node. Stop without doing any work; a worker runs only on a claim the scheduler took for it.',
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
ensure(
|
|
75
|
+
held.session === caller,
|
|
76
|
+
() =>
|
|
77
|
+
new DataError(
|
|
78
|
+
`the claim on "${parsed.node}" belongs to ${held.session}, not this session`,
|
|
79
|
+
{
|
|
80
|
+
hint: 'another session is working this node. Stop; two workers on one node undo each other.',
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
ctx.io.write(`claim ${parsed.node} ${caller}\n`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {DataError, ensure} from '../../lib/errors/index.mts';
|
|
5
|
+
import {DEFAULT_STALE_AFTER_SECONDS} from '../../lib/graph/index.mts';
|
|
6
|
+
import {correlateSession} from '../../lib/schedule/index.mts';
|
|
7
|
+
import {CoordinationStore} from '../../lib/stores/index.mts';
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
db: DB_OPTION,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Judge a worker launch: read the would-be worker's prompt on stdin and pass
|
|
15
|
+
* only if it names a node this session holds a live claim on.
|
|
16
|
+
*
|
|
17
|
+
* This is the deterministic half of the PreToolUse hook. The hook itself
|
|
18
|
+
* decides nothing — it forwards the prompt here and relays the verdict — so
|
|
19
|
+
* the rule lives in the CLI where it is tested, versioned, and shared with
|
|
20
|
+
* `claim check`.
|
|
21
|
+
*
|
|
22
|
+
* Matching is by whole token against the session's own live claims, not by
|
|
23
|
+
* parsing the prompt: the claims are few and their ids unique, while prompt
|
|
24
|
+
* shapes change. A prompt that names none of them is a launch the scheduler
|
|
25
|
+
* never authorized, whoever composed it. A prompt that names a claimed node
|
|
26
|
+
* while instructing work on another passes here — the worker's own
|
|
27
|
+
* `claim check` on the node it actually works is the layer that catches
|
|
28
|
+
* that, and this hook exists to stop wholesale unbudgeted launches, not to
|
|
29
|
+
* parse intent.
|
|
30
|
+
*
|
|
31
|
+
* CLI-only: it reads the prompt from stdin, and the hook runs in a shell.
|
|
32
|
+
*/
|
|
33
|
+
export class Command extends AbstractCommand {
|
|
34
|
+
readonly name = 'guard';
|
|
35
|
+
readonly summary =
|
|
36
|
+
'Pass iff stdin names a node this session holds a live claim on.';
|
|
37
|
+
readonly env = [];
|
|
38
|
+
readonly options = options;
|
|
39
|
+
override readonly transports = {mcp: false};
|
|
40
|
+
|
|
41
|
+
async run(
|
|
42
|
+
parsed: ParsedOptions<typeof options>,
|
|
43
|
+
ctx: CommandContext
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
for await (const chunk of process.stdin) {
|
|
47
|
+
chunks.push(chunk as Buffer);
|
|
48
|
+
}
|
|
49
|
+
const prompt = Buffer.concat(chunks).toString('utf8');
|
|
50
|
+
|
|
51
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
52
|
+
const session = await correlateSession(db, ctx.env, undefined);
|
|
53
|
+
ensure(
|
|
54
|
+
session !== null,
|
|
55
|
+
() =>
|
|
56
|
+
new DataError('no live server correlates to this session', {
|
|
57
|
+
hint: 'a worker launch is authorized by a claim, and only a session with a live server can hold one.',
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
const held = (
|
|
61
|
+
await new CoordinationStore(db).liveClaims({
|
|
62
|
+
now: nowIso(),
|
|
63
|
+
staleAfterSeconds: DEFAULT_STALE_AFTER_SECONDS,
|
|
64
|
+
})
|
|
65
|
+
).filter((claim) => claim.session === session);
|
|
66
|
+
// Whole-token match, not substring: a claim on CLC-7 must not
|
|
67
|
+
// authorize a launch for CLC-77. A node id is bounded by any character
|
|
68
|
+
// that cannot appear inside one.
|
|
69
|
+
const boundary = /[A-Za-z0-9/#_.-]/u;
|
|
70
|
+
const named = held.find((claim) => {
|
|
71
|
+
let from = 0;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const at = prompt.indexOf(claim.node, from);
|
|
74
|
+
if (at === -1) return false;
|
|
75
|
+
const before = prompt.slice(Math.max(0, at - 1), at);
|
|
76
|
+
const afterIndex = at + claim.node.length;
|
|
77
|
+
const after = prompt.slice(afterIndex, afterIndex + 1);
|
|
78
|
+
if (!boundary.test(before) && !boundary.test(after)) return true;
|
|
79
|
+
from = at + 1;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
ensure(
|
|
83
|
+
named !== undefined,
|
|
84
|
+
() =>
|
|
85
|
+
new DataError(
|
|
86
|
+
'the launch prompt names no node this session holds a claim on',
|
|
87
|
+
{
|
|
88
|
+
hint: 'workers are launched from work orders, which name a node the scheduler already claimed. Wait for a work order instead of composing a launch.',
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
ctx.io.write(`guard pass ${named.node}\n`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {DEFAULT_STALE_AFTER_SECONDS} from '../../lib/graph/index.mts';
|
|
5
|
+
import {CoordinationStore} from '../../lib/stores/index.mts';
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
db: DB_OPTION,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many claims are live right now, and on what.
|
|
13
|
+
*
|
|
14
|
+
* The count is what an outside supervisor needs to answer "is this session
|
|
15
|
+
* holding work" — a question it cannot get from `dispatch status`, whose
|
|
16
|
+
* `in-flight` counts items classified in-flight (a started ticket, a watched
|
|
17
|
+
* PR) rather than agents currently running.
|
|
18
|
+
*/
|
|
19
|
+
export class Command extends AbstractCommand {
|
|
20
|
+
readonly name = 'status';
|
|
21
|
+
readonly summary = 'Report the live claims and what holds them.';
|
|
22
|
+
readonly env = [];
|
|
23
|
+
readonly options = options;
|
|
24
|
+
|
|
25
|
+
async run(
|
|
26
|
+
parsed: ParsedOptions<typeof options>,
|
|
27
|
+
ctx: CommandContext
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
30
|
+
const coordination = new CoordinationStore(db);
|
|
31
|
+
const now = nowIso();
|
|
32
|
+
const held = await coordination.inFlightCount({
|
|
33
|
+
now,
|
|
34
|
+
staleAfterSeconds: DEFAULT_STALE_AFTER_SECONDS,
|
|
35
|
+
});
|
|
36
|
+
ctx.io.write(`claims held=${String(held)}\n`);
|
|
37
|
+
// Only the claims the count includes. Listing every row while counting
|
|
38
|
+
// the live ones reports two different truths in one output — a stale
|
|
39
|
+
// claim would print as if held and contradict the header a consumer
|
|
40
|
+
// parses.
|
|
41
|
+
for (const claim of await coordination.liveClaims({
|
|
42
|
+
now,
|
|
43
|
+
staleAfterSeconds: DEFAULT_STALE_AFTER_SECONDS,
|
|
44
|
+
})) {
|
|
45
|
+
ctx.io.write(`claim ${claim.node} session=${claim.session}\n`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {RefreshService} from '../../lib/refresh/index.mts';
|
|
5
|
+
import {EdgeStore} from '../../lib/stores/index.mts';
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
blocker: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'The node that must resolve first.',
|
|
11
|
+
positional: false,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
blocked: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'The node that waits on it.',
|
|
17
|
+
positional: false,
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
db: DB_OPTION,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export class Command extends AbstractCommand {
|
|
24
|
+
readonly name = 'add';
|
|
25
|
+
readonly summary = 'Record that one node blocks another.';
|
|
26
|
+
readonly env = [];
|
|
27
|
+
readonly options = options;
|
|
28
|
+
|
|
29
|
+
async run(
|
|
30
|
+
parsed: ParsedOptions<typeof options>,
|
|
31
|
+
ctx: CommandContext
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
34
|
+
const added = await new EdgeStore(db).addEdge(
|
|
35
|
+
parsed.blocker,
|
|
36
|
+
parsed.blocked
|
|
37
|
+
);
|
|
38
|
+
await new RefreshService(db).reconcile();
|
|
39
|
+
ctx.io.write(
|
|
40
|
+
`edge ${parsed.blocker} -> ${parsed.blocked} added=${String(added)}\n`
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {RefreshService} from '../../lib/refresh/index.mts';
|
|
5
|
+
import {EdgeStore} from '../../lib/stores/index.mts';
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
blocker: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'The node that must resolve first.',
|
|
11
|
+
positional: false,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
blocked: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'The node that waits on it.',
|
|
17
|
+
positional: false,
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
db: DB_OPTION,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export class Command extends AbstractCommand {
|
|
24
|
+
readonly name = 'rm';
|
|
25
|
+
readonly summary = 'Remove a blocking edge between two nodes.';
|
|
26
|
+
readonly env = [];
|
|
27
|
+
readonly options = options;
|
|
28
|
+
|
|
29
|
+
async run(
|
|
30
|
+
parsed: ParsedOptions<typeof options>,
|
|
31
|
+
ctx: CommandContext
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
34
|
+
const existed = await new EdgeStore(db).removeEdge(
|
|
35
|
+
parsed.blocker,
|
|
36
|
+
parsed.blocked
|
|
37
|
+
);
|
|
38
|
+
await new RefreshService(db).reconcile();
|
|
39
|
+
ctx.io.write(
|
|
40
|
+
`removed edge ${parsed.blocker} -> ${parsed.blocked} existed=${String(existed)}\n`
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {RefreshService} from '../../lib/refresh/index.mts';
|
|
5
|
+
import {EdgeStore} from '../../lib/stores/index.mts';
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
node: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'The node whose edges are being redeclared.',
|
|
11
|
+
positional: false,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
direction: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Which side to replace.',
|
|
17
|
+
positional: false,
|
|
18
|
+
required: true,
|
|
19
|
+
choices: ['blockers', 'blocks'],
|
|
20
|
+
},
|
|
21
|
+
others: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description:
|
|
24
|
+
'Comma-separated node ids; empty clears the direction. Only edges to ' +
|
|
25
|
+
'tickets are replaced — PR items and milestones are left alone.',
|
|
26
|
+
positional: false,
|
|
27
|
+
required: false,
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
db: DB_OPTION,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export class Command extends AbstractCommand {
|
|
34
|
+
readonly name = 'set';
|
|
35
|
+
readonly summary = 'Replace every edge on one side of a node.';
|
|
36
|
+
readonly env = [];
|
|
37
|
+
readonly options = options;
|
|
38
|
+
|
|
39
|
+
async run(
|
|
40
|
+
parsed: ParsedOptions<typeof options>,
|
|
41
|
+
ctx: CommandContext
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const others = parsed.others
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((id) => id.trim())
|
|
46
|
+
.filter((id) => id !== '');
|
|
47
|
+
|
|
48
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
49
|
+
await new EdgeStore(db).setEdges(parsed.node, parsed.direction, others);
|
|
50
|
+
await new RefreshService(db).reconcile();
|
|
51
|
+
ctx.io.write(
|
|
52
|
+
`edges of ${parsed.node} (${parsed.direction}) = ${others.join(',')}\n`
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {AbstractCommand} from '../lib/command/index.mts';
|
|
2
|
+
import type {ParsedOptions, CommandContext} from '../lib/command/index.mts';
|
|
3
|
+
|
|
4
|
+
const options = {
|
|
5
|
+
who: {
|
|
6
|
+
type: 'string',
|
|
7
|
+
description: 'Who to greet.',
|
|
8
|
+
positional: true,
|
|
9
|
+
required: false,
|
|
10
|
+
default: 'world',
|
|
11
|
+
},
|
|
12
|
+
loud: {
|
|
13
|
+
type: 'boolean',
|
|
14
|
+
description: 'Shout the greeting.',
|
|
15
|
+
positional: false,
|
|
16
|
+
required: false,
|
|
17
|
+
},
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export class Command extends AbstractCommand {
|
|
21
|
+
readonly name = 'greet';
|
|
22
|
+
readonly summary = 'Print a friendly greeting.';
|
|
23
|
+
readonly env = [];
|
|
24
|
+
readonly options = options;
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
27
|
+
async run(
|
|
28
|
+
parsed: ParsedOptions<typeof options>,
|
|
29
|
+
ctx: CommandContext
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const message = `hello ${parsed.who}`;
|
|
32
|
+
ctx.io.write(`${parsed.loud ? message.toUpperCase() : message}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {DataError, ensure} from '../../lib/errors/index.mts';
|
|
5
|
+
import {SessionStore} from '../../lib/stores/index.mts';
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
server: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'Registry id from the probe event being acknowledged.',
|
|
11
|
+
positional: false,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
db: DB_OPTION,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export class Command extends AbstractCommand {
|
|
18
|
+
readonly name = 'ack';
|
|
19
|
+
readonly summary = 'Acknowledge a channel probe, unlocking work orders.';
|
|
20
|
+
readonly env = [];
|
|
21
|
+
readonly options = options;
|
|
22
|
+
|
|
23
|
+
async run(
|
|
24
|
+
parsed: ParsedOptions<typeof options>,
|
|
25
|
+
ctx: CommandContext
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
28
|
+
const acked = await new SessionStore(db).ack(
|
|
29
|
+
parsed.server,
|
|
30
|
+
ctx.env.CLAUDE_CODE_SESSION_ID ?? null,
|
|
31
|
+
nowIso()
|
|
32
|
+
);
|
|
33
|
+
ensure(
|
|
34
|
+
acked,
|
|
35
|
+
() =>
|
|
36
|
+
new DataError(`no server registered as "${parsed.server}"`, {
|
|
37
|
+
hint: 'pass the registry id exactly as the probe event carried it.',
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
ctx.io.write(`acked ${parsed.server}\n`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
4
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
5
|
+
|
|
6
|
+
const options = {
|
|
7
|
+
nonce: {
|
|
8
|
+
type: 'string',
|
|
9
|
+
description:
|
|
10
|
+
'Token to echo back; one is generated when omitted. Match it against the pong to be sure they are the same round trip.',
|
|
11
|
+
positional: false,
|
|
12
|
+
required: false,
|
|
13
|
+
},
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ask the server to push a `pong` into the session, and report the nonce it
|
|
18
|
+
* used. Whether that pong arrives is the whole answer to "does this channel
|
|
19
|
+
* work".
|
|
20
|
+
*
|
|
21
|
+
* Everything else the server pushes is indirect evidence. A probe goes
|
|
22
|
+
* unanswered for two indistinguishable reasons — the runner never delivered
|
|
23
|
+
* it, or the session received it and did nothing — and an unacknowledged
|
|
24
|
+
* session row cannot tell them apart. This can: the tool result proves the
|
|
25
|
+
* call reached the server, so a missing pong isolates the failure to
|
|
26
|
+
* delivery.
|
|
27
|
+
*
|
|
28
|
+
* Over the CLI there is no session to push to, and the command says so rather
|
|
29
|
+
* than pretending to test anything.
|
|
30
|
+
*/
|
|
31
|
+
export class Command extends AbstractCommand {
|
|
32
|
+
readonly name = 'ping';
|
|
33
|
+
readonly summary =
|
|
34
|
+
'Push a pong through the channel to prove it reaches the session.';
|
|
35
|
+
readonly env = [];
|
|
36
|
+
readonly options = options;
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- the contract is async; this command only writes
|
|
39
|
+
async run(
|
|
40
|
+
parsed: ParsedOptions<typeof options>,
|
|
41
|
+
ctx: CommandContext
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const nonce = parsed.nonce ?? randomUUID();
|
|
44
|
+
|
|
45
|
+
if (ctx.channel === undefined) {
|
|
46
|
+
ctx.io.write(
|
|
47
|
+
`ping ${nonce} no-channel\nThis ran outside an MCP server, so there is no session to push to. Call the \`mcp_ping\` tool instead.\n`
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ctx.channel.push(
|
|
53
|
+
'pong',
|
|
54
|
+
{nonce},
|
|
55
|
+
`pong ${nonce} — this text was pushed over the channel, not returned by the tool call. Seeing it proves the channel reaches this session.`
|
|
56
|
+
);
|
|
57
|
+
ctx.io.write(
|
|
58
|
+
`ping ${nonce} pushed\nA pong carrying nonce ${nonce} went out over the channel. If it does not appear, the runner is not delivering channel events and every push the server makes is being dropped.\n`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {AbstractCommand} from '../../lib/command/index.mts';
|
|
2
|
+
import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
|
|
3
|
+
import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
|
|
4
|
+
import {DEFAULT_STALE_AFTER_SECONDS} from '../../lib/graph/index.mts';
|
|
5
|
+
import {withLiveProcesses} from '../../lib/liveness/index.mts';
|
|
6
|
+
import {SessionStore} from '../../lib/stores/index.mts';
|
|
7
|
+
|
|
8
|
+
const options = {
|
|
9
|
+
server: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
description:
|
|
12
|
+
'Answer for one registry id instead of correlating by session.',
|
|
13
|
+
positional: false,
|
|
14
|
+
required: false,
|
|
15
|
+
},
|
|
16
|
+
db: DB_OPTION,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export class Command extends AbstractCommand {
|
|
20
|
+
readonly name = 'status';
|
|
21
|
+
readonly summary =
|
|
22
|
+
'Report whether this session has an acknowledged channel server.';
|
|
23
|
+
readonly env = [];
|
|
24
|
+
readonly options = options;
|
|
25
|
+
|
|
26
|
+
async run(
|
|
27
|
+
parsed: ParsedOptions<typeof options>,
|
|
28
|
+
ctx: CommandContext
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
await withDatabase(parsed.db, ctx.env, async (db) => {
|
|
31
|
+
const sessions = new SessionStore(db);
|
|
32
|
+
const now = nowIso();
|
|
33
|
+
const caller = ctx.env.CLAUDE_CODE_SESSION_ID ?? null;
|
|
34
|
+
|
|
35
|
+
// Fail closed: a wrong `active` strands a session yielding for events
|
|
36
|
+
// that never arrive; a wrong `inactive` costs only polling.
|
|
37
|
+
if (parsed.server !== undefined) {
|
|
38
|
+
const named = await sessions.getSession(parsed.server);
|
|
39
|
+
if (
|
|
40
|
+
named === null ||
|
|
41
|
+
(caller !== null && named.claudeSessionId !== caller)
|
|
42
|
+
) {
|
|
43
|
+
ctx.io.write('inactive no-server-for-session\n');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// `--server` takes precedence over the match, not over liveness: a
|
|
47
|
+
// named row whose heartbeat went quiet or whose process is gone is
|
|
48
|
+
// no server at all.
|
|
49
|
+
const fresh =
|
|
50
|
+
Date.parse(now) - Date.parse(named.heartbeatAt) <=
|
|
51
|
+
DEFAULT_STALE_AFTER_SECONDS * 1_000;
|
|
52
|
+
if (!fresh || (await withLiveProcesses([named])).length === 0) {
|
|
53
|
+
ctx.io.write('inactive no-server-for-session\n');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
ctx.io.write(
|
|
57
|
+
named.ackedAt === null
|
|
58
|
+
? 'inactive awaiting-ack\n'
|
|
59
|
+
: `active ${named.id}\n`
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (caller === null) {
|
|
65
|
+
ctx.io.write('inactive no-session-id\n');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Heartbeat freshness alone would keep a killed server's row "live"
|
|
69
|
+
// for the whole staleness window; the process check rules it out now.
|
|
70
|
+
const live = await withLiveProcesses(
|
|
71
|
+
await sessions.liveForCaller(caller, now, DEFAULT_STALE_AFTER_SECONDS)
|
|
72
|
+
);
|
|
73
|
+
if (live.length === 0) {
|
|
74
|
+
ctx.io.write('inactive no-server-for-session\n');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (live.length > 1) {
|
|
78
|
+
ctx.io.write('inactive ambiguous-session\n');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const only = live[0];
|
|
82
|
+
ctx.io.write(
|
|
83
|
+
only?.ackedAt == null
|
|
84
|
+
? 'inactive awaiting-ack\n'
|
|
85
|
+
: `active ${only.id}\n`
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|