spidersan 0.20.1 → 0.20.2

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