spidersan 0.6.0 → 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 (214) hide show
  1. package/CHANGELOG.md +42 -52
  2. package/README.md +238 -3
  3. package/dist/bin/spidersan.d.ts.map +1 -1
  4. package/dist/bin/spidersan.js +13 -1
  5. package/dist/bin/spidersan.js.map +1 -1
  6. package/dist/commands/abandon.d.ts.map +1 -1
  7. package/dist/commands/abandon.js +1 -9
  8. package/dist/commands/abandon.js.map +1 -1
  9. package/dist/commands/ai.d.ts +15 -0
  10. package/dist/commands/ai.d.ts.map +1 -0
  11. package/dist/commands/ai.js +498 -0
  12. package/dist/commands/ai.js.map +1 -0
  13. package/dist/commands/auto.d.ts.map +1 -1
  14. package/dist/commands/auto.js +2 -1
  15. package/dist/commands/auto.js.map +1 -1
  16. package/dist/commands/bot.d.ts +16 -0
  17. package/dist/commands/bot.d.ts.map +1 -0
  18. package/dist/commands/bot.js +398 -0
  19. package/dist/commands/bot.js.map +1 -0
  20. package/dist/commands/conflicts.d.ts.map +1 -1
  21. package/dist/commands/conflicts.js +260 -277
  22. package/dist/commands/conflicts.js.map +1 -1
  23. package/dist/commands/context.d.ts +8 -0
  24. package/dist/commands/context.d.ts.map +1 -0
  25. package/dist/commands/context.js +104 -0
  26. package/dist/commands/context.js.map +1 -0
  27. package/dist/commands/cross-conflicts.d.ts.map +1 -1
  28. package/dist/commands/cross-conflicts.js +1 -41
  29. package/dist/commands/cross-conflicts.js.map +1 -1
  30. package/dist/commands/depends.d.ts.map +1 -1
  31. package/dist/commands/depends.js +1 -9
  32. package/dist/commands/depends.js.map +1 -1
  33. package/dist/commands/fleet-status.d.ts +14 -0
  34. package/dist/commands/fleet-status.d.ts.map +1 -0
  35. package/dist/commands/fleet-status.js +127 -0
  36. package/dist/commands/fleet-status.js.map +1 -0
  37. package/dist/commands/git-watch.d.ts +24 -0
  38. package/dist/commands/git-watch.d.ts.map +1 -0
  39. package/dist/commands/git-watch.js +84 -0
  40. package/dist/commands/git-watch.js.map +1 -0
  41. package/dist/commands/index.d.ts +5 -0
  42. package/dist/commands/index.d.ts.map +1 -1
  43. package/dist/commands/index.js +7 -0
  44. package/dist/commands/index.js.map +1 -1
  45. package/dist/commands/merge-order.d.ts.map +1 -1
  46. package/dist/commands/merge-order.js +18 -67
  47. package/dist/commands/merge-order.js.map +1 -1
  48. package/dist/commands/merged.d.ts.map +1 -1
  49. package/dist/commands/merged.js +1 -9
  50. package/dist/commands/merged.js.map +1 -1
  51. package/dist/commands/pulse.d.ts.map +1 -1
  52. package/dist/commands/pulse.js +134 -63
  53. package/dist/commands/pulse.js.map +1 -1
  54. package/dist/commands/queen.d.ts.map +1 -1
  55. package/dist/commands/queen.js +11 -7
  56. package/dist/commands/queen.js.map +1 -1
  57. package/dist/commands/ready-check.d.ts +2 -1
  58. package/dist/commands/ready-check.d.ts.map +1 -1
  59. package/dist/commands/ready-check.js +6 -30
  60. package/dist/commands/ready-check.js.map +1 -1
  61. package/dist/commands/register.d.ts.map +1 -1
  62. package/dist/commands/register.js +7 -29
  63. package/dist/commands/register.js.map +1 -1
  64. package/dist/commands/torrent.d.ts.map +1 -1
  65. package/dist/commands/torrent.js +29 -18
  66. package/dist/commands/torrent.js.map +1 -1
  67. package/dist/commands/watch.d.ts.map +1 -1
  68. package/dist/commands/watch.js +13 -34
  69. package/dist/commands/watch.js.map +1 -1
  70. package/dist/lib/ai/context-builder.d.ts +16 -0
  71. package/dist/lib/ai/context-builder.d.ts.map +1 -0
  72. package/dist/lib/ai/context-builder.js +216 -0
  73. package/dist/lib/ai/context-builder.js.map +1 -0
  74. package/dist/lib/ai/event-handler.d.ts +21 -0
  75. package/dist/lib/ai/event-handler.d.ts.map +1 -0
  76. package/dist/lib/ai/event-handler.js +98 -0
  77. package/dist/lib/ai/event-handler.js.map +1 -0
  78. package/dist/lib/ai/index.d.ts +13 -0
  79. package/dist/lib/ai/index.d.ts.map +1 -0
  80. package/dist/lib/ai/index.js +11 -0
  81. package/dist/lib/ai/index.js.map +1 -0
  82. package/dist/lib/ai/llm-client.d.ts +37 -0
  83. package/dist/lib/ai/llm-client.d.ts.map +1 -0
  84. package/dist/lib/ai/llm-client.js +225 -0
  85. package/dist/lib/ai/llm-client.js.map +1 -0
  86. package/dist/lib/ai/reasoner.d.ts +11 -0
  87. package/dist/lib/ai/reasoner.d.ts.map +1 -0
  88. package/dist/lib/ai/reasoner.js +246 -0
  89. package/dist/lib/ai/reasoner.js.map +1 -0
  90. package/dist/lib/ai/setup.d.ts +40 -0
  91. package/dist/lib/ai/setup.d.ts.map +1 -0
  92. package/dist/lib/ai/setup.js +154 -0
  93. package/dist/lib/ai/setup.js.map +1 -0
  94. package/dist/lib/ai/types.d.ts +135 -0
  95. package/dist/lib/ai/types.d.ts.map +1 -0
  96. package/dist/lib/ai/types.js +39 -0
  97. package/dist/lib/ai/types.js.map +1 -0
  98. package/dist/lib/colony-subscriber.d.ts +15 -12
  99. package/dist/lib/colony-subscriber.d.ts.map +1 -1
  100. package/dist/lib/colony-subscriber.js +146 -65
  101. package/dist/lib/colony-subscriber.js.map +1 -1
  102. package/dist/lib/config.d.ts.map +1 -1
  103. package/dist/lib/config.js +18 -4
  104. package/dist/lib/config.js.map +1 -1
  105. package/dist/lib/conflict-analyzer.d.ts +33 -0
  106. package/dist/lib/conflict-analyzer.d.ts.map +1 -0
  107. package/dist/lib/conflict-analyzer.js +114 -0
  108. package/dist/lib/conflict-analyzer.js.map +1 -0
  109. package/dist/lib/conflict-renderer.d.ts +7 -0
  110. package/dist/lib/conflict-renderer.d.ts.map +1 -0
  111. package/dist/lib/conflict-renderer.js +162 -0
  112. package/dist/lib/conflict-renderer.js.map +1 -0
  113. package/dist/lib/conflict-tier.d.ts +20 -0
  114. package/dist/lib/conflict-tier.d.ts.map +1 -0
  115. package/dist/lib/conflict-tier.js +49 -0
  116. package/dist/lib/conflict-tier.js.map +1 -0
  117. package/dist/lib/crypto.js +1 -1
  118. package/dist/lib/crypto.js.map +1 -1
  119. package/dist/lib/git-events-subscriber.d.ts +59 -0
  120. package/dist/lib/git-events-subscriber.d.ts.map +1 -0
  121. package/dist/lib/git-events-subscriber.js +779 -0
  122. package/dist/lib/git-events-subscriber.js.map +1 -0
  123. package/dist/lib/git.d.ts +15 -0
  124. package/dist/lib/git.d.ts.map +1 -0
  125. package/dist/lib/git.js +180 -0
  126. package/dist/lib/git.js.map +1 -0
  127. package/dist/lib/github.d.ts.map +1 -1
  128. package/dist/lib/github.js +14 -9
  129. package/dist/lib/github.js.map +1 -1
  130. package/dist/lib/graph.d.ts +23 -0
  131. package/dist/lib/graph.d.ts.map +1 -0
  132. package/dist/lib/graph.js +134 -0
  133. package/dist/lib/graph.js.map +1 -0
  134. package/dist/lib/hub.d.ts +31 -0
  135. package/dist/lib/hub.d.ts.map +1 -0
  136. package/dist/lib/hub.js +92 -0
  137. package/dist/lib/hub.js.map +1 -0
  138. package/dist/lib/remote-drift.d.ts +60 -0
  139. package/dist/lib/remote-drift.d.ts.map +1 -0
  140. package/dist/lib/remote-drift.js +225 -0
  141. package/dist/lib/remote-drift.js.map +1 -0
  142. package/dist/lib/salvage-analyzer.d.ts.map +1 -1
  143. package/dist/lib/salvage-analyzer.js +2 -3
  144. package/dist/lib/salvage-analyzer.js.map +1 -1
  145. package/dist/lib/security.d.ts +11 -0
  146. package/dist/lib/security.d.ts.map +1 -1
  147. package/dist/lib/security.js +24 -1
  148. package/dist/lib/security.js.map +1 -1
  149. package/dist/lib/session-logger.d.ts +54 -0
  150. package/dist/lib/session-logger.d.ts.map +1 -0
  151. package/dist/lib/session-logger.js +136 -0
  152. package/dist/lib/session-logger.js.map +1 -0
  153. package/dist/storage/adapter.d.ts +4 -0
  154. package/dist/storage/adapter.d.ts.map +1 -1
  155. package/dist/storage/branch-registry-store.d.ts +13 -0
  156. package/dist/storage/branch-registry-store.d.ts.map +1 -0
  157. package/dist/storage/branch-registry-store.js +2 -0
  158. package/dist/storage/branch-registry-store.js.map +1 -0
  159. package/dist/storage/factory.d.ts +4 -0
  160. package/dist/storage/factory.d.ts.map +1 -1
  161. package/dist/storage/factory.js +25 -9
  162. package/dist/storage/factory.js.map +1 -1
  163. package/dist/storage/git-messages.d.ts +53 -0
  164. package/dist/storage/git-messages.d.ts.map +1 -0
  165. package/dist/storage/git-messages.js +376 -0
  166. package/dist/storage/git-messages.js.map +1 -0
  167. package/dist/storage/index.d.ts +5 -0
  168. package/dist/storage/index.d.ts.map +1 -1
  169. package/dist/storage/index.js +5 -0
  170. package/dist/storage/index.js.map +1 -1
  171. package/dist/storage/json-branch-registry-store.d.ts +19 -0
  172. package/dist/storage/json-branch-registry-store.d.ts.map +1 -0
  173. package/dist/storage/json-branch-registry-store.js +112 -0
  174. package/dist/storage/json-branch-registry-store.js.map +1 -0
  175. package/dist/storage/local-messages.d.ts +37 -0
  176. package/dist/storage/local-messages.d.ts.map +1 -0
  177. package/dist/storage/local-messages.js +151 -0
  178. package/dist/storage/local-messages.js.map +1 -0
  179. package/dist/storage/local.d.ts +2 -6
  180. package/dist/storage/local.d.ts.map +1 -1
  181. package/dist/storage/local.js +13 -76
  182. package/dist/storage/local.js.map +1 -1
  183. package/dist/storage/memory-branch-registry-store.d.ts +16 -0
  184. package/dist/storage/memory-branch-registry-store.d.ts.map +1 -0
  185. package/dist/storage/memory-branch-registry-store.js +65 -0
  186. package/dist/storage/memory-branch-registry-store.js.map +1 -0
  187. package/dist/storage/message-adapter.d.ts +86 -0
  188. package/dist/storage/message-adapter.d.ts.map +1 -0
  189. package/dist/storage/message-adapter.js +28 -0
  190. package/dist/storage/message-adapter.js.map +1 -0
  191. package/dist/storage/message-factory.d.ts +36 -0
  192. package/dist/storage/message-factory.d.ts.map +1 -0
  193. package/dist/storage/message-factory.js +99 -0
  194. package/dist/storage/message-factory.js.map +1 -0
  195. package/dist/storage/mycmail-adapter.d.ts +38 -0
  196. package/dist/storage/mycmail-adapter.d.ts.map +1 -0
  197. package/dist/storage/mycmail-adapter.js +300 -0
  198. package/dist/storage/mycmail-adapter.js.map +1 -0
  199. package/dist/storage/supabase-registry-sync-client-impl.d.ts +46 -0
  200. package/dist/storage/supabase-registry-sync-client-impl.d.ts.map +1 -0
  201. package/dist/storage/supabase-registry-sync-client-impl.js +322 -0
  202. package/dist/storage/supabase-registry-sync-client-impl.js.map +1 -0
  203. package/dist/storage/supabase-registry-sync-client.d.ts +9 -0
  204. package/dist/storage/supabase-registry-sync-client.d.ts.map +1 -0
  205. package/dist/storage/supabase-registry-sync-client.js +2 -0
  206. package/dist/storage/supabase-registry-sync-client.js.map +1 -0
  207. package/dist/storage/supabase.d.ts +8 -46
  208. package/dist/storage/supabase.d.ts.map +1 -1
  209. package/dist/storage/supabase.js +30 -342
  210. package/dist/storage/supabase.js.map +1 -1
  211. package/dist/tui/screen.d.ts.map +1 -1
  212. package/dist/tui/screen.js +5 -3
  213. package/dist/tui/screen.js.map +1 -1
  214. package/package.json +92 -90
