lockage 0.0.0-stage → 0.9.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/db/index.d.ts +52 -0
  4. package/dist/db/index.js +238 -0
  5. package/dist/db/schema.sql +238 -0
  6. package/dist/hook/clients.d.ts +56 -0
  7. package/dist/hook/clients.js +258 -0
  8. package/dist/hook/context.d.ts +22 -0
  9. package/dist/hook/context.js +40 -0
  10. package/dist/hook/dispatch.d.ts +21 -0
  11. package/dist/hook/dispatch.js +212 -0
  12. package/dist/hook/guard.d.ts +34 -0
  13. package/dist/hook/guard.js +106 -0
  14. package/dist/hook/repo.d.ts +40 -0
  15. package/dist/hook/repo.js +173 -0
  16. package/dist/hook/setup-kiro.d.ts +30 -0
  17. package/dist/hook/setup-kiro.js +118 -0
  18. package/dist/index.d.ts +47 -0
  19. package/dist/index.js +694 -0
  20. package/dist/launcher/client.d.ts +22 -0
  21. package/dist/launcher/client.js +57 -0
  22. package/dist/launcher/publish-adapter.d.ts +88 -0
  23. package/dist/launcher/publish-adapter.js +233 -0
  24. package/dist/launcher/publish-cli.d.ts +2 -0
  25. package/dist/launcher/publish-cli.js +123 -0
  26. package/dist/launcher/publish.d.ts +140 -0
  27. package/dist/launcher/publish.js +522 -0
  28. package/dist/lockage/board.d.ts +247 -0
  29. package/dist/lockage/board.js +407 -0
  30. package/dist/lockage/claim.d.ts +307 -0
  31. package/dist/lockage/claim.js +580 -0
  32. package/dist/lockage/files.d.ts +229 -0
  33. package/dist/lockage/files.js +403 -0
  34. package/dist/lockage/git.d.ts +92 -0
  35. package/dist/lockage/git.js +183 -0
  36. package/dist/lockage/globs.d.ts +26 -0
  37. package/dist/lockage/globs.js +125 -0
  38. package/dist/lockage/merge.d.ts +132 -0
  39. package/dist/lockage/merge.js +831 -0
  40. package/dist/lockage/publish-lock.d.ts +38 -0
  41. package/dist/lockage/publish-lock.js +73 -0
  42. package/dist/lockage/tickets.d.ts +213 -0
  43. package/dist/lockage/tickets.js +340 -0
  44. package/dist/lockage/worktree.d.ts +212 -0
  45. package/dist/lockage/worktree.js +563 -0
  46. package/package.json +42 -3
