@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,45 @@
1
+ import type {PrSnapshot} from './snapshot.mts';
2
+
3
+ /**
4
+ * How long to wait before reading a PR again, derived from where the PR
5
+ * actually is rather than from a reason the worker declared.
6
+ *
7
+ * A worker cannot reliably say what it is waiting for — it may be waiting for
8
+ * several things at once, and whatever it declared goes stale the moment the
9
+ * PR moves. The PR's own state is the better signal and the server already
10
+ * has it, which is also what makes the interval data-driven rather than a
11
+ * constant table.
12
+ */
13
+ export const CADENCE_SECONDS = {
14
+ /** Checks are running: the next transition is close. */
15
+ ciActive: 60,
16
+ /** Checks have reported and someone must look: a person's timescale. */
17
+ awaitingReview: 300,
18
+ /** Nothing is pending; watch only for an out-of-band change. */
19
+ idle: 900,
20
+ } as const;
21
+
22
+ export function cadenceFor(snapshot: PrSnapshot | null): number {
23
+ if (snapshot === null) return CADENCE_SECONDS.ciActive;
24
+ if (snapshot.checks.some((check) => check.conclusion === null)) {
25
+ return CADENCE_SECONDS.ciActive;
26
+ }
27
+ // Out of draft with the forge still asking for review, or a reviewer who
28
+ // has not returned a verdict.
29
+ const awaiting =
30
+ !snapshot.draft &&
31
+ (snapshot.reviewDecision === 'REVIEW_REQUIRED' ||
32
+ snapshot.reviews.some((review) => review.state === 'PENDING'));
33
+ return awaiting ? CADENCE_SECONDS.awaitingReview : CADENCE_SECONDS.idle;
34
+ }
35
+
36
+ /**
37
+ * How long a watch runs before firing regardless of the diff. The snapshot
38
+ * sees only the forge, so an approval given on the ticket or a reaction never
39
+ * reaches it; expiry sends the worker to look for itself.
40
+ *
41
+ * Measured from arming, never from the last poll: a deadline a poll can push
42
+ * out is one a quiet PR never reaches, and a quiet PR is the only kind that
43
+ * needs it. The price is a wasted look at every unchanged PR once a window.
44
+ */
45
+ export const EXPIRY_SECONDS = 21_600;
@@ -0,0 +1,274 @@
1
+ import type {PrSnapshot} from './snapshot.mts';
2
+
3
+ /**
4
+ * Event kinds, four of them the channel-server event catalog's PR/CI triggers.
5
+ *
6
+ * `pr_conflicted` and `pr_head_changed` are additions: the catalog has no
7
+ * event for "the base moved and this no longer merges" or "someone else
8
+ * pushed to the branch", and a worker waiting on merge must react to both.
9
+ * Adding kinds is allowed; renaming the catalog's four would be breaking.
10
+ */
11
+ export const OBSERVATION_KINDS = [
12
+ 'ci_finished',
13
+ 'pr_review',
14
+ 'pr_comment',
15
+ 'pr_state_change',
16
+ 'pr_conflicted',
17
+ 'pr_head_changed',
18
+ // Not a PR observation: written by `ticket set` when a tracker write
19
+ // reveals a status transition. Same queue, same delivery.
20
+ 'ticket_changed',
21
+ // Also not a diff: written when a watch reaches its deadline. It reports
22
+ // that nothing the snapshot can see changed, which is the one thing a diff
23
+ // can never say — and the signal it stands in for (an approval on the
24
+ // ticket, a reaction, a go-ahead out of band) is outside the forge.
25
+ 'watch_expired',
26
+ ] as const;
27
+ export type ObservationKind = (typeof OBSERVATION_KINDS)[number];
28
+
29
+ export interface Observation {
30
+ readonly kind: ObservationKind;
31
+ /**
32
+ * Per-kind channel meta, beyond the `repo`/`pr` the pusher adds. Values are
33
+ * stringified at push time; the runner drops a non-string.
34
+ */
35
+ readonly meta: Readonly<Record<string, string>>;
36
+ /** One line for a human reading the log. The pushed body is `pr-status`. */
37
+ readonly summary: string;
38
+ }
39
+
40
+ /**
41
+ * Check conclusions that mean "this check is OK". Anything terminal and not
42
+ * in this set counts as a failure, so a conclusion GitHub adds later reads as
43
+ * failing rather than silently passing.
44
+ */
45
+ const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
46
+
47
+ function failing(snapshot: PrSnapshot): string[] {
48
+ return snapshot.checks
49
+ .filter(
50
+ (check) => check.conclusion !== null && !PASSING.has(check.conclusion)
51
+ )
52
+ .map((check) => check.name);
53
+ }
54
+
55
+ function settled(snapshot: PrSnapshot): boolean {
56
+ return (
57
+ snapshot.checks.length > 0 &&
58
+ snapshot.checks.every((check) => check.conclusion !== null)
59
+ );
60
+ }
61
+
62
+ /** The forge's word for "this no longer merges cleanly". */
63
+ const CONFLICTED = 'DIRTY';
64
+
65
+ /**
66
+ * What changed between two observations of one PR.
67
+ *
68
+ * Authorship is judged by this agent's machine marker, never by the account:
69
+ * under shared credentials the agent posts as the operator, so filtering by
70
+ * login would suppress the operator's own review — the one signal a waiting
71
+ * worker most needs. The marker says "this agent wrote it" whatever account
72
+ * carried it. Waking a worker to report its own comment is the noise that
73
+ * would make server-side waiting worse than the polling it replaces.
74
+ *
75
+ * A null `previous` is the first observation after arming, and yields
76
+ * nothing. The wait's own condition is evaluated separately by the caller,
77
+ * which is what catches a change that landed before the baseline was taken.
78
+ */
79
+ export function diffSnapshots(
80
+ previous: PrSnapshot | null,
81
+ next: PrSnapshot
82
+ ): Observation[] {
83
+ if (previous === null) return [];
84
+ const events: Observation[] = [];
85
+
86
+ if (next.merged && !previous.merged) {
87
+ return [
88
+ {
89
+ kind: 'pr_state_change',
90
+ meta: {state: 'merged'},
91
+ summary: 'The PR merged.',
92
+ },
93
+ ];
94
+ }
95
+
96
+ if (next.state === 'CLOSED' && previous.state !== 'CLOSED') {
97
+ // Whether a closed-unmerged PR actually shipped is `pr-status`'s call — a
98
+ // squash or rebase can land the content without setting `merged`. The
99
+ // event says the lifecycle moved; the body tells the worker which way.
100
+ return [
101
+ {
102
+ kind: 'pr_state_change',
103
+ meta: {state: 'closed'},
104
+ summary: 'The PR closed.',
105
+ },
106
+ ];
107
+ }
108
+
109
+ if (next.draft !== previous.draft) {
110
+ events.push({
111
+ kind: 'pr_state_change',
112
+ meta: {state: next.draft ? 'draft' : 'ready'},
113
+ summary: next.draft
114
+ ? 'The PR went back to draft.'
115
+ : 'The PR left draft and is ready for review.',
116
+ });
117
+ }
118
+
119
+ if (next.head !== previous.head && next.head !== null) {
120
+ events.push({
121
+ kind: 'pr_head_changed',
122
+ meta: {head: next.head},
123
+ summary: `The head commit moved to ${next.head.slice(0, 8)}.`,
124
+ });
125
+ }
126
+
127
+ // CI is reported per rollup, not per check: `ci_finished` fires when the
128
+ // rollup reaches a terminal state. Naming the failures in meta is the
129
+ // detail the rollup verdict alone cannot carry. Comparing the failing set
130
+ // (not just settled-ness) is what catches a rerun that goes straight from
131
+ // failing to green between two polls.
132
+ const nowFailing = failing(next);
133
+ const rollupChanged =
134
+ settled(next) &&
135
+ (!settled(previous) ||
136
+ failing(previous).join(',') !== nowFailing.join(',') ||
137
+ previous.head !== next.head);
138
+ if (rollupChanged) {
139
+ events.push({
140
+ kind: 'ci_finished',
141
+ meta: {
142
+ rollup: nowFailing.length > 0 ? 'failure' : 'success',
143
+ ...(nowFailing.length > 0 ? {failing: nowFailing.join(',')} : {}),
144
+ },
145
+ summary:
146
+ nowFailing.length > 0
147
+ ? `CI finished with failures: ${nowFailing.join(', ')}.`
148
+ : 'CI finished green.',
149
+ });
150
+ }
151
+
152
+ // A review is identified by (author, state, submittedAt): a reviewer walks
153
+ // pending -> verdict, and a re-request pushes them back to pending, so the
154
+ // author alone cannot tell a new verdict from an old one.
155
+ const seenReviews = new Set(
156
+ previous.reviews.map(
157
+ (review) => `${review.author} ${review.state} ${review.submittedAt ?? ''}`
158
+ )
159
+ );
160
+ for (const review of next.reviews) {
161
+ const key = `${review.author} ${review.state} ${review.submittedAt ?? ''}`;
162
+ if (seenReviews.has(key)) continue;
163
+ if (review.state === 'PENDING' || review.mine) continue;
164
+ events.push({
165
+ kind: 'pr_review',
166
+ meta: {state: reviewState(review.state), reviewer: review.author},
167
+ summary: `${review.author} left a ${reviewState(review.state)} review.`,
168
+ });
169
+ }
170
+
171
+ const before = new Map(previous.threads.map((thread) => [thread.id, thread]));
172
+ for (const thread of next.threads) {
173
+ const prior = before.get(thread.id);
174
+ const moved =
175
+ prior?.lastAt !== thread.lastAt || prior.resolved !== thread.resolved;
176
+ if (!moved || thread.resolved || thread.mine) continue;
177
+ events.push({
178
+ kind: 'pr_comment',
179
+ meta: {thread: thread.id},
180
+ summary:
181
+ prior === undefined
182
+ ? `${thread.lastAuthor ?? 'someone'} opened a review thread.`
183
+ : `${thread.lastAuthor ?? 'someone'} replied on a review thread.`,
184
+ });
185
+ }
186
+
187
+ // An id absent from the previous window is only new when that window held
188
+ // every comment. Past the cap the older entries fall out, and treating
189
+ // their reappearance as new would fire on comments from last week.
190
+ const windowComplete = previous.totals.comments <= previous.comments.length;
191
+ const known = new Set(previous.comments.map((comment) => comment.id));
192
+ for (const comment of next.comments) {
193
+ if (known.has(comment.id) || comment.mine) continue;
194
+ // Strictly older only. Timestamps are not a unique key, so a comment
195
+ // sharing the newest one's second would otherwise be dropped for good —
196
+ // the snapshot advances past it and no later tick can rediscover it.
197
+ if (!windowComplete && comment.createdAt < newestOf(previous.comments)) {
198
+ continue;
199
+ }
200
+ events.push({
201
+ kind: 'pr_comment',
202
+ meta: {thread: comment.id},
203
+ summary: `${comment.author} commented on the PR.`,
204
+ });
205
+ }
206
+
207
+ if (next.mergeState === CONFLICTED && previous.mergeState !== CONFLICTED) {
208
+ events.push({
209
+ kind: 'pr_conflicted',
210
+ meta: {mergeState: next.mergeState},
211
+ summary: 'The PR now conflicts with its base branch.',
212
+ });
213
+ }
214
+
215
+ return coalesce(events);
216
+ }
217
+
218
+ function newestOf(comments: PrSnapshot['comments']): string {
219
+ return comments.reduce(
220
+ (newest, comment) =>
221
+ comment.createdAt > newest ? comment.createdAt : newest,
222
+ ''
223
+ );
224
+ }
225
+
226
+ function reviewState(state: string): string {
227
+ if (state === 'APPROVED') return 'approved';
228
+ if (state === 'CHANGES_REQUESTED') return 'changes';
229
+ return 'comment';
230
+ }
231
+
232
+ /**
233
+ * Everything one tick saw about one PR, as a single event.
234
+ *
235
+ * The alternative — one event per change — interrupts a worker mid-reaction:
236
+ * it is told CI failed, starts fixing, and is then told a reviewer replied,
237
+ * which it must handle as a second turn without the first one's context. The
238
+ * worker already reads one `pr-status` blob per tick and reacts to everything
239
+ * in it at once; the channel should not be worse than that.
240
+ *
241
+ * So the kind is a routing hint — the most significant thing that moved —
242
+ * `changed` lists every kind that fired, and the per-kind specifics ride
243
+ * along in meta. The body carries the state the worker acts on.
244
+ */
245
+ const PRIORITY: readonly ObservationKind[] = [
246
+ 'pr_state_change',
247
+ 'pr_conflicted',
248
+ 'ci_finished',
249
+ 'pr_review',
250
+ 'pr_head_changed',
251
+ 'pr_comment',
252
+ ];
253
+
254
+ function coalesce(events: readonly Observation[]): Observation[] {
255
+ if (events.length <= 1) return [...events];
256
+ const ranked = [...events].sort(
257
+ (a, b) => PRIORITY.indexOf(a.kind) - PRIORITY.indexOf(b.kind)
258
+ );
259
+ const [lead] = ranked;
260
+ if (lead === undefined) return [];
261
+ const meta: Record<string, string> = {};
262
+ // Least significant first, so the lead event's own keys win a collision.
263
+ for (const event of [...ranked].reverse()) {
264
+ for (const [key, value] of Object.entries(event.meta)) meta[key] = value;
265
+ }
266
+ meta.changed = [...new Set(ranked.map((event) => event.kind))].join(',');
267
+ return [
268
+ {
269
+ kind: lead.kind,
270
+ meta,
271
+ summary: ranked.map((event) => event.summary).join(' '),
272
+ },
273
+ ];
274
+ }
@@ -0,0 +1,11 @@
1
+ export {armWatch} from './arm.mts';
2
+ export {diffSnapshots, OBSERVATION_KINDS} from './diff.mts';
3
+ export type {Observation, ObservationKind} from './diff.mts';
4
+ export {pollWatches} from './poll.mts';
5
+ export {githubSnapshot, SNAPSHOT_QUERY} from './snapshot.mts';
6
+ export type {PrSnapshot, Snapshotter} from './snapshot.mts';
7
+ export {AGENT_ID, writtenByThisAgent} from './marker.mts';
8
+ export {prStatusPayload, prStatusScript} from './payload.mts';
9
+ export {cadenceFor, CADENCE_SECONDS, EXPIRY_SECONDS} from './cadence.mts';
10
+ export {renderSnapshot} from './render.mts';
11
+ export {adoptOrphans, githubLister} from './adopt.mts';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The agent id this plugin writes into its machine markers. The wire format
3
+ * is `<!-- agent-reply:<agent-id> -->` on a post's first line.
4
+ */
5
+ export const AGENT_ID = 'dispatch';
6
+
7
+ const MARKER = new RegExp(`^\\s*<!--\\s*agent-reply:${AGENT_ID}\\s*-->`, 'iu');
8
+
9
+ /**
10
+ * Whether this agent wrote a post, judged by its own marker rather than by
11
+ * the authoring account.
12
+ *
13
+ * The account is the wrong test. Under shared credentials the agent posts as
14
+ * the operator, so filtering by login would suppress the operator's own
15
+ * review — the one signal a waiting worker most needs. The marker is written
16
+ * by this agent and by nothing else, so it identifies authorship regardless
17
+ * of which account carried it.
18
+ *
19
+ * Matching the agent id specifically, not a bare `agent-reply`, keeps another
20
+ * tool's marked post actionable.
21
+ */
22
+ export function writtenByThisAgent(body: string | null | undefined): boolean {
23
+ return typeof body === 'string' && MARKER.test(body);
24
+ }
@@ -0,0 +1,56 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+
4
+ import type {Logger} from '../logger/index.mts';
5
+
6
+ const run = promisify(execFile);
7
+
8
+ /**
9
+ * How long one payload read may take before the tick moves on without it.
10
+ *
11
+ * This bounds a heartbeat, not just a read. Payload reads happen inside the
12
+ * tick, and a session whose heartbeat stops for the staleness window is swept
13
+ * — its claims cascade and its work is re-dispatched underneath it. The
14
+ * budget below keeps the worst case an order of magnitude short of that.
15
+ */
16
+ const TIMEOUT_MS = 20_000;
17
+
18
+ /**
19
+ * The `pr-status` payload for a PR, which is what a PR/CI event body must
20
+ * carry: the worker then reacts to everything the tick saw in one turn, from
21
+ * the same document it would have read itself.
22
+ *
23
+ * `--repo` is what makes this reachable from the server, which stands in one
24
+ * directory and watches PRs across repos.
25
+ *
26
+ * A failed read returns null rather than failing the tick. The event still
27
+ * goes out with its summary, and the worker reads `pr-status` itself — a
28
+ * degraded wake-up beats a silent one.
29
+ */
30
+ export async function prStatusPayload(
31
+ repo: string,
32
+ prNumber: number,
33
+ opts: {script: string; log?: Logger | undefined}
34
+ ): Promise<string | null> {
35
+ try {
36
+ const {stdout} = await run(
37
+ opts.script,
38
+ ['--repo', repo, String(prNumber)],
39
+ {timeout: TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024}
40
+ );
41
+ const payload = stdout.trim();
42
+ return payload === '' ? null : payload;
43
+ } catch (error) {
44
+ opts.log?.warn('could not read the pr-status payload', {
45
+ repo,
46
+ pr: prNumber,
47
+ error: error instanceof Error ? error.message : String(error),
48
+ });
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** The bundled `pr-status`, resolved against this file rather than the cwd. */
54
+ export function prStatusScript(): string {
55
+ return new URL('../../../bin/pr-status', import.meta.url).pathname;
56
+ }
@@ -0,0 +1,87 @@
1
+ import {nowIso} from '../db/time.mts';
2
+ import {withDatabase} from '../db/index.mts';
3
+ import type {Logger} from '../logger/index.mts';
4
+ import {WatchStore} from '../stores/index.mts';
5
+ import {cadenceFor, EXPIRY_SECONDS} from './cadence.mts';
6
+ import {diffSnapshots} from './diff.mts';
7
+ import type {Snapshotter} from './snapshot.mts';
8
+
9
+ /** Snapshot calls one pass will make; keeps a tick short. */
10
+ const MAX_POLLS_PER_PASS = 10;
11
+
12
+ /**
13
+ * One polling pass over the due watches: fire the expired ones outright,
14
+ * snapshot the rest, and record what changed as events.
15
+ *
16
+ * Short of its deadline, a watch fires only when the diff produced something
17
+ * — an unchanged PR, or one that changed in a way only the agent itself
18
+ * caused, leaves the row watching. That is the whole point of diffing
19
+ * structurally rather than hashing: a worker is woken for a reason it can be
20
+ * told. Expiry is the one wake with no reason to give, and says so.
21
+ *
22
+ * A parked item never reads as expired (`due`), so for it the diff is the
23
+ * only thing that fires: it is waiting on a person, and "your six hours are
24
+ * up" is not an answer.
25
+ *
26
+ * A failed snapshot costs only that row's interval: `touch` pushes the retry
27
+ * out so a broken PR is not hammered every tick, and the error goes to the
28
+ * log rather than failing the pass.
29
+ */
30
+ export async function pollWatches(
31
+ env: NodeJS.ProcessEnv,
32
+ opts: {
33
+ snapshot: Snapshotter;
34
+ dbPath?: string | undefined;
35
+ now?: () => string;
36
+ log?: Logger | undefined;
37
+ }
38
+ ): Promise<{fired: string[]}> {
39
+ const now = opts.now ?? nowIso;
40
+ return withDatabase(opts.dbPath, env, async (db) => {
41
+ const watches = new WatchStore(db);
42
+ // Every unconcluded PR item is watched — including one parked on an
43
+ // operator — whether or not a worker ever asked. A PR moves whether
44
+ // anyone is waiting on it, and an item nobody armed is exactly the one
45
+ // whose change would otherwise be missed.
46
+ await watches.ensureForLiveItems(now(), EXPIRY_SECONDS);
47
+ const fired: string[] = [];
48
+
49
+ for (const due of await watches.due(now(), MAX_POLLS_PER_PASS)) {
50
+ if (due.expired) {
51
+ if ((await watches.fire(due.node, now(), due.createdAt)) === 'fired') {
52
+ fired.push(due.node);
53
+ }
54
+ continue;
55
+ }
56
+ try {
57
+ const taken = await opts.snapshot(due.repo, due.prNumber);
58
+ const observed = diffSnapshots(due.snapshot, taken);
59
+ // Changed or not changed is the only question. There is no predicate
60
+ // for "has the thing you were waiting for happened", because a worker
61
+ // cannot say what it is waiting for and any such predicate tests a
62
+ // persistent state — which fires again the moment the worker returns.
63
+ //
64
+ // One transaction: recording events and firing the watch must not be
65
+ // separable, or a crash between them re-emits the same events on the
66
+ // next tick against the same unchanged snapshot.
67
+ const outcome = await watches.observe({
68
+ node: due.node,
69
+ snapshot: taken,
70
+ at: now(),
71
+ createdAt: due.createdAt,
72
+ fire: observed.length > 0,
73
+ intervalSeconds: cadenceFor(taken),
74
+ events: observed,
75
+ });
76
+ if (outcome === 'fired') fired.push(due.node);
77
+ } catch (error) {
78
+ await watches.touch(due.node, now(), due.createdAt);
79
+ opts.log?.error('watch poll failed', {
80
+ node: due.node,
81
+ error: error instanceof Error ? error.message : String(error),
82
+ });
83
+ }
84
+ }
85
+ return {fired};
86
+ });
87
+ }
@@ -0,0 +1,61 @@
1
+ import type {PrSnapshot} from './snapshot.mts';
2
+
3
+ /**
4
+ * Render an event body from the snapshot the server already holds.
5
+ *
6
+ * This replaced shelling out to `pr-status` per event. That script makes
7
+ * several `gh` calls, writes cache files, and invokes `claude` to summarize —
8
+ * a deep read a worker runs for itself when it needs actionability and cached
9
+ * bodies. The event body's job is smaller: say where the PR stands, in the
10
+ * same XML vocabulary the worker already reads, so one wakeup carries
11
+ * everything one tick saw without costing a subprocess per event.
12
+ */
13
+ export function renderSnapshot(
14
+ repo: string,
15
+ prNumber: number,
16
+ snapshot: PrSnapshot
17
+ ): string {
18
+ const lines: string[] = [];
19
+ const esc = (value: string): string =>
20
+ value
21
+ .replaceAll('&', '&amp;')
22
+ .replaceAll('<', '&lt;')
23
+ .replaceAll('"', '&quot;');
24
+
25
+ lines.push(
26
+ `<pr-event repo="${esc(repo)}" pr="${String(prNumber)}" head="${esc(snapshot.head ?? '')}" state="${esc(snapshot.state ?? '')}" draft="${String(snapshot.draft)}" merged="${String(snapshot.merged)}">`
27
+ );
28
+ const rollup = snapshot.rollup ?? 'NONE';
29
+ lines.push(` <checks state="${esc(rollup)}">`);
30
+ for (const check of snapshot.checks) {
31
+ lines.push(
32
+ ` <check name="${esc(check.name)}" conclusion="${esc(check.conclusion ?? 'PENDING')}"${check.url === null ? '' : ` url="${esc(check.url)}"`}/>`
33
+ );
34
+ }
35
+ lines.push(' </checks>');
36
+ lines.push(
37
+ ` <merge-conflicts present="${String(snapshot.mergeState === 'DIRTY')}"/>`
38
+ );
39
+ lines.push(
40
+ ` <review-decision>${esc(snapshot.reviewDecision ?? 'NONE')}</review-decision>`
41
+ );
42
+ lines.push(' <reviews>');
43
+ for (const review of snapshot.reviews) {
44
+ lines.push(
45
+ ` <review author="${esc(review.author)}" state="${esc(review.state)}" mine="${String(review.mine)}"/>`
46
+ );
47
+ }
48
+ lines.push(' </reviews>');
49
+ lines.push(' <threads>');
50
+ for (const thread of snapshot.threads) {
51
+ lines.push(
52
+ ` <thread id="${esc(thread.id)}" resolved="${String(thread.resolved)}" lastAuthor="${esc(thread.lastAuthor ?? '')}" mine="${String(thread.mine)}"/>`
53
+ );
54
+ }
55
+ lines.push(' </threads>');
56
+ lines.push(
57
+ ` <note>Snapshot rendered by the server. For actionability classification and cached bodies, run pr-status --repo ${esc(repo)} ${String(prNumber)} yourself.</note>`
58
+ );
59
+ lines.push('</pr-event>');
60
+ return lines.join('\n');
61
+ }