@ianwremmel/dispatch 0.32.1-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,170 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {assertInstant} from '../db/time.mts';
3
+ import {DataError, ensure} from '../errors/index.mts';
4
+ import type {Session} from '../model/types.mts';
5
+
6
+ /* eslint-disable @typescript-eslint/require-await --
7
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
8
+
9
+ /**
10
+ * Sessions: one row per live MCP server process, the only liveness primitive.
11
+ * Claims reference a session and cascade when it is deleted — on a
12
+ * clean `close`, or when `sweepStale` reaps a process whose heartbeat stopped.
13
+ */
14
+ export class SessionStore {
15
+ readonly #db: Database;
16
+
17
+ constructor(db: Database) {
18
+ this.#db = db;
19
+ }
20
+
21
+ async register(session: {
22
+ id: string;
23
+ host?: string | null;
24
+ pid?: number | null;
25
+ claudeSessionId?: string | null;
26
+ startedAt: string;
27
+ heartbeatAt: string;
28
+ }): Promise<void> {
29
+ assertInstant(session.startedAt, 'startedAt');
30
+ assertInstant(session.heartbeatAt, 'heartbeatAt');
31
+ this.#db.run(
32
+ `INSERT INTO session (id, host, pid, claude_session_id, started_at, heartbeat_at)
33
+ VALUES (?, ?, ?, ?, ?, ?)
34
+ ON CONFLICT(id) DO UPDATE SET
35
+ host = excluded.host, pid = excluded.pid,
36
+ claude_session_id = excluded.claude_session_id,
37
+ heartbeat_at = excluded.heartbeat_at`,
38
+ [
39
+ session.id,
40
+ session.host ?? null,
41
+ session.pid ?? null,
42
+ session.claudeSessionId ?? null,
43
+ session.startedAt,
44
+ session.heartbeatAt,
45
+ ]
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Record the session's positive acknowledgement of a probe, stamping the
51
+ * acking process's Claude session id onto the row — the one write where the
52
+ * server and the session it serves can be made to agree.
53
+ */
54
+ async ack(
55
+ id: string,
56
+ claudeSessionId: string | null,
57
+ at: string
58
+ ): Promise<boolean> {
59
+ assertInstant(at, 'at');
60
+ // An acking process with no session id of its own (an operator shell) must
61
+ // not wipe the id the server registered — that id is the caller correlator.
62
+ return (
63
+ this.#db.run(
64
+ `UPDATE session
65
+ SET acked_at = ?, claude_session_id = COALESCE(?, claude_session_id)
66
+ WHERE id = ?`,
67
+ [at, claudeSessionId, id]
68
+ ) > 0
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Heartbeat-fresh sessions carrying a Claude session id, for correlating a
74
+ * caller to its own server. Freshness is this store's half of liveness;
75
+ * callers judge the other half — whether the registered process still
76
+ * exists — through `lib/liveness`.
77
+ */
78
+ async liveForCaller(
79
+ claudeSessionId: string,
80
+ now: string,
81
+ windowSeconds: number
82
+ ): Promise<Session[]> {
83
+ assertInstant(now, 'now');
84
+ return this.#db
85
+ .all(
86
+ `SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
87
+ FROM session
88
+ WHERE claude_session_id = ?
89
+ AND unixepoch(?) - unixepoch(heartbeat_at) <= ?
90
+ ORDER BY id`,
91
+ [claudeSessionId, now, windowSeconds]
92
+ )
93
+ .map(toSession);
94
+ }
95
+
96
+ /** Every row carrying this Claude session id, fresh or not. */
97
+ async forCaller(claudeSessionId: string): Promise<Session[]> {
98
+ return this.#db
99
+ .all(
100
+ `SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
101
+ FROM session WHERE claude_session_id = ? ORDER BY id`,
102
+ [claudeSessionId]
103
+ )
104
+ .map(toSession);
105
+ }
106
+
107
+ async heartbeat(id: string, at: string): Promise<boolean> {
108
+ assertInstant(at, 'at');
109
+ return (
110
+ this.#db.run('UPDATE session SET heartbeat_at = ? WHERE id = ?', [
111
+ at,
112
+ id,
113
+ ]) > 0
114
+ );
115
+ }
116
+
117
+ /** Clean exit: delete the session; its claims cascade. */
118
+ async close(id: string): Promise<boolean> {
119
+ return this.#db.run('DELETE FROM session WHERE id = ?', [id]) > 0;
120
+ }
121
+
122
+ /**
123
+ * Reap sessions whose heartbeat is older than `windowSeconds` before `now`.
124
+ * The staleness sweep is the only place liveness is judged by age. Returns the
125
+ * number of sessions removed (their claims cascade).
126
+ */
127
+ async sweepStale(now: string, windowSeconds: number): Promise<number> {
128
+ assertInstant(now, 'now');
129
+ ensure(
130
+ Number.isFinite(windowSeconds) && windowSeconds >= 0,
131
+ () =>
132
+ new DataError(`"${String(windowSeconds)}" is not a staleness window`, {
133
+ hint: 'pass a non-negative number of seconds; a negative window would sweep every live session.',
134
+ })
135
+ );
136
+ return this.#db.run(
137
+ 'DELETE FROM session WHERE unixepoch(?) - unixepoch(heartbeat_at) > ?',
138
+ [now, windowSeconds]
139
+ );
140
+ }
141
+
142
+ async getSession(id: string): Promise<Session | null> {
143
+ const row = this.#db.get(
144
+ `SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
145
+ FROM session WHERE id = ?`,
146
+ [id]
147
+ );
148
+ if (row === undefined) return null;
149
+ return toSession(row);
150
+ }
151
+ }
152
+
153
+ /* eslint-disable @typescript-eslint/no-base-to-string --
154
+ * SQLite hands back `unknown`; `String()` converts a primitive rather than
155
+ * asserting a type the row has not been checked for. */
156
+ function toSession(row: Record<string, unknown>): Session {
157
+ return {
158
+ id: String(row.id),
159
+ host: row.host === null ? null : String(row.host),
160
+ pid: row.pid === null ? null : Number(row.pid),
161
+ claudeSessionId:
162
+ row.claude_session_id === null ? null : String(row.claude_session_id),
163
+ ackedAt: row.acked_at === null ? null : String(row.acked_at),
164
+ startedAt: String(row.started_at),
165
+ heartbeatAt: String(row.heartbeat_at),
166
+ };
167
+ }
168
+ /* eslint-enable @typescript-eslint/no-base-to-string */
169
+
170
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,246 @@
1
+ import {assertInstant} from '../db/time.mts';
2
+ import type {Database} from '../db/database.mts';
3
+ import {DataError, ensure, UsageError} from '../errors/index.mts';
4
+ import {
5
+ isStatus,
6
+ isTargetKind,
7
+ STATUS_LIST,
8
+ TARGET_KIND_LIST,
9
+ } from '../model/status.mts';
10
+ import type {Ticket} from '../model/types.mts';
11
+ import {findNode, materialize} from './materialize.mts';
12
+
13
+ /* eslint-disable @typescript-eslint/require-await --
14
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
15
+
16
+ /** A partial write: `id` names the ticket, every other key is optional. */
17
+ export type TicketPatch = Partial<Omit<Ticket, 'id'>> & {id: string};
18
+
19
+ export class TicketStore {
20
+ readonly #db: Database;
21
+
22
+ constructor(db: Database) {
23
+ this.#db = db;
24
+ }
25
+
26
+ async upsertTicket(ticket: Ticket): Promise<void> {
27
+ this.#validate(ticket);
28
+ await this.#db.transaction(() => {
29
+ this.#apply(ticket, false);
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Write only the fields the caller named, leaving the rest as they are.
35
+ *
36
+ * A re-read answers with what the tracker returned for the fields it was
37
+ * asked about; it is not a declaration that everything absent is now empty.
38
+ * Assigning every column from a partial write blanks the title, url, and
39
+ * labels of a ticket the graph already knew, which is how a ranked, labelled
40
+ * ticket turns into an untitled one nobody can act on.
41
+ *
42
+ * Omitting a field preserves it; passing an explicit value sets it. A ticket
43
+ * the graph has never seen still needs `project` and `status`, since there is
44
+ * nothing to fall back to.
45
+ */
46
+ async patchTicket(patch: TicketPatch): Promise<void> {
47
+ this.#validate(patch);
48
+ await this.#db.transaction(() => {
49
+ this.#apply(patch, true);
50
+ });
51
+ }
52
+
53
+ #validate(input: TicketPatch): void {
54
+ const {status, targetKind} = input;
55
+ if (status !== undefined) {
56
+ ensure(
57
+ isStatus(status),
58
+ () =>
59
+ new DataError(`"${status}" is not a status`, {
60
+ hint: `use one of: ${STATUS_LIST}.`,
61
+ })
62
+ );
63
+ }
64
+ if (targetKind !== undefined) {
65
+ ensure(
66
+ isTargetKind(targetKind),
67
+ () =>
68
+ new DataError(`"${targetKind}" is not a target kind`, {
69
+ hint: `use one of: ${TARGET_KIND_LIST}.`,
70
+ })
71
+ );
72
+ }
73
+ if (input.updatedAt != null) assertInstant(input.updatedAt, 'updatedAt');
74
+ }
75
+
76
+ #apply(input: TicketPatch, merge: boolean): void {
77
+ const ticket = merge ? this.#merge(input) : (input as Ticket);
78
+ {
79
+ // The project must already be recorded: silently materializing a
80
+ // placeholder here re-parents the ticket onto an unknown-kind node the
81
+ // refresh cadence's project join can never see, so a session passing
82
+ // the project's name where the id belongs corrupts the graph without
83
+ // any error. Edges tolerate dangling references; a ticket's project
84
+ // does not.
85
+ const project = findNode(this.#db, ticket.project);
86
+ ensure(
87
+ project !== null && project.kind === 'project',
88
+ () =>
89
+ new UsageError(`"${ticket.project}" is not a recorded project`, {
90
+ hint: 'record it first with `dispatch project set`, and pass the project id — a project name is not its id.',
91
+ })
92
+ );
93
+ const projectId = project.id;
94
+ const nodeId = materialize(this.#db, ticket.id, 'ticket');
95
+ const previous = this.#db.get(
96
+ 'SELECT status FROM ticket WHERE node_id = ?',
97
+ [nodeId]
98
+ );
99
+ this.#db.run(
100
+ `INSERT INTO ticket (
101
+ node_id, project_id, url, title, status, target_kind,
102
+ requires_human, injected, priority, branch_hint, labels, updated_at
103
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104
+ ON CONFLICT(node_id) DO UPDATE SET
105
+ project_id = excluded.project_id, url = excluded.url,
106
+ title = excluded.title, status = excluded.status,
107
+ target_kind = excluded.target_kind,
108
+ requires_human = excluded.requires_human,
109
+ injected = excluded.injected, priority = excluded.priority,
110
+ branch_hint = excluded.branch_hint, labels = excluded.labels,
111
+ updated_at = excluded.updated_at`,
112
+ [
113
+ nodeId,
114
+ projectId,
115
+ ticket.url,
116
+ ticket.title,
117
+ ticket.status,
118
+ ticket.targetKind,
119
+ ticket.requiresHuman ? 1 : 0,
120
+ ticket.injected ? 1 : 0,
121
+ ticket.priority,
122
+ ticket.branchHint,
123
+ JSON.stringify(ticket.labels),
124
+ ticket.updatedAt,
125
+ ]
126
+ );
127
+ // The write is the answer to any outstanding re-read ask, whoever made
128
+ // it — the graph is current for this ticket as of this transaction.
129
+ this.#db.run(
130
+ `UPDATE fetch_request SET resolution = 'materialized'
131
+ WHERE kind = 'refresh_ticket' AND resolution IS NULL AND payload = ?`,
132
+ [JSON.stringify({ticket: ticket.id})]
133
+ );
134
+ // A status transition is the tracker speaking — an operator reply
135
+ // unparking a wait, a human moving the ticket — and is what a session
136
+ // must hear about without polling. Same event queue as the PR
137
+ // observations; a rewrite that changes nothing says nothing.
138
+ const from =
139
+ typeof previous?.status === 'string' ? previous.status : null;
140
+ if (from !== null && from !== ticket.status) {
141
+ this.#db.run(
142
+ `INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
143
+ VALUES (?, 'ticket_changed', ?, ?, NULL, ?)`,
144
+ [
145
+ nodeId,
146
+ `Ticket ${ticket.id} moved ${from} -> ${ticket.status} on the tracker.`,
147
+ JSON.stringify({ticket: ticket.id, from, to: ticket.status}),
148
+ new Date().toISOString(),
149
+ ]
150
+ );
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Fill a partial write from the row already stored. A ticket with no row
157
+ * needs `project` and `status` from the caller — nothing else can supply
158
+ * them — and takes the same defaults a fresh registration would.
159
+ */
160
+ #merge(patch: TicketPatch): Ticket {
161
+ const existing = this.#read(patch.id);
162
+ const need = <K extends keyof Ticket>(key: K): Ticket[K] => {
163
+ const given = patch[key];
164
+ if (given !== undefined) return given as Ticket[K];
165
+ ensure(
166
+ existing !== null,
167
+ () =>
168
+ new UsageError(`"${patch.id}" is new, so --${key} is required`, {
169
+ hint: 'a partial write fills gaps from the stored row; there is none yet for this id.',
170
+ })
171
+ );
172
+ return existing[key];
173
+ };
174
+ return {
175
+ id: patch.id,
176
+ project: need('project'),
177
+ status: need('status'),
178
+ url: patch.url ?? existing?.url ?? '',
179
+ title: patch.title ?? existing?.title ?? '',
180
+ targetKind: patch.targetKind ?? existing?.targetKind ?? 'pr',
181
+ requiresHuman: patch.requiresHuman ?? existing?.requiresHuman ?? false,
182
+ injected: patch.injected ?? existing?.injected ?? false,
183
+ priority:
184
+ patch.priority === undefined
185
+ ? (existing?.priority ?? null)
186
+ : patch.priority,
187
+ branchHint:
188
+ patch.branchHint === undefined
189
+ ? (existing?.branchHint ?? null)
190
+ : patch.branchHint,
191
+ labels: patch.labels ?? existing?.labels ?? [],
192
+ updatedAt:
193
+ patch.updatedAt === undefined
194
+ ? (existing?.updatedAt ?? null)
195
+ : patch.updatedAt,
196
+ };
197
+ }
198
+
199
+ /** Remove a ticket; its satellite, edges, claim, and outcome cascade. */
200
+ async removeTicket(id: string): Promise<boolean> {
201
+ return this.#db.transaction(() => {
202
+ const node = findNode(this.#db, id);
203
+ if (node?.kind !== 'ticket') return false;
204
+ this.#db.run('DELETE FROM node WHERE id = ?', [node.id]);
205
+ return true;
206
+ });
207
+ }
208
+
209
+ async getTicket(id: string): Promise<Ticket | null> {
210
+ return this.#read(id);
211
+ }
212
+
213
+ #read(id: string): Ticket | null {
214
+ const row = this.#db.get(
215
+ `SELECT n.external_id AS id, pn.external_id AS project, t.url, t.title,
216
+ t.status, t.target_kind, t.requires_human, t.injected, t.priority,
217
+ t.branch_hint, t.labels, t.updated_at
218
+ FROM ticket t
219
+ JOIN node n ON n.id = t.node_id
220
+ JOIN node pn ON pn.id = t.project_id
221
+ WHERE n.external_id = ?`,
222
+ [id]
223
+ );
224
+ if (row === undefined) return null;
225
+ /* eslint-disable @typescript-eslint/no-base-to-string --
226
+ * SQLite hands back `unknown`; `String()` converts a primitive rather than
227
+ * asserting a type the row has not been checked for. */
228
+ return {
229
+ id: String(row.id),
230
+ project: String(row.project),
231
+ url: String(row.url),
232
+ title: String(row.title),
233
+ status: row.status as Ticket['status'],
234
+ targetKind: row.target_kind as Ticket['targetKind'],
235
+ requiresHuman: row.requires_human === 1,
236
+ injected: row.injected === 1,
237
+ priority: row.priority === null ? null : Number(row.priority),
238
+ branchHint: row.branch_hint === null ? null : String(row.branch_hint),
239
+ labels: JSON.parse(String(row.labels)) as string[],
240
+ updatedAt: row.updated_at === null ? null : String(row.updated_at),
241
+ };
242
+ /* eslint-enable @typescript-eslint/no-base-to-string */
243
+ }
244
+ }
245
+
246
+ /* eslint-enable @typescript-eslint/require-await */