package/dist/index.js ADDED
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lockage — the coordinated-development MCP server itself (Phase 1: single host).
4
+ *
5
+ * Exposes the coordination logic implemented so far under src/lockage/* (atomic claim,
6
+ * worktree physical isolation, advisory claim/dirty set, integration merge gate,
7
+ * board/A2A wake) as MCP tools. It starts on the stdio transport and can be driven from
8
+ * MCP clients such as Claude Code / Kiro.
9
+ *
10
+ * The 7 design categories (§2):
11
+ * 2.1 Task operations create_task / list_tasks / claim_task / release_task / block_task / resume_task / handoff_task / task_done / heartbeat
12
+ * 2.3 File coordination claim_files / release_claim / list_claims / publish_dirty / list_dirty
13
+ * 2.5 Context sharing (board) post_board / read_board / resolve_question
14
+ * 2.6 worktree assign_worktree / release_worktree / list_worktrees
15
+ * 2.7 Waiting (A2A wake) wait_for_unblock
16
+ * (merge gate) process_merge_queue / list_merge_queue (maintenance toolset; not exposed by default)
17
+ * (publisher observation) publisher_status
18
+ *
19
+ * Differences from the design (Phase 1 scope): ticket operations (2.2) are implemented.
20
+ * The git-commit-linked context sharing (2.4) was never used and has been dropped (#69).
21
+ * handoff (§4.7) is implemented as handing a blocked task to another worker (handoff_task).
22
+ * agent_id is self-declared by the client (Phase 1 assumes a single host inside the trust boundary).
23
+ */
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
27
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
+ import { z } from 'zod';
29
+ import { openDb } from './db/index.js';
30
+ import { claimTask, createTask, addDependency, heartbeat, listTasks, releaseTask, blockTask, resumeTask, handoffTask, } from './lockage/claim.js';
31
+ import { openTicket, updateTicket, listTickets, linkTaskToTicket, getTicket, ticketRepos, clearIntegrationBranch, closeTicketOnMerge, } from './lockage/tickets.js';
32
+ import { assignWorktree, listWorktrees, listStaleWorktrees, releaseWorktree, releaseWorktreesForTicket, reclaimOrphanWorktrees, } from './lockage/worktree.js';
33
+ import { claimFiles, listClaims, listDirty, mergeDirtyResults, publishDirty, reconcileDirtyFromWorktrees, releaseClaim, releaseClaimsForFinishedTicketTasks, } from './lockage/files.js';
34
+ import { onTaskDone, listMergeQueue, processMergeQueue } from './lockage/merge.js';
35
+ import { acquirePublishLock, releasePublishLock } from './lockage/publish-lock.js';
36
+ import { postBoard, readBoard, resolveQuestion, wake, waitForUnblock, computePublisherStatus, } from './lockage/board.js';
37
+ /** Shared helper that returns a tool result as MCP text content (JSON). */
38
+ function ok(data) {
39
+ return {
40
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
41
+ };
42
+ }
43
+ function fail(message) {
44
+ return {
45
+ isError: true,
46
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
47
+ };
48
+ }
49
+ /**
50
+ * DB path. Overridable via the LOCKAGE_DB environment variable.
51
+ * Defaults to ~/.lockage/lockage.sqlite (home-absolute path).
52
+ * Pinned to a home-absolute path to match the design intent that the cross-repo ledger is
53
+ * globally single. A CWD-relative path would split the ledger per launch repo and break the
54
+ * "cross-repo" guarantee.
55
+ */
56
+ const DB_PATH = process.env.LOCKAGE_DB ?? join(homedir(), '.lockage', 'lockage.sqlite');
57
+ /**
58
+ * Toolsets not registered by default (#65). Enabled at server startup via the LOCKAGE_TOOLSETS
59
+ * environment variable (comma-separated). Tools that belong to no toolset (core) are always
60
+ * registered. lockage-publish also uses core tools such as read_ticket and list_tasks, so there
61
+ * is no option to drop core.
62
+ *
63
+ * - publisher: cleanup tools called only by lockage-publish. If an agent calls them it could
64
+ * close tickets or clear integration branch names, so they are not shown in the agent's list.
65
+ * - maintenance: for maintenance/diagnostics. Enabled only when a human needs it.
66
+ *
67
+ * The actual registration split is the `if (toolsets.has(...))` in buildServer; this list exists
68
+ * to cross-check that against the README (drift is detected by tests). We do not register
69
+ * everything and then remove some, because the MCP SDK has no public API to look up a registered
70
+ * tool by name and remove it.
71
+ */
72
+ export const OPTIONAL_TOOLSETS = {
73
+ publisher: [
74
+ 'acquire_publish_lock',
75
+ 'release_publish_lock',
76
+ 'clear_integration_branch',
77
+ 'close_ticket_on_merge',
78
+ 'release_ticket_worktrees',
79
+ 'release_ticket_claims',
80
+ ],
81
+ maintenance: ['reclaim_orphan_worktrees', 'process_merge_queue', 'list_merge_queue'],
82
+ };
83
+ /**
84
+ * Turns the LOCKAGE_TOOLSETS value into a set of toolsets. `core` is always enabled, so it is
85
+ * ignored even if written. An unknown name is a configuration error (e.g. a typo), so instead of
86
+ * silently ignoring it we stop startup.
87
+ */
88
+ export function parseToolsets(value) {
89
+ const result = new Set();
90
+ for (const raw of (value ?? '').split(',')) {
91
+ const name = raw.trim();
92
+ if (name === '' || name === 'core')
93
+ continue;
94
+ if (!Object.hasOwn(OPTIONAL_TOOLSETS, name)) {
95
+ throw new Error(`LOCKAGE_TOOLSETS has an unknown toolset: ${name} (valid: core, ${Object.keys(OPTIONAL_TOOLSETS).join(', ')})`);
96
+ }
97
+ result.add(name);
98
+ }
99
+ return result;
100
+ }
101
+ export function buildServer(cdb, opts = {}) {
102
+ const toolsets = new Set(opts.toolsets ?? []);
103
+ const server = new McpServer({
104
+ name: 'lockage',
105
+ version: '0.0.1',
106
+ });
107
+ // ── 2.1 Task operations ──────────────────────────────────────────
108
+ server.registerTool('create_task', {
109
+ description: 'Create a task. Right after creation it is open and unassigned. ticket_id is optional (you can also create minor tasks not tied to a ticket). This task does not become ready until every task passed in depends_on is done. If the dependencies form a cycle, returns {ok:false, reason:"cycle"}.',
110
+ inputSchema: {
111
+ repo: z.string().describe('target repo identifier'),
112
+ title: z.string(),
113
+ description: z.string().optional(),
114
+ ticket_id: z.string().nullable().optional(),
115
+ priority: z
116
+ .number()
117
+ .int()
118
+ .optional()
119
+ .describe('priority (integer, default 0). Larger is higher priority. Ties break by created_at ascending (oldest first). Negatives are allowed (explicitly deprioritize). Guide: -10=low / 0=normal / 10=high / 100=interrupt (top)'),
120
+ depends_on: z
121
+ .array(z.string())
122
+ .optional()
123
+ .describe('array of existing task ids that block this task (dependencies; not ready until all are done)'),
124
+ },
125
+ }, async (a) => ok(createTask(cdb, {
126
+ repo: a.repo,
127
+ title: a.title,
128
+ description: a.description,
129
+ ticketId: a.ticket_id ?? null,
130
+ priority: a.priority,
131
+ dependsOn: a.depends_on ?? [],
132
+ })));
133
+ server.registerTool('add_dependency', {
134
+ description: 'Add a dependency between two existing tasks. to_task does not become ready until from_task is done (the version that adds create_task\'s depends_on afterward). If it would create a cycle, returns {ok:false, reason:"cycle"}; "not_found" if a task is missing; "self" if both ids are the same.',
135
+ inputSchema: {
136
+ from_task: z.string().describe('the blocking side (the dependency that finishes first)'),
137
+ to_task: z.string().describe('the blocked side (waits for from_task to finish)'),
138
+ },
139
+ }, async (a) => ok(addDependency(cdb, a.from_task, a.to_task)));
140
+ server.registerTool('list_tasks', {
141
+ description: 'List tasks (regardless of status, highest priority first, oldest first on ties). Each row includes the version to pass to claim_task. Filter by status/assignee/ticket_id/repo. To find unassigned tasks use status="open". With ready_only=true, returns only tasks whose dependencies are all done. When status="blocked", to help find tasks left stuck, each row carries blocked_age_sec (seconds since the task was last updated; usually time since it became blocked), agent_silent_sec (seconds since the assignee\'s last activity; null if unknown), and last_question (the latest unresolved question for this task) (sort by blocked_age_sec to see the longest-neglected first).',
142
+ inputSchema: {
143
+ status: z
144
+ .enum(['open', 'in_progress', 'done', 'abandoned', 'blocked'])
145
+ .optional(),
146
+ assignee: z.string().optional(),
147
+ ticket_id: z.string().optional().describe('narrow to tasks under this parent ticket'),
148
+ repo: z.string().optional(),
149
+ ready_only: z
150
+ .boolean()
151
+ .optional()
152
+ .describe('true for only tasks whose dependencies are all done (unblocked)'),
153
+ },
154
+ }, async (a) => ok(listTasks(cdb, {
155
+ status: a.status,
156
+ assignee: a.assignee,
157
+ ticketId: a.ticket_id,
158
+ repo: a.repo,
159
+ readyOnly: a.ready_only,
160
+ })));
161
+ server.registerTool('claim_task', {
162
+ description: 'Claim a task atomically (set it to in_progress and make yourself the assignee). Pass the version read from list_tasks as expected_version. If another agent claimed it first, or it changed after you read it, returns {ok:false} with the current value without blocking. In that case do not steal it — pick another task.',
163
+ inputSchema: {
164
+ task_id: z.string(),
165
+ agent_id: z.string(),
166
+ expected_version: z.number().int(),
167
+ },
168
+ }, async (a) => {
169
+ heartbeat(cdb, a.agent_id);
170
+ return ok(claimTask(cdb, a.task_id, a.agent_id, a.expected_version));
171
+ });
172
+ server.registerTool('release_task', {
173
+ description: 'Release a claimed task (usable only on your own in_progress task; not usable on a blocked task). outcome="open" (default) returns it to open so anyone can re-claim it; "abandoned" marks it as no longer being worked on. When finished, use task_done instead.',
174
+ inputSchema: {
175
+ task_id: z.string(),
176
+ agent_id: z.string(),
177
+ outcome: z.enum(['open', 'abandoned']).optional().describe('default open'),
178
+ },
179
+ }, async (a) => {
180
+ heartbeat(cdb, a.agent_id);
181
+ return ok(releaseTask(cdb, a.task_id, a.agent_id, a.outcome ?? 'open'));
182
+ });
183
+ server.registerTool('block_task', {
184
+ description: 'Suspend a task to wait for a human decision (in_progress→blocked). Call this before exiting when you cannot finish — e.g. lacking permission, or a design decision is needed. Unlike release_task, you keep the assignment and worktree, and it is not taken away on timeout. Record the reason you got stuck first via post_board(kind="question", ref_task_id). Usable only on your own in_progress task.',
185
+ inputSchema: {
186
+ task_id: z.string(),
187
+ agent_id: z.string(),
188
+ },
189
+ }, async (a) => {
190
+ // Invoked by an operator (not the worker itself = orchestrator/human), so do not touch
191
+ // agents.last_seen (to avoid polluting agent_silent_sec of list_tasks(status="blocked") with
192
+ // the operation time). blockTask intentionally keeps the task's heartbeat_at, so we do not
193
+ // pass taskId here either.
194
+ heartbeat(cdb, a.agent_id, { touchAgent: false });
195
+ return ok(blockTask(cdb, a.task_id, a.agent_id));
196
+ });
197
+ server.registerTool('resume_task', {
198
+ description: 'When a human says "continue", resume a suspended task with the same agent (blocked→in_progress). The worktree is still usable. After resuming it returns to normal in_progress, and if heartbeat lapses it is returned to open. Usable only on your own blocked task. To hand it to another agent, use handoff_task.',
199
+ inputSchema: {
200
+ task_id: z.string(),
201
+ agent_id: z.string(),
202
+ },
203
+ }, async (a) => {
204
+ // resume is invoked by the human/orchestrator, and only then does the launcher start the
205
+ // worker, so at resume time the worker process is not yet alive. Touching agents.last_seen
206
+ // would be misread as "alive before launch" and skew orphan detection, hence touchAgent=false.
207
+ // The task's heartbeat_at revival (lease restart) is done inside resumeTask, so the launched
208
+ // worker records liveness via its normal heartbeat afterward.
209
+ heartbeat(cdb, a.agent_id, { touchAgent: false });
210
+ return ok(resumeTask(cdb, a.task_id, a.agent_id));
211
+ });
212
+ server.registerTool('handoff_task', {
213
+ description: 'Move a blocked task to another agent named by a human (use only when a human says so explicitly; the usual resume is resume_task). Reassigns the task\'s assignee, worktree, claims, and dirty from from_agent to to_agent together (branch and path do not change), posts a handoff to the board, and wakes to_agent. to_agent can trace the predecessor\'s question from ref_task_id. After the move it becomes to_agent\'s in_progress task and must have its lease extended by heartbeat. If it is not a blocked task held by from_agent, returns {ok:false, reason:"conflict", current} without changing anything ("not_found" if the task is missing). An in_progress task cannot be moved.',
214
+ inputSchema: {
215
+ task_id: z.string(),
216
+ from_agent: z.string().describe('the original agent_id that got stuck and set it to blocked'),
217
+ to_agent: z.string().describe('the handoff-target agent_id named by the human'),
218
+ },
219
+ }, async (a) => {
220
+ // handoff is invoked by the human/orchestrator, and to_agent's worker process is not yet
221
+ // started at this point (order: handoff → launcher starts it in the worktree). Recording
222
+ // agents.last_seen for to_agent would be misread as "alive before launch", delaying orphan
223
+ // detection if to_agent stays dead. So touchAgent=false. The task's heartbeat_at revival is
224
+ // done inside handoffTask, and the launched to_agent records liveness via its normal
225
+ // heartbeat afterward.
226
+ heartbeat(cdb, a.to_agent, { touchAgent: false });
227
+ const r = handoffTask(cdb, {
228
+ taskId: a.task_id,
229
+ fromAgent: a.from_agent,
230
+ toAgent: a.to_agent,
231
+ });
232
+ // Only when the move is committed do we leave a trace on the board and wake to_agent (point B).
233
+ // handoffTask opens BEGIN IMMEDIATE internally, so to avoid a nested BEGIN we do the board
234
+ // integration after success is confirmed (the atomicity of the state reassignment and the
235
+ // notification are separate concerns).
236
+ // - post_board(kind='handoff', author=from, to_agent=to, ref_task_id): the arriving to_agent
237
+ // learns it was handed the task and can trace the predecessor's question via ref (D-3's
238
+ // directed board notification).
239
+ // - wake(to, 'board'): immediately wake a to_agent waiting in wait_for_unblock (it would also
240
+ // be caught by the board_messages seq diff, but this insures against a miss before the
241
+ // long-poll starts; same style as needs_review).
242
+ if (r.ok) {
243
+ postBoard(cdb, {
244
+ agentId: a.from_agent,
245
+ kind: 'handoff',
246
+ toAgent: a.to_agent,
247
+ refTaskId: a.task_id,
248
+ body: `Handed task ${a.task_id} over from ${a.from_agent}. worktree: ${r.worktrees.join(', ') || '(none)'}. Check the stuck reason the predecessor left via read_board(kind='question', ref_task_id).`,
249
+ });
250
+ wake(cdb, a.to_agent, 'board', { reason: 'handoff', task_id: a.task_id });
251
+ }
252
+ return ok(r);
253
+ });
254
+ server.registerTool('task_done', {
255
+ description: 'Complete a task and hand it to the merge gate. Call it after committing your work in the worktree. A task with a ticket_id merges to the ticket\'s integration branch (lockage/ticket/<short id>); a task with no ticket_id (also when task_id is omitted) merges to the base branch. If a conflict looks likely, it does not merge and returns needs_review (each element\'s contested lists the files that would conflict; the task stays in_progress) — resolve in the worktree and call again. For a worktree_path you are not assigned, a branch equal to base, or a task not assigned to you, it changes nothing and returns {ok:false, reason:"no_active_worktree"|"branch_is_base"|"not_assignee"}. For a lease-expired task (heartbeat lapsed, so the task went back to unassigned), if your assignment is tied to that task it re-claims and continues (rescue). not_assignee means the task is not yours (another agent owns it, or the worktree assignment is not tied to that task and is not eligible for rescue), and repeating the same call will not succeed — check with list_tasks, and if another agent owns it, do not steal it; move to another task. If it is unassigned/open, confirm the task_id + worktree_path pair is your own work, then claim_task with the list_tasks version as expected_version and call task_done again. If the rescue re-claim loses a race and the task is still unassigned, returns {ok:false, reason:"lease_expired"}, so wait a few seconds and call again (retry at most 3 times after the first call). If it is still lease_expired, check with list_tasks: if unassigned/open, do the same as not_assignee — claim_task then call task_done (on conflict, start over from list_tasks). If another agent owns it, do not steal it; move to another task. Otherwise leave a question and stop (block_task only works when the task is yours and in_progress). When all of a ticket\'s tasks are done, ticket_ready is emitted automatically. PRs are created by lockage-publish (a CLI, not an MCP tool; not resident): after you set the ticket\'s last task to done, confirm it appears as awaiting PR via publisher_status, then run lockage-publish --repo-path <repo absolute path> --ticket <ticket_id>. gh pr create is not used.',
256
+ inputSchema: {
257
+ agent_id: z.string(),
258
+ worktree_path: z.string(),
259
+ branch: z.string(),
260
+ repo: z.string().describe('absolute path to the repo root'),
261
+ task_id: z.string().nullable().optional(),
262
+ },
263
+ }, async (a) => {
264
+ // Issue #52: before running task_done (= while the worktree is still active), recompute dirty
265
+ // from the worktree's git state and capture files outside the claim glob as unclaimed. Since
266
+ // onTaskDone clears that worktree's dirty set on a successful merge, this computation must
267
+ // happen before it. OnTaskDoneResult itself is unchanged; the handler merges in unclaimed and
268
+ // returns via ok().
269
+ const rc = reconcileDirtyFromWorktrees(cdb, {
270
+ agentId: a.agent_id,
271
+ worktreePath: a.worktree_path,
272
+ });
273
+ const res = onTaskDone(cdb, {
274
+ agentId: a.agent_id,
275
+ worktreePath: a.worktree_path,
276
+ branch: a.branch,
277
+ repo: a.repo,
278
+ taskId: a.task_id ?? null,
279
+ });
280
+ return ok({ ...res, unclaimed: rc.unclaimed });
281
+ });
282
+ server.registerTool('heartbeat', {
283
+ description: 'Signal that you are alive and extend the lease on the tasks and claims you hold (task lease is 15 minutes). Calling claim_task, release_task, claim_files, publish_dirty, resolve_question, assign_worktree, or wait_for_unblock also extends it, but post_board, task_done, release_claim, release_worktree, etc. do not, so call this at intervals shorter than 15 minutes while working (around 10 minutes). Passing task_id extends only that task. It also auto-computes changes (dirty) in your assigned worktree, returning overlaps if they overlap another agent\'s dirty or claims, and files changed outside your claim glob as unclaimed.',
284
+ inputSchema: { agent_id: z.string(), task_id: z.string().optional() },
285
+ }, async (a) => {
286
+ heartbeat(cdb, a.agent_id, a.task_id);
287
+ // Issue #52: auto-compute dirty from the assigned worktree's git state and return overlaps/wake/unclaimed.
288
+ const rc = reconcileDirtyFromWorktrees(cdb, { agentId: a.agent_id });
289
+ return ok({
290
+ ok: true,
291
+ overlaps: rc.overlaps,
292
+ wake_targets: rc.wake_targets,
293
+ unclaimed: rc.unclaimed,
294
+ });
295
+ });
296
+ // ── 2.2 Ticket operations (model 3: a grouping unit for issues) ───────────
297
+ server.registerTool('open_ticket', {
298
+ description: 'Create a ticket. A ticket groups multiple tasks and is a work unit that eventually becomes a PR from an integration branch; it is not claimed (it is distinct from a GitHub Issue). Omitting or leaving repos empty makes a ticket that relates to any repo. The title and body are full-text searchable via list_tickets\' query.',
299
+ inputSchema: {
300
+ title: z.string(),
301
+ body: z.string().nullable().optional(),
302
+ repos: z.array(z.string()).optional().describe('the repos involved. Omitted or empty means it relates to any repo'),
303
+ priority: z
304
+ .number()
305
+ .int()
306
+ .optional()
307
+ .describe('priority (integer, default 0). Larger is higher priority. Ties break by created_at ascending (oldest first). Negatives are allowed (explicitly deprioritize). Guide: -10=low / 0=normal / 10=high / 100=interrupt (top)'),
308
+ created_by: z.string().nullable().optional(),
309
+ },
310
+ }, async (a) => ok(openTicket(cdb, {
311
+ title: a.title,
312
+ body: a.body ?? null,
313
+ repos: a.repos,
314
+ priority: a.priority,
315
+ createdBy: a.created_by ?? null,
316
+ })));
317
+ server.registerTool('update_ticket', {
318
+ description: 'Partially update a ticket\'s status, body, priority, and assignee. Pass the version read via read_ticket (etc.) as expected_version. If it changed after you read it, returns {ok:false, reason:"conflict", current}. If the ticket is missing, {ok:false, reason:"not_found"}. When status changes automatically via a claim or a PR merge, the version is not bumped.',
319
+ inputSchema: {
320
+ ticket_id: z.string(),
321
+ expected_version: z.number().int(),
322
+ status: z.enum(['open', 'in_progress', 'closed']).optional(),
323
+ body: z.string().nullable().optional(),
324
+ priority: z
325
+ .number()
326
+ .int()
327
+ .optional()
328
+ .describe('priority (integer, default 0). Larger is higher priority. Ties break by created_at ascending (oldest first). Negatives are allowed (explicitly deprioritize). Guide: -10=low / 0=normal / 10=high / 100=interrupt (top)'),
329
+ assignee: z.string().nullable().optional(),
330
+ },
331
+ }, async (a) => ok(updateTicket(cdb, {
332
+ ticketId: a.ticket_id,
333
+ expectedVersion: a.expected_version,
334
+ status: a.status,
335
+ body: a.body,
336
+ priority: a.priority,
337
+ assignee: a.assignee,
338
+ })));
339
+ server.registerTool('list_tickets', {
340
+ description: 'List and search tickets (regardless of status/query, highest priority first, oldest first on ties). Filter by status. Passing repo returns tickets related to that repo plus tickets related to any repo. query full-text searches the title and body. Each ticket carries repos (empty means it relates to any repo).',
341
+ inputSchema: {
342
+ status: z.enum(['open', 'in_progress', 'closed']).optional(),
343
+ repo: z.string().optional(),
344
+ query: z.string().optional().describe('terms to full-text search the title and body (an FTS5 query expression; wrap words containing symbols such as - or # in "…")'),
345
+ limit: z.number().int().optional(),
346
+ },
347
+ }, async (a) => ok(listTickets(cdb, { status: a.status, repo: a.repo, query: a.query, limit: a.limit })));
348
+ server.registerTool('read_ticket', {
349
+ description: 'Read a single ticket ({ticket:null} if missing). Includes the integration branch name (integration_branch), the time ticket_ready was emitted (ticket_ready_at), and repos.',
350
+ inputSchema: {
351
+ ticket_id: z.string(),
352
+ },
353
+ }, async (a) => {
354
+ const ticket = getTicket(cdb, a.ticket_id);
355
+ if (!ticket)
356
+ return ok({ ticket: null });
357
+ return ok({ ticket: { ...ticket, repos: ticketRepos(cdb, a.ticket_id) } });
358
+ });
359
+ if (toolsets.has('publisher')) {
360
+ server.registerTool('clear_integration_branch', {
361
+ description: 'As post-merge cleanup for a PR, clear the ticket\'s integration branch name (integration_branch) (called by lockage-publish\'s reconcile). Does not bump the version. Passing expected_branch clears it only when the name matches (to avoid clearing a newly re-created branch name). Returns cleared:false if already cleared, not_found if the ticket is missing.',
362
+ inputSchema: {
363
+ ticket_id: z.string(),
364
+ expected_branch: z
365
+ .string()
366
+ .optional()
367
+ .describe('clear only when the name matches this (if omitted, clear regardless of name)'),
368
+ },
369
+ }, async (a) => ok(clearIntegrationBranch(cdb, a.ticket_id, a.expected_branch)));
370
+ server.registerTool('close_ticket_on_merge', {
371
+ description: 'As post-merge cleanup for a PR, set the ticket to closed (called by lockage-publish\'s reconcile). Does not bump the version, so it does not conflict with a human concurrently editing the body etc. Returns closed:false if already closed, not_found if the ticket is missing.',
372
+ inputSchema: {
373
+ ticket_id: z.string(),
374
+ },
375
+ }, async (a) => ok(closeTicketOnMerge(cdb, a.ticket_id)));
376
+ }
377
+ server.registerTool('link_task_to_ticket', {
378
+ description: 'Re-link a task to a parent ticket (ticket_id=null to unlink). Work spanning multiple repos is represented by creating one task per repo under the same ticket_id. If the task or ticket is missing, declines with a reason.',
379
+ inputSchema: {
380
+ task_id: z.string(),
381
+ ticket_id: z.string().nullable().optional().describe('null to unlink the parent ticket'),
382
+ },
383
+ }, async (a) => ok(linkTaskToTicket(cdb, a.task_id, a.ticket_id ?? null)));
384
+ // ── 2.3 File coordination (advisory) ─────────────────────────────
385
+ server.registerTool('claim_files', {
386
+ description: 'Declare the files you are about to touch as globs. It does not block if it overlaps another agent\'s declaration — it reports them as overlaps. A declaration has a lease; heartbeat extends it, and it expires when heartbeat stops. Re-declaring recomputes the changes in your assigned worktree and returns files already changed outside the declared glob as unclaimed.',
387
+ inputSchema: {
388
+ agent_id: z.string(),
389
+ globs: z.array(z.string()).describe('the glob patterns you take responsibility for'),
390
+ ttl_sec: z.number().int().optional(),
391
+ task_id: z.string().nullable().optional(),
392
+ },
393
+ }, async (a) => {
394
+ heartbeat(cdb, a.agent_id);
395
+ const res = claimFiles(cdb, {
396
+ agentId: a.agent_id,
397
+ globs: a.globs,
398
+ ttlSec: a.ttl_sec,
399
+ taskId: a.task_id ?? null,
400
+ });
401
+ // Issue #52: after re-declaring the claim, recompute dirty from the worktree's git state and
402
+ // return files outside the declared glob as unclaimed (surface out-of-scope changes).
403
+ const rc = reconcileDirtyFromWorktrees(cdb, { agentId: a.agent_id });
404
+ return ok({ ...res, unclaimed: rc.unclaimed, dirty_overlaps: rc.overlaps });
405
+ });
406
+ server.registerTool('release_claim', {
407
+ description: 'Release a file claim (only the declarer may call it). Claims tied to a task are released by task_done, release_task, and PR post-merge cleanup, so you usually need not call this. Release claims not tied to a task with this (even if you do not, they are reclaimed on expiry after heartbeat stops).',
408
+ inputSchema: { claim_id: z.string(), agent_id: z.string() },
409
+ }, async (a) => ok(releaseClaim(cdb, a.claim_id, a.agent_id)));
410
+ server.registerTool('list_claims', {
411
+ description: 'List active file claims (filter by holder).',
412
+ inputSchema: { holder: z.string().optional() },
413
+ }, async (a) => ok(listClaims(cdb, a.holder)));
414
+ server.registerTool('publish_dirty', {
415
+ description: 'Announce files you have already changed (dirty). If they overlap another agent\'s dirty or claims, reports them via overlaps and wakes that peer. Files outside your claim glob are returned as unclaimed. dirty is also auto-computed and added from the worktree\'s git state, so call this when you want to announce ahead of that automatic computation, for example.',
416
+ inputSchema: {
417
+ agent_id: z.string(),
418
+ worktree_path: z.string(),
419
+ files: z.array(z.string()).describe('list of changed files (repo-relative)'),
420
+ },
421
+ }, async (a) => {
422
+ heartbeat(cdb, a.agent_id);
423
+ const res = publishDirty(cdb, {
424
+ agentId: a.agent_id,
425
+ worktreePath: a.worktree_path,
426
+ files: a.files,
427
+ });
428
+ // Issue #52: in addition to the explicitly announced files, include the same worktree's git
429
+ // state in the union. reconcile overwrites with the union of existing dirty (the announcement
430
+ // just published) and git-derived files, so the announced files always remain.
431
+ //
432
+ // Here we explicitly pass minIntervalSec: 0. The dirty rows publishDirty just wrote have
433
+ // updated_at=now, so at the default minimum interval (30 seconds) reconcile would judge them
434
+ // "fresh", skip reading git, and leave git-derived changes out of the same response (which
435
+ // would contradict the tool's description that it also adds and returns dirty computed from
436
+ // git state). Only the publish_dirty path disables the throttle so that "explicit announce ∪
437
+ // git state" is always unioned in the same call (the announced files are part of the union, so
438
+ // nothing breaks).
439
+ const rc = reconcileDirtyFromWorktrees(cdb, {
440
+ agentId: a.agent_id,
441
+ worktreePath: a.worktree_path,
442
+ minIntervalSec: 0,
443
+ });
444
+ // Issue #52 regression fix: overwriting res wholesale with rc would, when the worktree_path
445
+ // passed to publish_dirty is not in an active assignment (e.g. no assign_worktree), make rc
446
+ // empty and swallow the overlap/wake/unclaimed that publishDirty actually detected. Merge the
447
+ // two so that res's detection always remains even if reconcile cannot find the worktree
448
+ // (dedup included).
449
+ const merged = mergeDirtyResults(res, rc);
450
+ // Explicitly build and return the 3 items from merged (ok/overlaps/wake_targets/unclaimed).
451
+ // We avoid a `...res` spread that overwrites just the 3 items on res's shape, since res and
452
+ // merged share the same keys and are easy to mix up (behavior is unchanged = the same 4 keys
453
+ // as PublishDirtyResult).
454
+ return ok({
455
+ ok: true,
456
+ overlaps: merged.overlaps,
457
+ wake_targets: merged.wake_targets,
458
+ unclaimed: merged.unclaimed,
459
+ });
460
+ });
461
+ server.registerTool('list_dirty', {
462
+ description: 'List dirty (changed) files (filter by agent_id).',
463
+ inputSchema: { agent_id: z.string().optional() },
464
+ }, async (a) => ok(listDirty(cdb, a.agent_id)));
465
+ // ── 2.5 Context sharing (board) ──────────────────────────────────
466
+ server.registerTool('post_board', {
467
+ description: 'Post to the shared board. Specifying to_agent addresses it to that peer. An agent waiting in wait_for_unblock wakes on posts addressed to it or to everyone (regardless of kind). Attach the target task to a question via ref_task_id. ticket_ready is posted by the server; pr_opened, pr_updated, and pr_merged are posted by lockage-publish.',
468
+ inputSchema: {
469
+ agent_id: z.string(),
470
+ body: z.string(),
471
+ kind: z
472
+ .enum(['note', 'handoff', 'question', 'ticket_ready', 'pr_opened', 'pr_updated', 'pr_merged'])
473
+ .optional(),
474
+ to_agent: z.string().nullable().optional(),
475
+ ref_task_id: z.string().nullable().optional(),
476
+ ref_ticket_id: z.string().nullable().optional(),
477
+ },
478
+ }, async (a) => ok(postBoard(cdb, {
479
+ agentId: a.agent_id,
480
+ body: a.body,
481
+ kind: a.kind,
482
+ toAgent: a.to_agent ?? null,
483
+ refTaskId: a.ref_task_id ?? null,
484
+ refTicketId: a.ref_ticket_id ?? null,
485
+ })));
486
+ server.registerTool('read_board', {
487
+ description: 'Read the board. Passing the last-read seq as since returns only the diff. Passing to_agent returns posts addressed to you and to everyone. Filter by kind (ticket_ready = tickets ready to become a PR; pr_opened, pr_updated, pr_merged = records from lockage-publish). unresolved_only=true excludes resolved questions (kinds other than question are still returned, so combine with kind="question" to see only unresolved questions).',
488
+ inputSchema: {
489
+ since: z.number().int().optional(),
490
+ to_agent: z.string().optional(),
491
+ kind: z
492
+ .enum(['note', 'handoff', 'question', 'ticket_ready', 'pr_opened', 'pr_updated', 'pr_merged'])
493
+ .optional(),
494
+ limit: z.number().int().optional(),
495
+ unresolved_only: z.boolean().optional(),
496
+ },
497
+ }, async (a) => ok(readBoard(cdb, {
498
+ since: a.since,
499
+ toAgent: a.to_agent,
500
+ kind: a.kind,
501
+ limit: a.limit,
502
+ unresolvedOnly: a.unresolved_only,
503
+ })));
504
+ server.registerTool('resolve_question', {
505
+ description: 'Mark a question resolved. Questions are resolved automatically when their ref_task_id task becomes done or abandoned, so call this for questions not tied to a task, or questions you want to close with just an answer without resuming the task. If already resolved, {ok:true, already_resolved:true}. A message_id that is not a question or does not exist returns {ok:false, reason}. Read unresolved questions via read_board(kind="question", unresolved_only:true).',
506
+ inputSchema: {
507
+ agent_id: z.string(),
508
+ message_id: z.string(),
509
+ },
510
+ }, async (a) => {
511
+ heartbeat(cdb, a.agent_id);
512
+ return ok(resolveQuestion(cdb, { agentId: a.agent_id, messageId: a.message_id }));
513
+ });
514
+ // ── 2.6 worktree ───────────────────────────────────────────────
515
+ server.registerTool('assign_worktree', {
516
+ description: 'Assign a git worktree for a task. Do all editing inside the returned worktree_path. If an equivalent assignment is still valid, returns it (reused=true). The branch name is lockage/<agent>/<slug>.',
517
+ inputSchema: {
518
+ agent_id: z.string(),
519
+ repo_path: z.string().describe('the repo root on the filesystem'),
520
+ task_id: z.string().nullable().optional(),
521
+ base_ref: z.string().optional().describe('the branch to fork from (default: the first of dev / stg / main / master that actually exists in the repo)'),
522
+ },
523
+ }, async (a) => {
524
+ heartbeat(cdb, a.agent_id);
525
+ return ok(assignWorktree(cdb, {
526
+ agentId: a.agent_id,
527
+ repoPath: a.repo_path,
528
+ taskId: a.task_id ?? null,
529
+ baseRef: a.base_ref,
530
+ }));
531
+ });
532
+ server.registerTool('release_worktree', {
533
+ description: 'Release a worktree. Only when git worktree remove (without --force) succeeds does it set the ledger to released and clear dirty. If it cannot be removed due to untracked files or uncommitted changes, returns {ok:true, removed:false, reason:"untracked"|"dirty"} and removes nothing (stash/commit the changes, then call again). The branch is kept by default. With keep_branch=false, the branch is also deleted only when removed=true.',
534
+ inputSchema: {
535
+ agent_id: z.string(),
536
+ worktree_path: z.string(),
537
+ repo_path: z.string(),
538
+ keep_branch: z.boolean().optional(),
539
+ },
540
+ }, async (a) => ok(releaseWorktree(cdb, {
541
+ agentId: a.agent_id,
542
+ worktreePath: a.worktree_path,
543
+ repoPath: a.repo_path,
544
+ keepBranch: a.keep_branch,
545
+ })));
546
+ server.registerTool('list_worktrees', {
547
+ description: 'List active worktree assignments (filter by agent_id / ticket_id). With stale=true, returns only worktrees that remain despite being tied to a done task (mostly ones that could not be released due to uncommitted changes) (each row adds task_status and ticket_id to the same columns as the non-stale list). These are not re-released automatically, so stash the changes and then call release_worktree.',
548
+ inputSchema: {
549
+ agent_id: z.string().optional(),
550
+ stale: z.boolean().optional().describe('true to return only candidates that failed to release'),
551
+ ticket_id: z.string().optional().describe('narrow to worktrees of tasks under that ticket'),
552
+ },
553
+ }, async (a) => ok(a.stale
554
+ ? listStaleWorktrees(cdb, { agentId: a.agent_id, ticketId: a.ticket_id })
555
+ : listWorktrees(cdb, a.agent_id, a.ticket_id)));
556
+ if (toolsets.has('maintenance')) {
557
+ server.registerTool('reclaim_orphan_worktrees', {
558
+ description: 'List worktrees that remain under .lockage-worktrees/ in the repo\'s parent directory but are not active in the ledger (orphans). ledger_status is released for ones released earlier yet still remaining, and none for ones with no ledger record. With dry_run=false, it tries to remove them via git worktree remove (without --force); ones with untracked files or uncommitted changes left are not removed and return removed:false with reason ("untracked"|"dirty"). Returns { orphans, removed_count }.',
559
+ inputSchema: {
560
+ repo_path: z.string().describe('the target repo root on the filesystem (the main repo)'),
561
+ dry_run: z.boolean().optional().describe('true (default) to list only; false to attempt reclamation'),
562
+ },
563
+ }, async (a) => ok(reclaimOrphanWorktrees(cdb, {
564
+ repoPath: a.repo_path,
565
+ dryRun: a.dry_run,
566
+ })));
567
+ }
568
+ if (toolsets.has('publisher')) {
569
+ server.registerTool('release_ticket_worktrees', {
570
+ description: 'As post-merge cleanup for a PR, release the worktrees of done tasks under the ticket together (called by lockage-publish\'s reconcile). Also deletes the task branches by default (keep_branch=true to keep them). Worktrees with uncommitted changes are left unreleased. Calling it repeatedly does not double-release. Returns { released, removed_count }.',
571
+ inputSchema: {
572
+ ticket_id: z.string(),
573
+ repo_path: z
574
+ .string()
575
+ .describe('the repo root where the worktree physically lives (used for old assignments with no repo record in the ledger)'),
576
+ keep_branch: z
577
+ .boolean()
578
+ .optional()
579
+ .describe('true to keep the task branches. Default false (deleted since they were merged via the integration branch)'),
580
+ },
581
+ }, async (a) => ok(releaseWorktreesForTicket(cdb, {
582
+ ticketId: a.ticket_id,
583
+ repoPath: a.repo_path,
584
+ keepBranch: a.keep_branch,
585
+ })));
586
+ server.registerTool('release_ticket_claims', {
587
+ description: 'As post-merge cleanup for a PR, release the remaining file claims tied to done/abandoned tasks under the ticket together (called by lockage-publish\'s reconcile). Claims not tied to a task are excluded. Calling it repeatedly does not double-release. Returns { released_count }.',
588
+ inputSchema: {
589
+ ticket_id: z.string(),
590
+ },
591
+ }, async (a) => ok(releaseClaimsForFinishedTicketTasks(cdb, {
592
+ ticketId: a.ticket_id,
593
+ })));
594
+ }
595
+ // ── Merge gate (observe/drive) ─────────────────────────────────
596
+ if (toolsets.has('maintenance')) {
597
+ server.registerTool('process_merge_queue', {
598
+ description: 'Process the merge queue in order. Non-conflicting entries are merged to the ticket\'s integration branch or the base branch; entries that would conflict are sent back to needs_review. task_done normally processes this automatically, so use this when you want to drive it by hand.',
599
+ inputSchema: {},
600
+ }, async () => ok(processMergeQueue(cdb)));
601
+ server.registerTool('list_merge_queue', {
602
+ description: 'List the merge queue (filter by state). Use it to check entries sent back to needs_review by task_done.',
603
+ inputSchema: {
604
+ state: z.enum(['queued', 'merging', 'merged', 'needs_review']).optional(),
605
+ },
606
+ }, async (a) => ok(listMergeQueue(cdb, a.state)));
607
+ }
608
+ // ── publisher observation (read-only) ───────────────────────────
609
+ server.registerTool('publisher_status', {
610
+ description: 'Read PR progress from the board (read-only; does not create PRs). Returns counts and lists (with integration branch names) for tickets awaiting a PR (pending_count, pending_tickets), awaiting a follow-up push to an existing PR (pending_update_count, pending_update_tickets), and awaiting a PR merge (awaiting_merge_count, awaiting_merge_tickets), plus the times of the last PR created and last merge recorded (last_pr_opened_at, last_pr_merged_at). The counts can overlap, so do not add them up. If pending_count or pending_update_count is 1 or more, run lockage-publish --repo-path <repo absolute path>; if awaiting_merge_count is 1 or more, run lockage-publish --repo-path <repo absolute path> --mode reconcile.',
611
+ inputSchema: {},
612
+ }, async () => ok(computePublisherStatus(cdb)));
613
+ // ── publish advisory lock (prevents a per-ticket read→act race) ──────
614
+ if (toolsets.has('publisher')) {
615
+ server.registerTool('acquire_publish_lock', {
616
+ description: 'lockage-publish takes a lock to prevent other processes from processing the same ticket while it processes it. If acquired, { ok:true, holder }; if another holds it, { ok:false }. A lock whose lease (ttl_sec, default 300 seconds) has expired can be taken over. The lock is per ticket, so it does not block other tickets.',
617
+ inputSchema: {
618
+ ticket_id: z.string(),
619
+ ttl_sec: z.number().int().positive().optional().describe('the lock TTL in seconds. Default 300'),
620
+ },
621
+ },
622
+ // This is a CAS write (INSERT/DELETE), not a read, so wrap the result with ok().
623
+ async (a) => ok(acquirePublishLock(cdb, a.ticket_id, { ttlSec: a.ttl_sec })));
624
+ server.registerTool('release_publish_lock', {
625
+ description: 'Release a lock taken via acquire_publish_lock. Deletes it only when holder matches (does not delete a lock that expired and was taken over by someone else). Returns {ok:true} regardless of whether it was deleted.',
626
+ inputSchema: {
627
+ ticket_id: z.string(),
628
+ holder: z.string(),
629
+ },
630
+ }, async (a) => {
631
+ releasePublishLock(cdb, a.ticket_id, a.holder);
632
+ return ok({ ok: true });
633
+ });
634
+ }
635
+ // ── 2.7 Waiting (A2A wake) ─────────────────────────────────────
636
+ server.registerTool('wait_for_unblock', {
637
+ description: 'Wait once until one of the following happens: a task you are waiting on becomes ready; a board post addressed to you or to everyone (regardless of kind); an overlap with another agent; or timeout. Returns what woke you via woke_by. Use it instead of repeatedly polling lists.',
638
+ inputSchema: {
639
+ agent_id: z.string(),
640
+ task_id: z.string().optional().describe('the task whose dependency resolution you are waiting for'),
641
+ timeout_sec: z.number().int().optional().describe('default 60'),
642
+ },
643
+ }, async (a) => {
644
+ heartbeat(cdb, a.agent_id);
645
+ return ok(await waitForUnblock(cdb, {
646
+ agentId: a.agent_id,
647
+ taskId: a.task_id,
648
+ timeoutSec: a.timeout_sec,
649
+ }));
650
+ });
651
+ return server;
652
+ }
653
+ async function main() {
654
+ const toolsets = parseToolsets(process.env.LOCKAGE_TOOLSETS);
655
+ const cdb = openDb({ path: DB_PATH });
656
+ const server = buildServer(cdb, { toolsets });
657
+ const transport = new StdioServerTransport();
658
+ await server.connect(transport);
659
+ // stdio keeps the process alive while connected. On SIGINT/SIGTERM, close the DB and exit.
660
+ const shutdown = () => {
661
+ try {
662
+ cdb.close();
663
+ }
664
+ finally {
665
+ process.exit(0);
666
+ }
667
+ };
668
+ process.on('SIGINT', shutdown);
669
+ process.on('SIGTERM', shutdown);
670
+ // Startup log to stderr only (stdout is reserved for JSON-RPC, so do not write there)
671
+ process.stderr.write(`[lockage] ready (db=${DB_PATH}, toolsets=${['core', ...toolsets].join(',')})\n`);
672
+ }
673
+ // Start only when run as the entry point (do not start when imported from tests)
674
+ import { fileURLToPath } from 'node:url';
675
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
676
+ const fatal = (e) => {
677
+ process.stderr.write(`[lockage] fatal: ${e?.stack ?? e}\n`);
678
+ process.exit(1);
679
+ };
680
+ // Dispatch to the hook CLI only when argv[2] is 'hook'/'setup'. No args or an unknown argv[2]
681
+ // (e.g. 'serve') starts the MCP server as before. The dynamic import keeps the hook logic and git
682
+ // execution from loading on a no-arg MCP start (lighter startup, fewer side effects).
683
+ const sub = process.argv[2];
684
+ if (sub === 'hook' || sub === 'setup') {
685
+ import('./hook/dispatch.js')
686
+ .then((m) => m.dispatchCli(process.argv.slice(2)))
687
+ .catch(fatal);
688
+ }
689
+ else {
690
+ main().catch(fatal);
691
+ }
692
+ }
693
+ export { fail, ok };
694
+ //# sourceMappingURL=index.js.map