@@ -0,0 +1,779 @@
1
+ /**
2
+ * git-events-subscriber
3
+ *
4
+ * Subscribes to spidersan_git_events via Supabase Realtime for low-latency
5
+ * git-change notifications, with a catch-up poll on startup/reconnect so no
6
+ * events are missed while a machine was offline.
7
+ *
8
+ * Delivery model:
9
+ * Realtime INSERT — instant fan-out (< 1s when connected)
10
+ * Catch-up query — WHERE seq > last_seen_seq on startup/reconnect
11
+ *
12
+ * Actions per event type (P1 — notify, not auto-execute):
13
+ * push → warn agent, log to activity + pending files
14
+ * pull_request → log only
15
+ * create → log only
16
+ * delete → mark registry branch abandoned, archive entry
17
+ *
18
+ * AI layer (P2): pending events will be routed through spidersan-smol/gemma4
19
+ * for ecosystem-aware severity decisions. The pending log is the handoff point.
20
+ */
21
+ import { execFileSync, execFile } from 'child_process';
22
+ import { promisify } from 'util';
23
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, statSync, renameSync } from 'fs';
24
+ import { join, resolve } from 'path';
25
+ import { homedir } from 'os';
26
+ import { getStorage } from '../storage/index.js';
27
+ import { handleEvent } from './ai/event-handler.js';
28
+ import { logActivity } from './activity.js';
29
+ const execFileAsync = promisify(execFile);
30
+ // ─── Paths ────────────────────────────────────────────────────────────────────
31
+ const SPIDERSAN_DIR = join(homedir(), '.spidersan');
32
+ const CURSOR_FILE = join(SPIDERSAN_DIR, 'git-watch-cursor.json');
33
+ const ACTIVITY_LOG = join(SPIDERSAN_DIR, 'activity.jsonl');
34
+ const PENDING_LOG = join(SPIDERSAN_DIR, 'git-events-pending.jsonl');
35
+ const PENDING_CONSUMED_LOG = join(SPIDERSAN_DIR, 'git-events-consumed.jsonl');
36
+ const PENDING_TEMP = join(SPIDERSAN_DIR, 'git-events-pending.jsonl.tmp');
37
+ const ARCHIVE_LOG = join(SPIDERSAN_DIR, 'archive.jsonl');
38
+ // ─── Cursor persistence ───────────────────────────────────────────────────────
39
+ function readCursor() {
40
+ try {
41
+ if (!existsSync(CURSOR_FILE))
42
+ return 0;
43
+ const data = JSON.parse(readFileSync(CURSOR_FILE, 'utf8'));
44
+ return typeof data.seq === 'number' ? data.seq : 0;
45
+ }
46
+ catch {
47
+ return 0;
48
+ }
49
+ }
50
+ function writeCursor(seq) {
51
+ try {
52
+ writeFileSync(CURSOR_FILE, JSON.stringify({ seq, updated_at: new Date().toISOString() }));
53
+ }
54
+ catch {
55
+ // non-fatal
56
+ }
57
+ }
58
+ // ─── Activity / pending logging ───────────────────────────────────────────────
59
+ function appendLog(file, entry) {
60
+ try {
61
+ appendFileSync(file, JSON.stringify(entry) + '\n');
62
+ }
63
+ catch {
64
+ // non-fatal
65
+ }
66
+ }
67
+ // ─── Remote URL → local path cache ───────────────────────────────────────────
68
+ /**
69
+ * Build a map from "owner/repo" to local clone path(s).
70
+ * Scans provided repoPaths first; if empty, scans all registered repos
71
+ * by reading their .spidersan/registry.json locations from ~/.spidersan.
72
+ */
73
+ function normalizeRemoteUrl(url) {
74
+ // Normalize ssh and https to "owner/repo"
75
+ // git@github.com:treebird7/Envoak.git → treebird7/Envoak
76
+ // https://github.com/treebird7/Envoak.git → treebird7/Envoak
77
+ return url
78
+ .replace(/^git@github\.com:/, '')
79
+ .replace(/^https?:\/\/github\.com\//, '')
80
+ .replace(/\.git$/, '');
81
+ }
82
+ function getRemoteForPath(repoPath) {
83
+ try {
84
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
85
+ cwd: repoPath,
86
+ encoding: 'utf-8',
87
+ timeout: 5000,
88
+ }).trim();
89
+ return normalizeRemoteUrl(url);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ function buildRepoMap(explicitPaths) {
96
+ const map = new Map(); // owner/repo → [localPath, ...]
97
+ const pathsToScan = explicitPaths.length > 0
98
+ ? explicitPaths
99
+ : discoverRegisteredRepoPaths();
100
+ for (const p of pathsToScan) {
101
+ const absPath = resolve(p);
102
+ const remote = getRemoteForPath(absPath);
103
+ if (!remote)
104
+ continue;
105
+ const existing = map.get(remote) ?? [];
106
+ existing.push(absPath);
107
+ map.set(remote, existing);
108
+ }
109
+ return map;
110
+ }
111
+ /**
112
+ * Discover repo paths from all .spidersan registries visible from ~/.spidersan.
113
+ * Falls back to scanning ~/Dev/* for .spidersan directories.
114
+ */
115
+ function discoverRegisteredRepoPaths() {
116
+ const devDir = join(homedir(), 'Dev');
117
+ const paths = [];
118
+ try {
119
+ const entries = readdirSync(devDir);
120
+ for (const entry of entries) {
121
+ const full = join(devDir, entry);
122
+ try {
123
+ if (statSync(full).isDirectory() && existsSync(join(full, '.spidersan', 'registry.json'))) {
124
+ paths.push(full);
125
+ }
126
+ }
127
+ catch {
128
+ // skip
129
+ }
130
+ }
131
+ }
132
+ catch {
133
+ // devDir doesn't exist or can't be read
134
+ }
135
+ return paths;
136
+ }
137
+ // ─── Per-repo worker queue ────────────────────────────────────────────────────
138
+ const queues = new Map(); // repoPath → last task
139
+ function enqueue(repoPath, task) {
140
+ const last = queues.get(repoPath) ?? Promise.resolve();
141
+ const next = last.then(task).catch(() => undefined);
142
+ queues.set(repoPath, next);
143
+ }
144
+ // ─── Tree-pair detection ──────────────────────────────────────────────────────
145
+ const TREE_PAIR_RE = /^(sql-tree|ts-tree)\/pairs\/[^/]+\.json$/;
146
+ const TREE_ACTION_HINTS = {
147
+ 'sql-tree': 'mycsan /sql-review validate',
148
+ 'ts-tree': 'ts-review validate',
149
+ };
150
+ /**
151
+ * When a push lands on treebird7/treebird, compare before..after via GitHub API
152
+ * and detect newly added gold-pair JSON files. Emits a targeted pending entry
153
+ * per tree so the validation cue is specific and actionable.
154
+ */
155
+ async function detectTreePairs(event, log) {
156
+ if (!event.before_sha || !event.after_sha)
157
+ return;
158
+ const url = `https://api.github.com/repos/${event.repo}/compare/${event.before_sha}...${event.after_sha}`;
159
+ const githubToken = process.env.GITHUB_TOKEN;
160
+ const headers = {
161
+ 'Accept': 'application/vnd.github.v3+json',
162
+ 'User-Agent': 'spidersan-git-watch',
163
+ };
164
+ if (githubToken)
165
+ headers['Authorization'] = `Bearer ${githubToken}`;
166
+ try {
167
+ const res = await fetch(url, { headers });
168
+ if (!res.ok)
169
+ return;
170
+ const data = await res.json();
171
+ const newPairs = (data.files ?? []).filter(f => f.status === 'added' && TREE_PAIR_RE.test(f.filename));
172
+ if (!newPairs.length)
173
+ return;
174
+ // Group by tree (sql-tree / ts-tree)
175
+ const grouped = {};
176
+ for (const f of newPairs) {
177
+ const tree = f.filename.split('/')[0];
178
+ (grouped[tree] ??= []).push(f.filename);
179
+ }
180
+ for (const [tree, files] of Object.entries(grouped)) {
181
+ const hint = TREE_ACTION_HINTS[tree] ?? `review ${tree}/pairs/`;
182
+ log(`🌱 ${files.length} new pair(s) in ${tree} — validate: \`${hint}\``);
183
+ appendLog(PENDING_LOG, {
184
+ type: 'tree_pairs_ready',
185
+ tree,
186
+ files,
187
+ count: files.length,
188
+ repo: event.repo,
189
+ branch: event.branch,
190
+ after_sha: event.after_sha?.slice(0, 7),
191
+ received_at: event.received_at,
192
+ ts: new Date().toISOString(),
193
+ action_hint: hint,
194
+ });
195
+ }
196
+ }
197
+ catch {
198
+ // non-fatal — GitHub API might be rate-limited or unreachable
199
+ }
200
+ }
201
+ // ─── Pending tree-pairs consumer ──────────────────────────────────────────────
202
+ /** Deterministic ID for a tree_pairs_ready entry — used for idempotent consumption. */
203
+ function makeEntryId(entry) {
204
+ const tree = typeof entry.tree === 'string' ? entry.tree : 'unknown';
205
+ const sha = typeof entry.after_sha === 'string' ? entry.after_sha : 'unknown';
206
+ const repo = typeof entry.repo === 'string' ? entry.repo : 'unknown';
207
+ const files = Array.isArray(entry.files) ? entry.files.join('|') : '';
208
+ return `${repo}:${sha}:${tree}:${files}`;
209
+ }
210
+ /** Read already-consumed entry IDs from the consumed log. */
211
+ function readConsumedIds() {
212
+ try {
213
+ if (!existsSync(PENDING_CONSUMED_LOG))
214
+ return new Set();
215
+ const lines = readFileSync(PENDING_CONSUMED_LOG, 'utf8').split('\n').filter(Boolean);
216
+ const ids = new Set();
217
+ for (const line of lines) {
218
+ try {
219
+ const entry = JSON.parse(line);
220
+ if (typeof entry._id === 'string')
221
+ ids.add(entry._id);
222
+ }
223
+ catch { /* skip malformed */ }
224
+ }
225
+ return ids;
226
+ }
227
+ catch {
228
+ return new Set();
229
+ }
230
+ }
231
+ let isConsuming = false; // single-flight guard
232
+ /**
233
+ * Read pending.jsonl, emit a hive handoff signal for each unconsumed
234
+ * tree_pairs_ready entry, then atomically rewrite the file without them.
235
+ *
236
+ * Only successfully dispatched entries are removed — on failure the entry
237
+ * stays in pending for the next poll cycle. Uses a deterministic ID so
238
+ * restarts are idempotent.
239
+ */
240
+ async function consumePendingTreePairs(log) {
241
+ if (isConsuming)
242
+ return;
243
+ isConsuming = true;
244
+ try {
245
+ if (!existsSync(PENDING_LOG))
246
+ return;
247
+ let rawLines;
248
+ try {
249
+ rawLines = readFileSync(PENDING_LOG, 'utf8').split('\n').filter(Boolean);
250
+ }
251
+ catch {
252
+ return;
253
+ }
254
+ const consumedIds = readConsumedIds();
255
+ const remaining = []; // non-tree_pairs_ready lines → kept
256
+ const toConsume = [];
257
+ for (const line of rawLines) {
258
+ try {
259
+ const entry = JSON.parse(line);
260
+ if (entry.type === 'tree_pairs_ready') {
261
+ const id = makeEntryId(entry);
262
+ if (!consumedIds.has(id)) {
263
+ toConsume.push({ entry, id, raw: line });
264
+ }
265
+ // Already-consumed entries are dropped from pending silently
266
+ }
267
+ else {
268
+ remaining.push(line);
269
+ }
270
+ }
271
+ catch {
272
+ remaining.push(line); // preserve malformed lines verbatim
273
+ }
274
+ }
275
+ if (toConsume.length === 0)
276
+ return;
277
+ log(`🔁 consuming ${toConsume.length} pending tree_pairs_ready event(s)`);
278
+ const succeeded = [];
279
+ const failed = []; // raw lines to keep in pending
280
+ for (const { entry, id, raw } of toConsume) {
281
+ const task = typeof entry.action_hint === 'string' ? entry.action_hint : `review ${entry.tree}/pairs/`;
282
+ const files = Array.isArray(entry.files) ? entry.files : [];
283
+ const summary = `spidersan auto-dispatch: ${entry.tree} pair(s) ready — ${entry.repo}@${entry.after_sha} — ${task}`;
284
+ const args = [
285
+ 'hive', 'signal',
286
+ '--status', 'awaiting-review',
287
+ '--task', task,
288
+ '--handoff', 'review',
289
+ '--summary', summary,
290
+ ];
291
+ if (files.length > 0)
292
+ args.push('--files', files.join(','));
293
+ log(`📡 hive handoff → ${task} (${files.length} file(s))`);
294
+ try {
295
+ await execFileAsync('envoak', args, { timeout: 15000 });
296
+ succeeded.push({ entry, id });
297
+ log(`✅ dispatched ${id}`);
298
+ }
299
+ catch (err) {
300
+ const msg = err instanceof Error ? err.message : String(err);
301
+ log(`⚠ hive signal failed for ${id}: ${msg} — retaining in pending`);
302
+ failed.push(raw);
303
+ }
304
+ }
305
+ if (succeeded.length === 0)
306
+ return;
307
+ // Record successful dispatches in consumed log
308
+ for (const { entry, id } of succeeded) {
309
+ appendLog(PENDING_CONSUMED_LOG, { ...entry, _id: id, consumed_at: new Date().toISOString() });
310
+ }
311
+ // Atomic rewrite: remaining non-consumed lines + any failed tree_pairs lines
312
+ const finalLines = [...remaining, ...failed];
313
+ try {
314
+ writeFileSync(PENDING_TEMP, finalLines.length > 0 ? finalLines.join('\n') + '\n' : '');
315
+ renameSync(PENDING_TEMP, PENDING_LOG);
316
+ }
317
+ catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ log(`⚠ failed to rewrite pending log: ${msg}`);
320
+ }
321
+ }
322
+ finally {
323
+ isConsuming = false;
324
+ }
325
+ }
326
+ const NULL_SHA = '0000000000000000000000000000000000000000';
327
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
328
+ async function enrichFilesFromGit(repoPath, beforeSha, afterSha) {
329
+ if (!beforeSha || !afterSha || beforeSha === NULL_SHA)
330
+ return [];
331
+ if (!SHA_RE.test(beforeSha) || !SHA_RE.test(afterSha))
332
+ return [];
333
+ try {
334
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', `${beforeSha}...${afterSha}`], {
335
+ cwd: repoPath,
336
+ timeout: 10000,
337
+ });
338
+ return stdout.trim().split('\n').filter(Boolean);
339
+ }
340
+ catch {
341
+ return [];
342
+ }
343
+ }
344
+ // ─── Event handlers ───────────────────────────────────────────────────────────
345
+ async function handlePush(event, localPaths, log) {
346
+ const shortSha = event.after_sha?.slice(0, 7) ?? 'unknown';
347
+ const who = event.sender_login ?? 'unknown';
348
+ const branch = event.branch ?? event.ref ?? 'unknown';
349
+ log(`⚠ git push: ${event.repo} ${branch} by ${who} (${shortSha}) — run \`spidersan pulse\` to check conflicts`);
350
+ // Notify fleet when main is updated — all machines see this in hive status / pulse
351
+ const isDefaultBranch = branch === 'main' || branch === 'master';
352
+ if (isDefaultBranch) {
353
+ const commitCount = event.commits?.length ?? 1;
354
+ const summary = `${commitCount} commit${commitCount !== 1 ? 's' : ''} pushed to ${event.repo}/${branch} by ${who} (${shortSha})`;
355
+ execFile('envoak', [
356
+ 'hive', 'signal',
357
+ '--status', 'idle',
358
+ '--task', `main updated: ${event.repo}`,
359
+ '--summary', summary,
360
+ ], { timeout: 10000 }, () => { });
361
+ log(`📡 hive signal emitted: ${summary}`);
362
+ }
363
+ const entry = {
364
+ type: 'git_push',
365
+ repo: event.repo,
366
+ branch,
367
+ sender: who,
368
+ after_sha: shortSha,
369
+ received_at: event.received_at,
370
+ ts: new Date().toISOString(),
371
+ action_hint: 'spidersan pulse',
372
+ };
373
+ appendLog(ACTIVITY_LOG, { ...entry, source: 'git-watch' });
374
+ appendLog(PENDING_LOG, entry);
375
+ // Detect newly committed gold pairs in sql-tree / ts-tree
376
+ if (event.repo === 'treebird7/treebird') {
377
+ await detectTreePairs(event, log);
378
+ }
379
+ // Phase C: AI advice — computed once per event (not per local path)
380
+ const aiRepoPath = localPaths[0];
381
+ const files = aiRepoPath
382
+ ? await enrichFilesFromGit(aiRepoPath, event.before_sha, event.after_sha)
383
+ : [];
384
+ const payload = {
385
+ type: 'push',
386
+ repo: event.repo,
387
+ branch,
388
+ files,
389
+ metadata: { after_sha: event.after_sha, sender: who },
390
+ };
391
+ try {
392
+ const advice = await handleEvent(payload, { repoRoot: aiRepoPath, localOnly: true });
393
+ if (advice.tier >= 2) {
394
+ logActivity({
395
+ repo: event.repo,
396
+ branch,
397
+ agent: who !== 'unknown' ? who : undefined,
398
+ event: 'conflict_detected',
399
+ details: {
400
+ source: 'git-watch-ai',
401
+ tier: advice.tier,
402
+ action: advice.action,
403
+ message: advice.message,
404
+ commands: advice.commands,
405
+ after_sha: event.after_sha,
406
+ files_checked: files.length,
407
+ },
408
+ });
409
+ log(`🕷 AI [T${advice.tier}/${advice.action}] ${event.repo}/${branch}: ${advice.message.slice(0, 120)}`);
410
+ // Best-effort hive signal — async, non-blocking
411
+ execFile('envoak', [
412
+ 'hive', 'signal',
413
+ '--status', 'needs-context',
414
+ '--task', `TIER ${advice.tier} conflict: ${event.repo}/${branch} — ${advice.message.slice(0, 120)}`,
415
+ ], { timeout: 10000 }, () => { });
416
+ }
417
+ }
418
+ catch {
419
+ // AI layer failure is non-fatal — daemon continues
420
+ }
421
+ // Per-path queue: reserved for Phase C2 per-path operations
422
+ for (const p of localPaths) {
423
+ enqueue(p, async () => { void p; });
424
+ }
425
+ }
426
+ async function handlePR(event, _localPaths, log) {
427
+ const action = event.action ?? 'event';
428
+ const branch = event.branch ?? 'unknown';
429
+ const who = event.sender_login ?? 'unknown';
430
+ log(`ℹ PR ${action}: ${event.repo} ${branch} by ${who}`);
431
+ appendLog(ACTIVITY_LOG, { type: 'pull_request', action, repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
432
+ // Phase C2: AI merge-readiness advice
433
+ const payload = {
434
+ type: 'pull_request',
435
+ repo: event.repo,
436
+ branch,
437
+ metadata: { action, sender: who },
438
+ };
439
+ try {
440
+ const advice = await handleEvent(payload, { localOnly: true });
441
+ if (advice.tier >= 2) {
442
+ logActivity({
443
+ repo: event.repo,
444
+ branch,
445
+ agent: who !== 'unknown' ? who : undefined,
446
+ event: 'conflict_detected',
447
+ details: {
448
+ source: 'git-watch-ai',
449
+ trigger: 'pull_request',
450
+ pr_action: action,
451
+ tier: advice.tier,
452
+ action: advice.action,
453
+ message: advice.message,
454
+ commands: advice.commands,
455
+ },
456
+ });
457
+ log(`🕷 AI [T${advice.tier}/${advice.action}] PR ${event.repo}/${branch}: ${advice.message.slice(0, 120)}`);
458
+ execFile('envoak', [
459
+ 'hive', 'signal',
460
+ '--status', 'needs-context',
461
+ '--task', `TIER ${advice.tier} PR conflict: ${event.repo}/${branch} — ${advice.message.slice(0, 120)}`,
462
+ ], { timeout: 10000 }, () => { });
463
+ }
464
+ }
465
+ catch {
466
+ // AI layer failure is non-fatal
467
+ }
468
+ }
469
+ async function handleCreate(event, _localPaths, log) {
470
+ if (event.ref_type !== 'branch')
471
+ return;
472
+ const branch = event.branch ?? 'unknown';
473
+ const who = event.sender_login ?? 'unknown';
474
+ log(`ℹ branch created: ${event.repo}/${branch} by ${who}`);
475
+ appendLog(ACTIVITY_LOG, { type: 'branch_created', repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
476
+ // Phase C2: register branch stub so AI conflict detection knows it exists
477
+ // before any files are pushed. Files start empty; enriched on first push.
478
+ try {
479
+ const storage = await getStorage();
480
+ const existing = await storage.get(branch);
481
+ if (!existing) {
482
+ await storage.register({
483
+ name: branch,
484
+ files: [],
485
+ status: 'active',
486
+ agent: who !== 'unknown' ? who : undefined,
487
+ description: `auto-registered by git-watch on remote create (${event.repo})`,
488
+ });
489
+ log(` registered branch stub: ${branch}`);
490
+ logActivity({
491
+ repo: event.repo,
492
+ branch,
493
+ agent: who !== 'unknown' ? who : undefined,
494
+ event: 'register',
495
+ details: { source: 'git-watch-auto', trigger: 'remote_branch_create' },
496
+ });
497
+ }
498
+ }
499
+ catch {
500
+ // Storage write is best-effort — log failure is non-fatal
501
+ }
502
+ }
503
+ async function handleDelete(event, localPaths, log) {
504
+ if (event.ref_type !== 'branch')
505
+ return;
506
+ const branch = event.branch ?? 'unknown';
507
+ const who = event.sender_login ?? 'unknown';
508
+ log(`🗑 branch deleted remotely: ${event.repo}/${branch} by ${who} — archiving in local registries`);
509
+ // Only fire the conflict notification once even if multiple local paths exist
510
+ let notified = false;
511
+ for (const p of localPaths) {
512
+ enqueue(p, async () => {
513
+ try {
514
+ const storage = await getStorage();
515
+ const allBranches = await storage.list();
516
+ const entry = allBranches.find(b => b.name === branch);
517
+ if (!entry)
518
+ return;
519
+ // Notify if the deleted branch had active file registrations — means
520
+ // conflict detection was tracking it and other branches may share files.
521
+ if (!notified && entry.status === 'active' && entry.files && entry.files.length > 0) {
522
+ notified = true;
523
+ log(`⚠ deleted branch ${branch} had ${entry.files.length} registered file(s) — potential conflict impact`);
524
+ logActivity({
525
+ repo: event.repo,
526
+ branch,
527
+ agent: who !== 'unknown' ? who : undefined,
528
+ event: 'conflict_detected',
529
+ details: {
530
+ trigger: 'branch_deleted_with_active_registrations',
531
+ files: entry.files,
532
+ deleted_by: who,
533
+ },
534
+ });
535
+ execFile('envoak', [
536
+ 'hive', 'signal',
537
+ '--status', 'awaiting-review',
538
+ '--task', `deleted branch ${branch} had active conflict registrations (${entry.files.length} files) in ${event.repo}`,
539
+ '--files', entry.files.slice(0, 5).join(','),
540
+ ], { timeout: 10000 }, () => { });
541
+ }
542
+ // Archive first, then remove from active registry
543
+ appendLog(ARCHIVE_LOG, {
544
+ ...entry,
545
+ archived_at: new Date().toISOString(),
546
+ archived_reason: 'remote-delete',
547
+ archived_by: who,
548
+ repo_path: p,
549
+ });
550
+ await storage.update(branch, { status: 'abandoned' });
551
+ log(` archived ${branch} from ${p}`);
552
+ }
553
+ catch {
554
+ // non-fatal
555
+ }
556
+ });
557
+ }
558
+ appendLog(ACTIVITY_LOG, { type: 'branch_deleted', repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
559
+ }
560
+ async function dispatchEvent(event, repoMap, opts) {
561
+ const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
562
+ const localPaths = repoMap.get(event.repo) ?? [];
563
+ opts.onEvent?.(event);
564
+ switch (event.event_type) {
565
+ case 'push': return handlePush(event, localPaths, log);
566
+ case 'pull_request': return handlePR(event, localPaths, log);
567
+ case 'create': return handleCreate(event, localPaths, log);
568
+ case 'delete': return handleDelete(event, localPaths, log);
569
+ }
570
+ }
571
+ function getConfig() {
572
+ const url = process.env.SUPABASE_URL;
573
+ if (!url)
574
+ return null;
575
+ const jwt = process.env.COLONY_SESSION_JWT;
576
+ const key = process.env.SUPABASE_KEY;
577
+ if (!jwt && !key)
578
+ return null;
579
+ return { url, key: key ?? '', jwt };
580
+ }
581
+ async function supabaseFetch(cfg, path, params = {}) {
582
+ const qs = new URLSearchParams(params).toString();
583
+ const endpoint = `${cfg.url}/rest/v1/${path}${qs ? '?' + qs : ''}`;
584
+ const headers = {
585
+ 'apikey': cfg.key,
586
+ 'Content-Type': 'application/json',
587
+ };
588
+ headers['Authorization'] = cfg.jwt ? `Bearer ${cfg.jwt}` : `Bearer ${cfg.key}`;
589
+ try {
590
+ const res = await fetch(endpoint, { headers });
591
+ if (!res.ok)
592
+ return null;
593
+ return res.json();
594
+ }
595
+ catch {
596
+ return null;
597
+ }
598
+ }
599
+ // ─── Catch-up query ───────────────────────────────────────────────────────────
600
+ async function catchUp(cfg, repoMap, opts) {
601
+ const cursor = readCursor();
602
+ const data = await supabaseFetch(cfg, 'spidersan_git_events', {
603
+ 'seq': `gt.${cursor}`,
604
+ 'order': 'seq.asc',
605
+ 'limit': '200',
606
+ 'select': '*',
607
+ });
608
+ if (!data?.length)
609
+ return;
610
+ for (const row of data) {
611
+ await dispatchEvent(row, repoMap, opts);
612
+ }
613
+ writeCursor(data[data.length - 1].seq);
614
+ }
615
+ /**
616
+ * Opens a Supabase Realtime WebSocket subscription to spidersan_git_events.
617
+ * Uses Node 24's native globalThis.WebSocket — no extra dependencies.
618
+ * Returns null if WebSocket is unavailable (graceful downgrade to polling).
619
+ */
620
+ function startRealtimeSubscription(cfg, repoMap, opts, highWaterMark, log) {
621
+ // Feature-detect native WebSocket (Node 21.3+)
622
+ const WS = globalThis.WebSocket;
623
+ if (!WS)
624
+ return null;
625
+ // Rebind so TypeScript keeps it non-nullable inside nested closures
626
+ const WSClass = WS;
627
+ const project = cfg.url.replace('https://', '').split('.')[0];
628
+ const wsUrl = `wss://${project}.supabase.co/realtime/v1/websocket?apikey=${cfg.key}&vsn=1.0.0`;
629
+ let ws = null;
630
+ let stopped = false;
631
+ let heartbeatTimer = null;
632
+ let reconnectTimer = null;
633
+ let backoffMs = 1000;
634
+ let refCounter = 1;
635
+ function connect() {
636
+ if (stopped)
637
+ return;
638
+ ws = new WSClass(wsUrl);
639
+ ws.onopen = () => {
640
+ backoffMs = 1000;
641
+ log('realtime: WebSocket active');
642
+ // Join the postgres_changes channel
643
+ ws.send(JSON.stringify({
644
+ topic: 'realtime:public:spidersan_git_events',
645
+ event: 'phx_join',
646
+ payload: {
647
+ config: {
648
+ broadcast: { ack: false },
649
+ presence: { key: '' },
650
+ postgres_changes: [
651
+ { event: 'INSERT', schema: 'public', table: 'spidersan_git_events' },
652
+ ],
653
+ },
654
+ access_token: cfg.jwt ?? cfg.key,
655
+ },
656
+ ref: String(refCounter++),
657
+ }));
658
+ // Heartbeat every 25s
659
+ heartbeatTimer = setInterval(() => {
660
+ if (ws?.readyState === 1 /* OPEN */) {
661
+ ws.send(JSON.stringify({
662
+ topic: 'phoenix',
663
+ event: 'heartbeat',
664
+ payload: {},
665
+ ref: String(refCounter++),
666
+ }));
667
+ }
668
+ }, 25_000);
669
+ };
670
+ ws.onmessage = (ev) => {
671
+ let msg;
672
+ try {
673
+ msg = JSON.parse(ev.data);
674
+ }
675
+ catch {
676
+ return;
677
+ }
678
+ // Supabase sends postgres_changes events
679
+ if (msg.event !== 'postgres_changes')
680
+ return;
681
+ const data = msg.payload?.data;
682
+ if (!data || data.type !== 'INSERT')
683
+ return;
684
+ const record = data.record;
685
+ if (!record || typeof record.seq !== 'number')
686
+ return;
687
+ // Dedup with shared high-water mark
688
+ if (record.seq <= highWaterMark.seq)
689
+ return;
690
+ highWaterMark.seq = record.seq;
691
+ writeCursor(record.seq);
692
+ dispatchEvent(record, repoMap, opts).catch(() => { });
693
+ };
694
+ ws.onerror = () => { };
695
+ ws.onclose = () => {
696
+ if (heartbeatTimer) {
697
+ clearInterval(heartbeatTimer);
698
+ heartbeatTimer = null;
699
+ }
700
+ if (stopped)
701
+ return;
702
+ log(`realtime: disconnected — reconnecting in ${backoffMs / 1000}s`);
703
+ reconnectTimer = setTimeout(() => {
704
+ // Catch up on events missed during disconnect, then reconnect
705
+ if (getConfig())
706
+ catchUp(cfg, repoMap, opts).catch(() => { }).finally(connect);
707
+ }, backoffMs);
708
+ backoffMs = Math.min(backoffMs * 2, 30_000);
709
+ };
710
+ }
711
+ connect();
712
+ return {
713
+ stop: () => {
714
+ stopped = true;
715
+ if (heartbeatTimer)
716
+ clearInterval(heartbeatTimer);
717
+ if (reconnectTimer)
718
+ clearTimeout(reconnectTimer);
719
+ ws?.close();
720
+ },
721
+ };
722
+ }
723
+ // ─── Public API ───────────────────────────────────────────────────────────────
724
+ /**
725
+ * Start the git-events daemon in hybrid mode:
726
+ * - Supabase Realtime WebSocket for < 1s delivery (when available)
727
+ * - 5-min safety-sweep poll to catch events missed during disconnects
728
+ * Returns a handle with a stop() method.
729
+ */
730
+ export async function startGitWatch(opts = {}) {
731
+ const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
732
+ // Safety-sweep interval: 5 min when realtime is active, otherwise use configured interval
733
+ const pollMs = opts.pollIntervalMs ?? 60_000;
734
+ const cfg = getConfig();
735
+ if (!cfg) {
736
+ throw new Error('git-watch: SUPABASE_URL and (SUPABASE_KEY or COLONY_SESSION_JWT) are required');
737
+ }
738
+ const repoMap = buildRepoMap(opts.repoPaths ?? []);
739
+ log(`Watching ${repoMap.size} repo(s): ${[...repoMap.keys()].join(', ')}`);
740
+ // Shared high-water mark (monotonic seq dedup between realtime + poll paths)
741
+ const highWaterMark = { seq: readCursor() };
742
+ // Catch-up on startup
743
+ await catchUp(cfg, repoMap, opts);
744
+ await consumePendingTreePairs(log);
745
+ // Start Realtime WebSocket (unless explicitly disabled)
746
+ let realtimeHandle = null;
747
+ if (!opts.disableRealtime) {
748
+ realtimeHandle = startRealtimeSubscription(cfg, repoMap, opts, highWaterMark, log);
749
+ if (!realtimeHandle) {
750
+ log('realtime: WebSocket unavailable — polling only');
751
+ }
752
+ }
753
+ // Safety-sweep poll (5 min when realtime active, else use configured interval)
754
+ const sweepMs = realtimeHandle ? Math.max(pollMs, 5 * 60_000) : pollMs;
755
+ const pollTimer = setInterval(async () => {
756
+ await catchUp(cfg, repoMap, opts);
757
+ await consumePendingTreePairs(log);
758
+ }, sweepMs);
759
+ return {
760
+ stop: () => {
761
+ clearInterval(pollTimer);
762
+ realtimeHandle?.stop();
763
+ },
764
+ };
765
+ }
766
+ /**
767
+ * One-shot catch-up: fetch any missed events since last cursor, then exit.
768
+ * Useful for cron/CI environments that don't run persistent daemons.
769
+ */
770
+ export async function catchUpOnce(opts = {}) {
771
+ const cfg = getConfig();
772
+ if (!cfg)
773
+ throw new Error('git-watch: SUPABASE_URL and (SUPABASE_KEY or COLONY_SESSION_JWT) are required');
774
+ const repoMap = buildRepoMap(opts.repoPaths ?? []);
775
+ const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
776
+ await catchUp(cfg, repoMap, opts);
777
+ await consumePendingTreePairs(log);
778
+ }
779
+ //# sourceMappingURL=git-events-subscriber.js.map