lockage 0.0.0-stage → 0.9.1

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 +43 -4
@@ -0,0 +1,563 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { ulid } from 'ulid';
4
+ import { unixNow } from '../db/index.js';
5
+ import { git, resolveBaseBranch, resolveRefSha, safeSlug, short } from './git.js';
6
+ /**
7
+ * #36 / #41: a helper that realpath-normalizes a path to absorb symlink-resolution differences.
8
+ *
9
+ * `git worktree list --porcelain` may return a normalized path with symlinks resolved (e.g. on macOS
10
+ * `/var/...` → `/private/var/...`). On the other hand, the ledger (worktree_assignments.worktree_path)
11
+ * used to hold the path assignWorktree built with `join(...)` (a path that does not resolve symlinks).
12
+ *
13
+ * The #41 fix made assignWorktree normalize with normalizePath before saving, but existing ledger rows
14
+ * (saved un-normalized before #41) remain with symlinks unresolved, so normalization on the read side of
15
+ * reclaimOrphanWorktrees is still needed as a "backward-compatibility layer for old rows". We do not run a
16
+ * migration to bulk realpath-rewrite existing ledger rows. Reasons:
17
+ * (1) rows for already-deleted worktrees make realpathSync fail.
18
+ * (2) it would require touching released history rows too.
19
+ * (3) normalization on the read side (reclaimOrphanWorktrees) already absorbs matching old rows.
20
+ *
21
+ * realpathSync throws when, e.g., the target does not exist, so in that case best-effort return the input
22
+ * path as-is (cannot normalize = fall back to the original string comparison). In assignWorktree's
23
+ * pre-save normalization (#41) it is called "after the physical worktree is created with git worktree
24
+ * add", so in the normal path it always resolves. In reclaimOrphanWorktrees' read-side normalization
25
+ * (#36), the ledger path of an already-deleted worktree may fall back, in which case we warn via console.error.
26
+ */
27
+ function normalizePath(p) {
28
+ try {
29
+ return realpathSync(p);
30
+ }
31
+ catch (e) {
32
+ // When the target does not exist / permission is insufficient, etc., we cannot normalize, so return
33
+ // the input as-is (best effort). But a silent fallback would mean that if only one side fails to
34
+ // normalize and an orphan is misidentified, no one notices in operation. Leave a warning for which
35
+ // path realpathSync failed on (this codebase's convention is to emit diagnostic logs to console.error).
36
+ const detail = e instanceof Error ? e.message : String(e);
37
+ console.error(`[worktree] normalizePath: realpathSync failed, using the input path as-is (path=${p}): ${detail}`);
38
+ return p;
39
+ }
40
+ }
41
+ /** Whether the branch already exists in the repo (local ref). */
42
+ function branchExists(repoPath, branch) {
43
+ try {
44
+ git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /**
52
+ * §4.5 Automatic worktree assignment.
53
+ * Physically isolate each agent with a git worktree (the core that makes "two agents writing the same
54
+ * file at once" physically impossible). The advisory claim is the coordination layer on top of this.
55
+ *
56
+ * - Reuse an existing active assignment if present (idempotent).
57
+ * - The branch name/path are deterministic (lockage/<agent>/<slug>). On reconnect you return to the same worktree.
58
+ * - The UNIQUE constraint on worktree_path guarantees "1 worktree = 1 agent" at the DB level.
59
+ *
60
+ * #41 fix: worktree_path is normalized with realpathSync after the physical worktree is created and then
61
+ * saved to the ledger, and the return value is the normalized value too. Downstream exact-match
62
+ * comparisons on worktree_path (releaseWorktree, merge.ts's onTaskDone, files.ts's publish_dirty,
63
+ * claim.ts's handoff) all have the structure of either "the caller passes the worktree_path that
64
+ * assignWorktree returned as the key" or "use the worktree_path read from the ledger", so once the saved
65
+ * value is normalized they naturally match normalized-against-normalized and no new normalization call is
66
+ * needed (fixing #36's one-sided fallback mismatch on the write side).
67
+ *
68
+ * @param repoPath the filesystem root of the target repo (the multi-repo `git -C` target).
69
+ */
70
+ export function assignWorktree(cdb, args) {
71
+ const db = cdb.db;
72
+ const now = unixNow();
73
+ const baseRef = args.baseRef ?? resolveBaseBranch(args.repoPath);
74
+ // Best-effort resolve the real existing SHA of the branch point (FEAT-002). Used later to compute the
75
+ // diff from base (git diff --name-only <base_sha>...HEAD); recorded only at new assignment time. When it
76
+ // cannot be resolved (ref missing, empty repo, etc.) it is null → base_sha is saved as NULL and the diff
77
+ // computation falls back to status only when base_sha is absent. On the idempotent-reuse path of an
78
+ // existing active assignment, base_sha is not overwritten (keep the first branch point).
79
+ const baseSha = resolveRefSha(args.repoPath, baseRef);
80
+ // Generate the branch name/path deterministically (avoid collisions). Because it includes the repo name
81
+ // + agent + task slug, only calls with the "same agent, same repo, same task" land on the same path;
82
+ // different repo / different task become different paths. The reuse decision keys on this deterministic
83
+ // path (keying on agent_id alone would return the first worktree even when a different repo or task is
84
+ // requested — wrong for multi-repo / multiple assigned tasks).
85
+ const slug = args.taskId ? short(args.taskId) : short(args.agentId);
86
+ const agentSlug = safeSlug(args.agentId);
87
+ const branch = `lockage/${agentSlug}/${slug}`;
88
+ // Dig a .lockage-worktrees/ under the repo's parent directory.
89
+ const repoParent = dirname(args.repoPath);
90
+ const repoName = basename(args.repoPath);
91
+ const path = join(repoParent, '.lockage-worktrees', `${repoName}-${agentSlug}-${slug}`);
92
+ // 1) Reuse an existing active assignment if present (idempotent). Key on the active row of this deterministic path.
93
+ // #41: the ledger now saves normalized paths, but old ledger rows (saved un-normalized before #41) remain
94
+ // as the original join() path. Because the deterministic path for the same agent/repo/task matches as a
95
+ // string, the reuse decision picks up both old and new rows whether on path (un-normalized) or
96
+ // canonicalPath (normalized). However, for a new assignment where the physical worktree does not exist
97
+ // yet, realpathSync fails, so here we query with the un-normalized path and re-check with the normalized
98
+ // path after the physical worktree is created.
99
+ const existing = db
100
+ .prepare(`SELECT * FROM worktree_assignments
101
+ WHERE agent_id = ? AND worktree_path = ? AND status = 'active' LIMIT 1`)
102
+ .get(args.agentId, path);
103
+ if (existing) {
104
+ // #41 follow-up (review finding #2): when reusing an old ledger row (saved un-normalized before #41),
105
+ // if the return value stays un-normalized, downstream exact-match comparisons on worktree_path diverge
106
+ // between the "normalized new row" and the "un-normalized old row", leaving the theoretical room for a
107
+ // normalized and an un-normalized row to coexist for the same worktree (a re-INSERT after release
108
+ // slips past UNIQUE as a different string). To close this, lazily normalize the saved value on reuse too.
109
+ //
110
+ // Safety: normalizePath only folds to the real path when the target physically exists, and returns the
111
+ // input as-is when it does not (= an old row for a deleted worktree). Therefore:
112
+ // - an old row whose physical worktree remains is rewritten to normalized here and converges
113
+ // (thereafter all comparisons for that worktree match normalized-against-normalized).
114
+ // - an old row for a deleted worktree returns the original value on realpath failure (keeps backward compatibility).
115
+ // If the UPDATE would violate the partial UNIQUE (idx_wt_path_active) (= the rare case where a
116
+ // normalized active row for the same real path already exists), give up the rewrite and return the
117
+ // original saved value as-is (fail-safe).
118
+ const reusedCanonical = normalizePath(existing.worktree_path);
119
+ if (reusedCanonical !== existing.worktree_path) {
120
+ try {
121
+ db.prepare(`UPDATE worktree_assignments SET worktree_path = ? WHERE id = ?`).run(reusedCanonical, existing.id);
122
+ return {
123
+ worktree_path: reusedCanonical,
124
+ branch: existing.branch,
125
+ reused: true,
126
+ };
127
+ }
128
+ catch {
129
+ // If it cannot be rewritten due to a UNIQUE collision etc., return the old value as-is (status quo = fail-safe).
130
+ }
131
+ }
132
+ return {
133
+ worktree_path: existing.worktree_path,
134
+ branch: existing.branch,
135
+ reused: true,
136
+ };
137
+ }
138
+ // 2) Physically create the worktree (if it exists, skip creation and only record).
139
+ // Pass the original pre-normalization path to git worktree add as-is. normalizePath is used only for
140
+ // matching/saving; passing a realpath to the git command could cause other problems depending on the
141
+ // environment, so follow the existing reclaimOrphanWorktrees convention of "pass the original p to remove" (#41).
142
+ if (!existsSync(path)) {
143
+ if (branchExists(args.repoPath, branch)) {
144
+ // Add an existing branch without -b (reconnect / continuation of previous work)
145
+ git(args.repoPath, ['worktree', 'add', path, branch]);
146
+ }
147
+ else {
148
+ git(args.repoPath, ['worktree', 'add', '-b', branch, path, baseRef]);
149
+ }
150
+ }
151
+ // #41 fix: with the physical worktree present, realpathSync-normalize and make the ledger-saved value and
152
+ // the return value normalized. This makes downstream exact-match comparisons on worktree_path
153
+ // (releaseWorktree, merge.ts's onTaskDone, files.ts's publish_dirty, claim.ts's handoff) naturally match
154
+ // normalized-against-normalized, fixing the "one-sided fallback mismatch" raised in #36 on the write
155
+ // side. realpathSync throws when the target does not exist, so it must be called after git worktree add
156
+ // creates the physical directory.
157
+ const canonicalPath = normalizePath(path);
158
+ // #41: do the reuse decision with the normalized path too. If an old ledger row is saved with an
159
+ // un-normalized path, the SELECT above on path picks it up, while a row newly saved with a normalized
160
+ // path can differ as a string (symlink environments). Conversely, querying with the normalized path picks
161
+ // up rows saved since #41. For the same agent/repo/task's deterministic path, many normal environments
162
+ // have path === canonicalPath, but to safely handle the symlink case we check both.
163
+ if (canonicalPath !== path) {
164
+ const existingCanonical = db
165
+ .prepare(`SELECT * FROM worktree_assignments
166
+ WHERE agent_id = ? AND worktree_path = ? AND status = 'active' LIMIT 1`)
167
+ .get(args.agentId, canonicalPath);
168
+ if (existingCanonical) {
169
+ return {
170
+ worktree_path: existingCanonical.worktree_path,
171
+ branch: existingCanonical.branch,
172
+ reused: true,
173
+ };
174
+ }
175
+ }
176
+ // 3) Record. The UNIQUE constraint on worktree_path rejects double assignment at the DB.
177
+ // BEGIN IMMEDIATE serializes numbering through INSERT (avoids the concurrent-assignment race).
178
+ // #41: the saved value uses the normalized canonicalPath. The return value is the same.
179
+ db.exec('BEGIN IMMEDIATE');
180
+ try {
181
+ // Re-check in case another path recorded the same worktree just before (query with the normalized path)
182
+ const racer = db
183
+ .prepare(`SELECT * FROM worktree_assignments WHERE worktree_path = ? AND status = 'active'`)
184
+ .get(canonicalPath);
185
+ if (racer) {
186
+ db.exec('ROLLBACK');
187
+ return {
188
+ worktree_path: racer.worktree_path,
189
+ branch: racer.branch,
190
+ reused: true,
191
+ };
192
+ }
193
+ db.prepare(`INSERT INTO worktree_assignments
194
+ (id, agent_id, worktree_path, repo_path, branch, base_sha, task_id, status, created_at, released_at, host_id, origin)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, NULL, ?, 'local')`).run(ulid(), args.agentId, canonicalPath, args.repoPath, branch, baseSha, args.taskId ?? null, now, cdb.hostId);
196
+ db.exec('COMMIT');
197
+ }
198
+ catch (e) {
199
+ try {
200
+ db.exec('ROLLBACK');
201
+ }
202
+ catch {
203
+ /* already closed */
204
+ }
205
+ throw e;
206
+ }
207
+ return { worktree_path: canonicalPath, branch, reused: false };
208
+ }
209
+ /**
210
+ * Infer why `git worktree remove` was refused from the worktree's working-tree state.
211
+ *
212
+ * Classify by the leading 2 characters of each `git status --porcelain` line:
213
+ * - starts with `??` = untracked file.
214
+ * - otherwise (` M`/`M `/`A `/`D `, etc.) = a tracked uncommitted diff.
215
+ * If there is even one tracked diff, prefer 'dirty' (because the information lost is larger); if only
216
+ * untracked, return 'untracked'. If fetching the status itself fails, treat it as 'dirty' as the cause is
217
+ * unknown (fail-safe: keep it active so it can be reclaimed).
218
+ */
219
+ function classifyRemoveFailure(worktreePath) {
220
+ let porcelain;
221
+ try {
222
+ porcelain = git(worktreePath, ['status', '--porcelain']);
223
+ }
224
+ catch {
225
+ // When even status cannot be fetched (abnormal), treat it as dirty (keep active, fail-safe).
226
+ return 'dirty';
227
+ }
228
+ const lines = porcelain.split('\n').filter((l) => l.length > 0);
229
+ let hasTracked = false;
230
+ let hasUntracked = false;
231
+ for (const line of lines) {
232
+ if (line.startsWith('??'))
233
+ hasUntracked = true;
234
+ else
235
+ hasTracked = true;
236
+ }
237
+ // Prefer 'dirty' if there is a tracked diff. If none and only untracked, 'untracked'.
238
+ if (hasTracked)
239
+ return 'dirty';
240
+ if (hasUntracked)
241
+ return 'untracked';
242
+ // remove was refused even though no diff is found (locked, etc.). Dirty on the fail-safe side.
243
+ return 'dirty';
244
+ }
245
+ /**
246
+ * §4.5 Worktree release.
247
+ * - keep_branch defaults to true (branch deletion is destructive, so only when explicitly false).
248
+ * - Runs `git worktree remove` (does not use --force because git refuses when there are uncommitted changes).
249
+ * - Only when remove succeeds does it set the ledger to released and clear that worktree's dirty set
250
+ * (§4.4 — cleared on mainline reflection or discard).
251
+ *
252
+ * The #33 fix: previously, even when remove was refused due to untracked files / uncommitted diffs
253
+ * (removed=false), the ledger was unconditionally flipped to released and dirty_sets was deleted. As a
254
+ * result, the physical worktree and its `git worktree list` registration remained while the active ledger
255
+ * row disappeared, becoming an "orphan" that does not appear in list_worktrees and cannot be re-released.
256
+ * This is changed to "only when removed=true, set the ledger to released and clear dirty_sets. When
257
+ * removed=false, leave the status active, touch neither the ledger nor dirty_sets, and put 'untracked' /
258
+ * 'dirty' in the returned reason so the caller can notice". Branch deletion (keep_branch=false) is also
259
+ * done only when removed=true (deleting the branch while the worktree remains breaks the worktree).
260
+ */
261
+ export function releaseWorktree(cdb, args) {
262
+ const db = cdb.db;
263
+ const now = unixNow();
264
+ const keepBranch = args.keepBranch ?? true;
265
+ const row = db
266
+ .prepare(`SELECT * FROM worktree_assignments
267
+ WHERE worktree_path = ? AND agent_id = ? AND status = 'active'`)
268
+ .get(args.worktreePath, args.agentId);
269
+ if (!row)
270
+ return { ok: false, removed: false };
271
+ // Detach the physical worktree. If it does not exist, only update the record.
272
+ // When there are uncommitted changes `git worktree remove` refuses (do not use --force = avoid destruction).
273
+ // Swallow the failure and return removed:false + reason (do not let the exception escape).
274
+ let removed = false;
275
+ let reason;
276
+ if (existsSync(args.worktreePath)) {
277
+ try {
278
+ git(args.repoPath, ['worktree', 'remove', args.worktreePath]);
279
+ removed = true;
280
+ }
281
+ catch {
282
+ // Refused due to untracked files / uncommitted diffs → keep the physical worktree.
283
+ // Attach a reason so the caller can tell the cause (#33).
284
+ removed = false;
285
+ reason = classifyRemoveFailure(args.worktreePath);
286
+ }
287
+ }
288
+ else {
289
+ // The directory is gone but git may still have debris, so prune.
290
+ // A missing directory means "the working tree is already gone" = treat as released and set removed=true.
291
+ try {
292
+ git(args.repoPath, ['worktree', 'prune']);
293
+ }
294
+ catch {
295
+ /* best effort */
296
+ }
297
+ removed = true;
298
+ }
299
+ // When removed=false, touch neither the ledger, dirty_sets, nor the branch; leave it active (#33).
300
+ // Releasing again later (e.g. after stashing uncommitted diffs) can reclaim it.
301
+ if (!removed) {
302
+ return { ok: true, removed: false, reason };
303
+ }
304
+ // Delete the branch only when removed=true (deleting the branch while the worktree remains breaks it).
305
+ if (!keepBranch) {
306
+ try {
307
+ git(args.repoPath, ['branch', '-D', row.branch]);
308
+ }
309
+ catch {
310
+ /* ignore when the branch is missing / checked out, etc. */
311
+ }
312
+ }
313
+ db.exec('BEGIN IMMEDIATE');
314
+ try {
315
+ db.prepare(`UPDATE worktree_assignments SET status = 'released', released_at = ?
316
+ WHERE id = ?`).run(now, row.id);
317
+ // On discard, clear that worktree's dirty-set row (the discipline at the end of §4.5)
318
+ db.prepare(`DELETE FROM dirty_sets WHERE worktree_path = ?`).run(args.worktreePath);
319
+ db.exec('COMMIT');
320
+ }
321
+ catch (e) {
322
+ try {
323
+ db.exec('ROLLBACK');
324
+ }
325
+ catch {
326
+ /* already closed */
327
+ }
328
+ throw e;
329
+ }
330
+ return { ok: true, removed: true };
331
+ }
332
+ /**
333
+ * §4.5 addendum (#32 option 1): release in bulk the active worktree assignments tied to done tasks under
334
+ * the given ticket.
335
+ *
336
+ * Since #16 changed "tasks under a ticket are 1-B even for a single one", the 1-B path (processMergeQueue→
337
+ * finalizeMergedTask) did not release worktrees and reconcile did not touch worktrees either, so under
338
+ * ticket operation the worktrees and lockage/<agent>/<slug> branches of completed tasks kept piling up.
339
+ * Clean them up by calling this from reconcile's MERGED path (after confirming all tasks are done and the PR is merged).
340
+ *
341
+ * Conditions for release:
342
+ * - worktree_assignments.status = 'active'
343
+ * - task_id is not NULL (worktree_assignments.task_id can become NULL via ON DELETE SET NULL, but with
344
+ * NULL the ticket link cannot be traced, so exclude it).
345
+ * - that task_id's tasks.ticket_id matches the given ticket, and tasks.status = 'done'.
346
+ *
347
+ * For each target row call releaseWorktree keyed on (agent_id, worktree_path). Because the calling
348
+ * publisher needs to release worktrees of multiple agents under the ticket, use each assignment row's
349
+ * agent_id (not the publisher's own agent_id). Call it with keep_branch=false equivalent to also clean up
350
+ * the task branch (lockage/<agent>/<slug>) merged via the integration branch.
351
+ *
352
+ * By the FEAT-002 fix, releaseWorktree returns removed:false on remove failure (uncommitted diffs present)
353
+ * and leaves the ledger active, so a worktree not released here does not become an orphan; it remains
354
+ * active and can be reclaimed later (safe). Because releaseWorktree is idempotent on the presence of the
355
+ * active row, a double reconcile run does not double-release.
356
+ *
357
+ * @param ticketId the target ticket (matched against tasks.ticket_id).
358
+ * @param repoPath the FS root of the repo where the worktrees physically live (the `git -C` target). The
359
+ * assignment row's repo_path is preferred; this value is used as a fallback when it is
360
+ * absent (old DB row).
361
+ */
362
+ export function releaseWorktreesForTicket(cdb, args) {
363
+ const db = cdb.db;
364
+ // keep_branch defaults to false (merged via the integration branch, so the task branch may be deleted too = #32 option 1).
365
+ const keepBranch = args.keepBranch ?? false;
366
+ // Enumerate active assignments tied to done tasks under the ticket. Rows with NULL task_id drop out of
367
+ // the JOIN (= excluded). tasks.status='done' avoids wrongly deleting worktrees of incomplete tasks.
368
+ const rows = db
369
+ .prepare(`SELECT wa.agent_id AS agent_id,
370
+ wa.worktree_path AS worktree_path,
371
+ wa.repo_path AS repo_path,
372
+ wa.task_id AS task_id
373
+ FROM worktree_assignments wa
374
+ JOIN tasks t ON t.id = wa.task_id
375
+ WHERE wa.status = 'active'
376
+ AND t.ticket_id = ?
377
+ AND t.status = 'done'
378
+ ORDER BY wa.created_at ASC`)
379
+ .all(args.ticketId);
380
+ const released = [];
381
+ let removedCount = 0;
382
+ for (const r of rows) {
383
+ // Old DB rows may have NULL repo_path, so use the repoPath argument as a fallback.
384
+ const repoPath = r.repo_path ?? args.repoPath;
385
+ const res = releaseWorktree(cdb, {
386
+ agentId: r.agent_id,
387
+ worktreePath: r.worktree_path,
388
+ repoPath,
389
+ keepBranch,
390
+ });
391
+ if (res.removed)
392
+ removedCount += 1;
393
+ released.push({
394
+ agent_id: r.agent_id,
395
+ worktree_path: r.worktree_path,
396
+ task_id: r.task_id,
397
+ removed: res.removed,
398
+ ...(res.reason ? { reason: res.reason } : {}),
399
+ });
400
+ }
401
+ return { released, removed_count: removedCount };
402
+ }
403
+ /**
404
+ * List of active worktrees.
405
+ * @param ticketId when given, narrow to only worktrees assigned to tasks under that ticket
406
+ * (assignments with NULL task_id are excluded).
407
+ */
408
+ export function listWorktrees(cdb, agentId, ticketId) {
409
+ const clauses = [`status = 'active'`];
410
+ const params = [];
411
+ if (agentId) {
412
+ clauses.push('agent_id = ?');
413
+ params.push(agentId);
414
+ }
415
+ if (ticketId) {
416
+ clauses.push('task_id IN (SELECT id FROM tasks WHERE ticket_id = ?)');
417
+ params.push(ticketId);
418
+ }
419
+ return cdb.db
420
+ .prepare(`SELECT * FROM worktree_assignments WHERE ${clauses.join(' AND ')} ORDER BY created_at ASC`)
421
+ .all(...params);
422
+ }
423
+ /**
424
+ * §4.5 addendum (#35 option 2, visualization): a read-only pure function that enumerates worktrees still
425
+ * active even though they are tied to done tasks.
426
+ *
427
+ * Background: release_worktree / release_ticket_worktrees have `git worktree remove` (without --force)
428
+ * refused when there are uncommitted tracked diffs (dirty), and prioritize data protection by leaving the
429
+ * ledger active (#33's orphan fix). reconcile's MERGED path auto-releases done-task worktrees (#32), but it
430
+ * is made idempotent by the pr_merged post and does not reprocess that ticket thereafter, so a worktree
431
+ * left active due to dirty is never auto-retried again. The purpose of this function is to let operators
432
+ * observe this asymmetry where it does not orphan yet is not auto-reclaimed. A worktree listed here is
433
+ * handled by stashing the diff (commit/stash/discard) and then calling release_worktree (it cannot be
434
+ * reclaimed by reclaim_orphan_worktrees, which only handles orphans, because it is still active in the ledger).
435
+ *
436
+ * Like releaseWorktreesForTicket, the query JOINs worktree_assignments to tasks via task_id and targets
437
+ * only rows with status='active' and the corresponding task status='done'. Assignments with NULL task_id
438
+ * drop out of the JOIN (= excluded). Whereas list_worktrees returns all active, this function narrows to
439
+ * done tasks and shows only the "unreleased candidates". The server itself releases nothing (observation only).
440
+ *
441
+ * @param args.ticketId when given, narrow with t.ticket_id = ? (all tickets when omitted).
442
+ * @param args.agentId when given, narrow with wa.agent_id = ? (all agents when omitted).
443
+ */
444
+ export function listStaleWorktrees(cdb, args) {
445
+ const conditions = [
446
+ "wa.status = 'active'",
447
+ "t.status = 'done'",
448
+ ];
449
+ const params = [];
450
+ if (args?.ticketId !== undefined) {
451
+ conditions.push('t.ticket_id = ?');
452
+ params.push(args.ticketId);
453
+ }
454
+ if (args?.agentId !== undefined) {
455
+ conditions.push('wa.agent_id = ?');
456
+ params.push(args.agentId);
457
+ }
458
+ return cdb.db
459
+ .prepare(`SELECT wa.*,
460
+ t.status AS task_status,
461
+ t.ticket_id AS ticket_id
462
+ FROM worktree_assignments wa
463
+ JOIN tasks t ON t.id = wa.task_id
464
+ WHERE ${conditions.join(' AND ')}
465
+ ORDER BY wa.created_at ASC`)
466
+ .all(...params);
467
+ }
468
+ /**
469
+ * §4.5 addendum (together with #33): enumerate/reclaim already-orphaned worktrees.
470
+ *
471
+ * A means to reclaim in operation the orphans born before the #33 fix (the physical worktree and its
472
+ * `git worktree list` registration remain while there is no active ledger row). Read the given repo's
473
+ * `git worktree list --porcelain`, target only the paths under lockage's dug `.lockage-worktrees/`, and
474
+ * match them against the ledger (worktree_assignments).
475
+ *
476
+ * "Physically present but not active in the ledger" = treat as an orphan:
477
+ * - if there is an active row for that worktree_path in the ledger, it is normal (excluded).
478
+ * - if there is no active row but a released row, ledger_status='released' (a pre-#33 orphan).
479
+ * - if there is no row at all, ledger_status='none' (the ledger split / was manually deleted, etc.).
480
+ *
481
+ * dry_run=true (default) only enumerates. When false, attempt `git worktree remove` (without --force) on
482
+ * each orphan. Those with untracked files or tracked uncommitted diffs remaining return removed=false +
483
+ * reason and are not destroyed (do not use --force).
484
+ *
485
+ * @param repoPath the filesystem root of the target repo (the `git -C` target = the main repo).
486
+ * @param dryRun defaults to true (enumeration only). false to attempt reclaim.
487
+ */
488
+ export function reclaimOrphanWorktrees(cdb, args) {
489
+ const db = cdb.db;
490
+ const dryRun = args.dryRun ?? true;
491
+ // Collect physical paths from the `worktree <path>` lines of `git worktree list --porcelain`.
492
+ // On failure (not a repo, etc.) return empty as "no orphans" (best effort).
493
+ let porcelain;
494
+ try {
495
+ porcelain = git(args.repoPath, ['worktree', 'list', '--porcelain']);
496
+ }
497
+ catch {
498
+ return { orphans: [], removed_count: 0 };
499
+ }
500
+ const physicalPaths = [];
501
+ for (const line of porcelain.split('\n')) {
502
+ if (line.startsWith('worktree ')) {
503
+ physicalPaths.push(line.slice('worktree '.length));
504
+ }
505
+ }
506
+ // #36: the ledger's worktree_path is the path assignWorktree built with join() (symlinks unresolved),
507
+ // so matching it against the git-derived path (sometimes realpath-resolved) with an exact string compare
508
+ // diverges in some environments. So pull all active/released rows from the ledger, pre-build a set of
509
+ // realpath-normalized worktree_path values, and match by whether the normalized value of the git-derived
510
+ // path is in that set (drop the SQL `= ?` direct comparison). The path count is at most the worktree
511
+ // count, assumed to be not that many. Also keep normalized value → original DB-saved value.
512
+ const activeNorm = new Set();
513
+ const releasedNorm = new Set();
514
+ for (const row of db
515
+ .prepare(`SELECT worktree_path, status FROM worktree_assignments WHERE status IN ('active', 'released')`)
516
+ .all()) {
517
+ const norm = normalizePath(row.worktree_path);
518
+ if (row.status === 'active')
519
+ activeNorm.add(norm);
520
+ else
521
+ releasedNorm.add(norm);
522
+ }
523
+ // #36: exclude the main repo itself with normalized-against-normalized values too (repoPath and git get the same normalization).
524
+ const repoNorm = normalizePath(args.repoPath);
525
+ const orphans = [];
526
+ let removedCount = 0;
527
+ for (const p of physicalPaths) {
528
+ // #36: use the normalized value of the git-derived path for matching (pass the original p to remove as-is).
529
+ const pNorm = normalizePath(p);
530
+ // Do not target the main repo itself or worktrees unmanaged by lockage (only under .lockage-worktrees/).
531
+ if (p === args.repoPath || pNorm === repoNorm)
532
+ continue;
533
+ // The .lockage-worktrees inclusion check targets it if either the original path or the normalized path
534
+ // contains it (do not miss cases where .lockage-worktrees does not appear in the symlink name but only
535
+ // in the real path).
536
+ if (!p.includes('.lockage-worktrees') && !pNorm.includes('.lockage-worktrees'))
537
+ continue;
538
+ // If there is an active row in the ledger, it is normal (not an orphan). Match normalized-against-normalized (#36).
539
+ if (activeNorm.has(pNorm))
540
+ continue;
541
+ // A physical worktree with no active = an orphan. Split ledger_status by the presence of a released row (decided by normalized value).
542
+ const orphan = {
543
+ worktree_path: p,
544
+ ledger_status: releasedNorm.has(pNorm) ? 'released' : 'none',
545
+ };
546
+ if (!dryRun) {
547
+ // Do not use --force. If untracked files or tracked uncommitted diffs remain, git refuses, so leave
548
+ // it with a reason (do not destroy).
549
+ try {
550
+ git(args.repoPath, ['worktree', 'remove', p]);
551
+ orphan.removed = true;
552
+ removedCount += 1;
553
+ }
554
+ catch {
555
+ orphan.removed = false;
556
+ orphan.reason = classifyRemoveFailure(p);
557
+ }
558
+ }
559
+ orphans.push(orphan);
560
+ }
561
+ return { orphans, removed_count: removedCount };
562
+ }
563
+ //# sourceMappingURL=worktree.js.map
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "lockage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1",
4
+ "description": "Lockage — an MCP server for multiple coding LLMs to collaborate around a local git repository (single host)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "!dist/**/*.map"
10
+ ],
11
+ "bin": {
12
+ "lockage": "dist/index.js",
13
+ "lockage-publish": "dist/launcher/publish-cli.js"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc && cp src/db/schema.sql dist/db/schema.sql && chmod +x dist/index.js dist/launcher/publish-cli.js",
17
+ "prepublishOnly": "rm -rf dist && npm run build",
18
+ "dev": "tsx src/index.ts",
19
+ "pretest": "npm run build",
20
+ "test": "node --test --import tsx test/*.test.ts",
21
+ "test:unit": "node --test --import tsx \"test/!(e2e).test.ts\"",
22
+ "pretest:e2e": "npm run build",
23
+ "test:e2e": "node --test --import tsx test/e2e.test.ts",
24
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
25
+ "sync:plugins": "tsx scripts/sync-plugins.ts"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.0.0",
29
+ "minimatch": "^10.0.0",
30
+ "ulid": "^2.3.0",
31
+ "zod": "^4.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "tsx": "^4.0.0",
36
+ "typescript": "^5.5.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.5"
40
+ },
41
+ "allowScripts": {
42
+ "esbuild@0.28.2": true,
43
+ "fsevents@2.3.3": true
44
+ }
45
+ }