@lovenyberg/ove 0.2.1 → 0.3.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.
@@ -0,0 +1,826 @@
1
+ # Repo Auto-Discovery Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Scale Ove from manually configured repos to auto-discovering 50-100+ repos via GitHub, with on-demand cloning and Claude-powered repo resolution.
6
+
7
+ **Architecture:** New `RepoRegistry` class backed by SQLite `repos` table. GitHub sync via `gh repo list` runs on startup + interval. Config.json repos become overrides only. User wildcard `["*"]` grants access to all discovered repos. When a user message doesn't name a repo, the full repo list is injected into the prompt for Claude to resolve.
8
+
9
+ **Tech Stack:** Bun + TypeScript, bun:sqlite, `gh` CLI for GitHub API, existing ClaudeRunner for repo resolution.
10
+
11
+ ---
12
+
13
+ ### Task 1: Create RepoRegistry — SQLite table + CRUD
14
+
15
+ **Files:**
16
+ - Create: `src/repo-registry.ts`
17
+ - Test: `src/repo-registry.test.ts`
18
+
19
+ **Step 1: Write the failing tests**
20
+
21
+ ```typescript
22
+ // src/repo-registry.test.ts
23
+ import { describe, it, expect, beforeEach } from "bun:test";
24
+ import { Database } from "bun:sqlite";
25
+ import { RepoRegistry } from "./repo-registry";
26
+
27
+ describe("RepoRegistry", () => {
28
+ let db: Database;
29
+ let registry: RepoRegistry;
30
+
31
+ beforeEach(() => {
32
+ db = new Database(":memory:");
33
+ registry = new RepoRegistry(db);
34
+ });
35
+
36
+ it("creates repos table on construction", () => {
37
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='repos'").all();
38
+ expect(tables.length).toBe(1);
39
+ });
40
+
41
+ it("upserts and retrieves a repo", () => {
42
+ registry.upsert({
43
+ name: "my-app",
44
+ url: "git@github.com:user/my-app.git",
45
+ owner: "user",
46
+ defaultBranch: "main",
47
+ source: "github-sync",
48
+ });
49
+ const repo = registry.getByName("my-app");
50
+ expect(repo).not.toBeNull();
51
+ expect(repo!.url).toBe("git@github.com:user/my-app.git");
52
+ expect(repo!.source).toBe("github-sync");
53
+ });
54
+
55
+ it("upsert updates existing repo", () => {
56
+ registry.upsert({ name: "my-app", url: "old-url", source: "config" });
57
+ registry.upsert({ name: "my-app", url: "new-url", source: "github-sync" });
58
+ const repo = registry.getByName("my-app");
59
+ expect(repo!.url).toBe("new-url");
60
+ });
61
+
62
+ it("returns null for unknown repo", () => {
63
+ expect(registry.getByName("nope")).toBeNull();
64
+ });
65
+
66
+ it("lists all non-excluded repos", () => {
67
+ registry.upsert({ name: "a", url: "u1", source: "github-sync" });
68
+ registry.upsert({ name: "b", url: "u2", source: "github-sync" });
69
+ registry.upsert({ name: "c", url: "u3", source: "github-sync", excluded: true });
70
+ const all = registry.getAll();
71
+ expect(all.length).toBe(2);
72
+ expect(all.map(r => r.name).sort()).toEqual(["a", "b"]);
73
+ });
74
+
75
+ it("lists all repo names", () => {
76
+ registry.upsert({ name: "x", url: "u1", source: "config" });
77
+ registry.upsert({ name: "y", url: "u2", source: "github-sync" });
78
+ const names = registry.getAllNames();
79
+ expect(names.sort()).toEqual(["x", "y"]);
80
+ });
81
+
82
+ it("excludes a repo", () => {
83
+ registry.upsert({ name: "old", url: "u", source: "github-sync" });
84
+ registry.setExcluded("old", true);
85
+ expect(registry.getAll().length).toBe(0);
86
+ expect(registry.getByName("old")!.excluded).toBe(true);
87
+ });
88
+
89
+ it("migrates config repos", () => {
90
+ const configRepos = {
91
+ "my-app": { url: "git@github.com:user/my-app.git", defaultBranch: "main" },
92
+ "infra": { url: "git@github.com:user/infra.git", defaultBranch: "develop" },
93
+ };
94
+ registry.migrateFromConfig(configRepos);
95
+ expect(registry.getAll().length).toBe(2);
96
+ const infra = registry.getByName("infra");
97
+ expect(infra!.defaultBranch).toBe("develop");
98
+ expect(infra!.source).toBe("config");
99
+ });
100
+
101
+ it("migration does not overwrite github-sync repos", () => {
102
+ registry.upsert({ name: "my-app", url: "gh-url", source: "github-sync", defaultBranch: "main" });
103
+ registry.migrateFromConfig({
104
+ "my-app": { url: "config-url", defaultBranch: "main" },
105
+ });
106
+ expect(registry.getByName("my-app")!.url).toBe("gh-url");
107
+ });
108
+ });
109
+ ```
110
+
111
+ **Step 2: Run tests to verify they fail**
112
+
113
+ Run: `bun test src/repo-registry.test.ts`
114
+ Expected: FAIL — `repo-registry` module not found
115
+
116
+ **Step 3: Write the implementation**
117
+
118
+ ```typescript
119
+ // src/repo-registry.ts
120
+ import { Database } from "bun:sqlite";
121
+
122
+ export interface RepoRecord {
123
+ name: string;
124
+ url: string;
125
+ owner?: string;
126
+ defaultBranch: string;
127
+ source: string; // "github-sync" | "manual" | "config"
128
+ excluded: boolean;
129
+ lastSyncedAt: string | null;
130
+ }
131
+
132
+ export interface RepoUpsertInput {
133
+ name: string;
134
+ url: string;
135
+ owner?: string;
136
+ defaultBranch?: string;
137
+ source: string;
138
+ excluded?: boolean;
139
+ }
140
+
141
+ export class RepoRegistry {
142
+ private db: Database;
143
+
144
+ constructor(db: Database) {
145
+ this.db = db;
146
+ this.db.run(`
147
+ CREATE TABLE IF NOT EXISTS repos (
148
+ name TEXT PRIMARY KEY,
149
+ url TEXT NOT NULL,
150
+ owner TEXT,
151
+ default_branch TEXT DEFAULT 'main',
152
+ source TEXT NOT NULL,
153
+ excluded INTEGER DEFAULT 0,
154
+ last_synced_at TEXT
155
+ )
156
+ `);
157
+ }
158
+
159
+ upsert(input: RepoUpsertInput): void {
160
+ this.db.run(
161
+ `INSERT INTO repos (name, url, owner, default_branch, source, excluded, last_synced_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?)
163
+ ON CONFLICT(name) DO UPDATE SET
164
+ url = excluded.url,
165
+ owner = excluded.owner,
166
+ default_branch = excluded.default_branch,
167
+ source = excluded.source,
168
+ excluded = excluded.excluded,
169
+ last_synced_at = excluded.last_synced_at`,
170
+ [
171
+ input.name,
172
+ input.url,
173
+ input.owner || null,
174
+ input.defaultBranch || "main",
175
+ input.source,
176
+ input.excluded ? 1 : 0,
177
+ new Date().toISOString(),
178
+ ]
179
+ );
180
+ }
181
+
182
+ getByName(name: string): RepoRecord | null {
183
+ const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as any;
184
+ return row ? this.rowToRecord(row) : null;
185
+ }
186
+
187
+ getAll(): RepoRecord[] {
188
+ const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
189
+ return rows.map(r => this.rowToRecord(r));
190
+ }
191
+
192
+ getAllNames(): string[] {
193
+ const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
194
+ return rows.map(r => r.name);
195
+ }
196
+
197
+ setExcluded(name: string, excluded: boolean): void {
198
+ this.db.run(`UPDATE repos SET excluded = ? WHERE name = ?`, [excluded ? 1 : 0, name]);
199
+ }
200
+
201
+ migrateFromConfig(configRepos: Record<string, { url: string; defaultBranch?: string }>): void {
202
+ for (const [name, repo] of Object.entries(configRepos)) {
203
+ // Don't overwrite repos already synced from GitHub
204
+ const existing = this.getByName(name);
205
+ if (existing && existing.source === "github-sync") continue;
206
+
207
+ this.upsert({
208
+ name,
209
+ url: repo.url,
210
+ defaultBranch: repo.defaultBranch || "main",
211
+ source: "config",
212
+ });
213
+ }
214
+ }
215
+
216
+ private rowToRecord(row: any): RepoRecord {
217
+ return {
218
+ name: row.name,
219
+ url: row.url,
220
+ owner: row.owner,
221
+ defaultBranch: row.default_branch,
222
+ source: row.source,
223
+ excluded: row.excluded === 1,
224
+ lastSyncedAt: row.last_synced_at,
225
+ };
226
+ }
227
+ }
228
+ ```
229
+
230
+ **Step 4: Run tests to verify they pass**
231
+
232
+ Run: `bun test src/repo-registry.test.ts`
233
+ Expected: All 9 tests PASS
234
+
235
+ **Step 5: Commit**
236
+
237
+ ```bash
238
+ git add src/repo-registry.ts src/repo-registry.test.ts
239
+ git commit -m "feat: add RepoRegistry with SQLite-backed repo store"
240
+ ```
241
+
242
+ ---
243
+
244
+ ### Task 2: Add GitHub sync to RepoRegistry
245
+
246
+ **Files:**
247
+ - Modify: `src/repo-registry.ts`
248
+ - Test: `src/repo-registry.test.ts` (add sync tests)
249
+
250
+ **Step 1: Write the failing tests**
251
+
252
+ Add to `src/repo-registry.test.ts`:
253
+
254
+ ```typescript
255
+ describe("parseGhRepoLine", () => {
256
+ it("parses standard gh repo list output", () => {
257
+ const result = parseGhRepoLine("jacksoncage/ove\tMy app\tpublic\t2026-02-20T10:00:00Z");
258
+ expect(result).toEqual({ name: "ove", owner: "jacksoncage", fullName: "jacksoncage/ove" });
259
+ });
260
+
261
+ it("returns null for empty line", () => {
262
+ expect(parseGhRepoLine("")).toBeNull();
263
+ });
264
+ });
265
+ ```
266
+
267
+ `parseGhRepoLine` is exported from `repo-registry.ts`.
268
+
269
+ **Step 2: Run tests to verify they fail**
270
+
271
+ Run: `bun test src/repo-registry.test.ts`
272
+ Expected: FAIL — `parseGhRepoLine` not exported
273
+
274
+ **Step 3: Write the implementation**
275
+
276
+ Add to `src/repo-registry.ts`:
277
+
278
+ ```typescript
279
+ import { logger } from "./logger";
280
+
281
+ export interface GhRepoParsed {
282
+ name: string;
283
+ owner: string;
284
+ fullName: string;
285
+ }
286
+
287
+ export function parseGhRepoLine(line: string): GhRepoParsed | null {
288
+ const trimmed = line.trim();
289
+ if (!trimmed) return null;
290
+ // gh repo list output: owner/name\tdescription\tvisibility\tupdated_at
291
+ const fullName = trimmed.split("\t")[0];
292
+ if (!fullName || !fullName.includes("/")) return null;
293
+ const [owner, name] = fullName.split("/");
294
+ return { name, owner, fullName };
295
+ }
296
+
297
+ export async function syncGitHub(
298
+ registry: RepoRegistry,
299
+ orgs?: string[]
300
+ ): Promise<number> {
301
+ let count = 0;
302
+ const targets = orgs && orgs.length > 0 ? orgs : [undefined];
303
+
304
+ for (const org of targets) {
305
+ try {
306
+ const args = ["repo", "list", "--limit", "500", "--no-archived"];
307
+ if (org) args.splice(2, 0, org);
308
+
309
+ const proc = Bun.spawn(["gh", ...args], {
310
+ stdout: "pipe",
311
+ stderr: "pipe",
312
+ });
313
+
314
+ const output = await new Response(proc.stdout).text();
315
+ const exitCode = await proc.exited;
316
+
317
+ if (exitCode !== 0) {
318
+ const stderr = await new Response(proc.stderr).text();
319
+ logger.warn("gh repo list failed", { org, error: stderr.slice(0, 200) });
320
+ continue;
321
+ }
322
+
323
+ for (const line of output.split("\n")) {
324
+ const parsed = parseGhRepoLine(line);
325
+ if (!parsed) continue;
326
+
327
+ registry.upsert({
328
+ name: parsed.name,
329
+ url: `git@github.com:${parsed.fullName}.git`,
330
+ owner: parsed.owner,
331
+ source: "github-sync",
332
+ });
333
+ count++;
334
+ }
335
+ } catch (err) {
336
+ logger.warn("github sync error", { org, error: String(err) });
337
+ }
338
+ }
339
+
340
+ logger.info("github sync complete", { repos: count });
341
+ return count;
342
+ }
343
+ ```
344
+
345
+ **Step 4: Run tests to verify they pass**
346
+
347
+ Run: `bun test src/repo-registry.test.ts`
348
+ Expected: All tests PASS (sync function tested end-to-end in Task 6)
349
+
350
+ **Step 5: Commit**
351
+
352
+ ```bash
353
+ git add src/repo-registry.ts src/repo-registry.test.ts
354
+ git commit -m "feat: add GitHub sync via gh repo list"
355
+ ```
356
+
357
+ ---
358
+
359
+ ### Task 3: Update config.ts — GitHub config, wildcard auth, optional url
360
+
361
+ **Files:**
362
+ - Modify: `src/config.ts`
363
+ - Test: `src/config.test.ts` (create)
364
+
365
+ **Step 1: Write the failing tests**
366
+
367
+ ```typescript
368
+ // src/config.test.ts
369
+ import { describe, it, expect } from "bun:test";
370
+ import { isAuthorized, getUserRepos } from "./config";
371
+ import type { Config } from "./config";
372
+
373
+ function makeConfig(overrides: Partial<Config> = {}): Config {
374
+ return {
375
+ repos: {},
376
+ users: {},
377
+ claude: { maxTurns: 25 },
378
+ reposDir: "./repos",
379
+ ...overrides,
380
+ };
381
+ }
382
+
383
+ describe("wildcard auth", () => {
384
+ it("grants access to any repo with wildcard", () => {
385
+ const config = makeConfig({
386
+ users: { "tg:123": { name: "love", repos: ["*"] } },
387
+ });
388
+ expect(isAuthorized(config, "tg:123", "any-repo")).toBe(true);
389
+ expect(isAuthorized(config, "tg:123", "another-repo")).toBe(true);
390
+ });
391
+
392
+ it("still works with explicit repo list", () => {
393
+ const config = makeConfig({
394
+ users: { "tg:123": { name: "love", repos: ["my-app"] } },
395
+ });
396
+ expect(isAuthorized(config, "tg:123", "my-app")).toBe(true);
397
+ expect(isAuthorized(config, "tg:123", "other")).toBe(false);
398
+ });
399
+
400
+ it("denies unknown user", () => {
401
+ const config = makeConfig();
402
+ expect(isAuthorized(config, "unknown", "repo")).toBe(false);
403
+ });
404
+ });
405
+
406
+ describe("getUserRepos with wildcard", () => {
407
+ it("returns ['*'] when user has wildcard", () => {
408
+ const config = makeConfig({
409
+ users: { "tg:123": { name: "love", repos: ["*"] } },
410
+ });
411
+ expect(getUserRepos(config, "tg:123")).toEqual(["*"]);
412
+ });
413
+ });
414
+
415
+ describe("github config", () => {
416
+ it("loadConfig parses github section", async () => {
417
+ // This is a structural test — just verify the type compiles
418
+ const config = makeConfig({
419
+ github: { syncInterval: 60000, orgs: ["my-org"] },
420
+ });
421
+ expect(config.github!.syncInterval).toBe(60000);
422
+ expect(config.github!.orgs).toEqual(["my-org"]);
423
+ });
424
+ });
425
+ ```
426
+
427
+ **Step 2: Run tests to verify they fail**
428
+
429
+ Run: `bun test src/config.test.ts`
430
+ Expected: FAIL — `github` not a valid key on Config type
431
+
432
+ **Step 3: Write the implementation**
433
+
434
+ Modify `src/config.ts`:
435
+
436
+ 1. Add `GitHubConfig` interface:
437
+ ```typescript
438
+ export interface GitHubConfig {
439
+ syncInterval?: number; // ms, default 1800000 (30 min)
440
+ orgs?: string[];
441
+ }
442
+ ```
443
+
444
+ 2. Add `github` to `Config`:
445
+ ```typescript
446
+ export interface Config {
447
+ repos: Record<string, RepoConfig>;
448
+ users: Record<string, UserConfig>;
449
+ claude: { maxTurns: number };
450
+ reposDir: string;
451
+ mcpServers?: Record<string, McpServerConfig>;
452
+ cron?: CronTaskConfig[];
453
+ runner?: RunnerConfig;
454
+ github?: GitHubConfig;
455
+ }
456
+ ```
457
+
458
+ 3. Make `url` optional on `RepoConfig` (overrides-only repos won't have a url):
459
+ ```typescript
460
+ export interface RepoConfig {
461
+ url?: string;
462
+ defaultBranch?: string;
463
+ runner?: RunnerConfig;
464
+ excluded?: boolean;
465
+ }
466
+ ```
467
+
468
+ 4. Update `loadConfig` to parse `github`:
469
+ ```typescript
470
+ // In loadConfig return:
471
+ github: raw.github,
472
+ ```
473
+
474
+ 5. Update `isAuthorized` to support wildcard:
475
+ ```typescript
476
+ export function isAuthorized(config: Config, platformUserId: string, repo?: string): boolean {
477
+ const user = config.users[platformUserId];
478
+ if (!user) return false;
479
+ if (!repo) return true;
480
+ return user.repos.includes("*") || user.repos.includes(repo);
481
+ }
482
+ ```
483
+
484
+ 6. Update `saveConfig` to preserve `github`:
485
+ ```typescript
486
+ if (config.github) merged.github = config.github;
487
+ ```
488
+
489
+ **Step 4: Run tests to verify they pass**
490
+
491
+ Run: `bun test src/config.test.ts`
492
+ Expected: All tests PASS
493
+
494
+ Also run existing tests: `bun test src/router.test.ts`
495
+ Expected: All PASS (no breaking changes)
496
+
497
+ **Step 5: Commit**
498
+
499
+ ```bash
500
+ git add src/config.ts src/config.test.ts
501
+ git commit -m "feat: add GitHub config, wildcard auth, optional repo url"
502
+ ```
503
+
504
+ ---
505
+
506
+ ### Task 4: Wire registry into index.ts — replace config.repos, background sync
507
+
508
+ **Files:**
509
+ - Modify: `src/index.ts`
510
+
511
+ This is the integration task. Changes:
512
+
513
+ **Step 1: Import and initialize RepoRegistry**
514
+
515
+ At the top of `src/index.ts`, add:
516
+ ```typescript
517
+ import { RepoRegistry, syncGitHub } from "./repo-registry";
518
+ ```
519
+
520
+ After `const schedules = new ScheduleStore(db);`, add:
521
+ ```typescript
522
+ const repoRegistry = new RepoRegistry(db);
523
+
524
+ // Migrate existing config repos to SQLite
525
+ repoRegistry.migrateFromConfig(
526
+ Object.fromEntries(
527
+ Object.entries(config.repos)
528
+ .filter(([_, r]) => r.url)
529
+ .map(([name, r]) => [name, { url: r.url!, defaultBranch: r.defaultBranch }])
530
+ )
531
+ );
532
+ ```
533
+
534
+ **Step 2: Add background sync function**
535
+
536
+ ```typescript
537
+ async function startGitHubSync() {
538
+ if (!config.github) return;
539
+ const interval = config.github.syncInterval || 1_800_000;
540
+
541
+ // Initial sync
542
+ await syncGitHub(repoRegistry, config.github.orgs);
543
+
544
+ // Recurring sync
545
+ setInterval(() => {
546
+ syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
547
+ logger.warn("github sync failed", { error: String(err) })
548
+ );
549
+ }, interval);
550
+ }
551
+ ```
552
+
553
+ **Step 3: Update repo resolution in handleMessage**
554
+
555
+ Replace the current repo-fallback block (lines 387-401 in current `index.ts`) with registry-aware resolution:
556
+
557
+ ```typescript
558
+ // Need a repo for task commands
559
+ if (!parsed.repo) {
560
+ const userRepos = getUserRepos(config, msg.userId);
561
+ const hasWildcard = userRepos.includes("*");
562
+
563
+ if (!hasWildcard && userRepos.length === 1) {
564
+ parsed.repo = userRepos[0];
565
+ } else if (hasWildcard || userRepos.length > 1) {
566
+ // Resolve via Claude — inject repo list into prompt
567
+ const repoNames = hasWildcard
568
+ ? repoRegistry.getAllNames()
569
+ : userRepos;
570
+
571
+ if (repoNames.length === 1) {
572
+ parsed.repo = repoNames[0];
573
+ } else if (repoNames.length === 0) {
574
+ const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
575
+ await msg.reply(reply);
576
+ return;
577
+ } else {
578
+ // Let Claude resolve — inject available repos into the prompt
579
+ // The free-form prompt + repo list will let Claude pick or ask
580
+ parsed.args._availableRepos = repoNames;
581
+ }
582
+ } else {
583
+ const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
584
+ await msg.reply(reply);
585
+ return;
586
+ }
587
+ }
588
+ ```
589
+
590
+ **Step 4: Update repo config lookup in handleMessage**
591
+
592
+ Replace the hard check for `config.repos[parsed.repo]` (lines 410-414) to fall back to registry:
593
+
594
+ ```typescript
595
+ // Check repo exists — config overrides or registry
596
+ if (parsed.repo) {
597
+ const repoConfig = config.repos[parsed.repo];
598
+ const registryRepo = repoRegistry.getByName(parsed.repo);
599
+
600
+ if (!repoConfig && !registryRepo) {
601
+ await msg.reply(`Never heard of ${parsed.repo}. Check the config or run GitHub sync.`);
602
+ return;
603
+ }
604
+ }
605
+ ```
606
+
607
+ **Step 5: Update processTask to resolve repo URL from registry**
608
+
609
+ In `processTask`, replace the `config.repos[task.repo]` lookup with a function that merges config overrides + registry:
610
+
611
+ ```typescript
612
+ function getRepoInfo(repoName: string): { url: string; defaultBranch: string } | null {
613
+ const configRepo = config.repos[repoName];
614
+ const registryRepo = repoRegistry.getByName(repoName);
615
+
616
+ if (!configRepo && !registryRepo) return null;
617
+
618
+ return {
619
+ url: configRepo?.url || registryRepo?.url || "",
620
+ defaultBranch: configRepo?.defaultBranch || registryRepo?.defaultBranch || "main",
621
+ };
622
+ }
623
+ ```
624
+
625
+ Use this in `processTask` instead of `config.repos[task.repo]`.
626
+
627
+ **Step 6: Inject repo list into prompt when Claude needs to resolve**
628
+
629
+ In `buildContextualPrompt` call (or just before it), when `parsed.args._availableRepos` is set and `parsed.repo` is still undefined, prepend the repo list:
630
+
631
+ ```typescript
632
+ // Before building prompt, inject repo list for Claude resolution
633
+ if (parsed.args._availableRepos && !parsed.repo) {
634
+ const repoList = parsed.args._availableRepos.join(", ");
635
+ const resolvePrefix = `Available repos: ${repoList}\n\nThe user hasn't specified which repo. Based on their message, determine the correct repo. If unclear, ask them which repo they mean.\n\n`;
636
+ parsed.rawText = resolvePrefix + parsed.rawText;
637
+ }
638
+ ```
639
+
640
+ **Step 7: Start GitHub sync in main()**
641
+
642
+ In `main()`, after stale task reset, before adapter startup:
643
+
644
+ ```typescript
645
+ // Start GitHub repo sync (non-blocking)
646
+ startGitHubSync().catch((err) =>
647
+ logger.warn("initial github sync failed", { error: String(err) })
648
+ );
649
+ ```
650
+
651
+ **Step 8: Update handleEvent similarly**
652
+
653
+ Apply the same registry-aware resolution to `handleEvent` (the event adapter code path).
654
+
655
+ **Step 9: Run all tests**
656
+
657
+ Run: `bun test`
658
+ Expected: All existing tests PASS
659
+
660
+ **Step 10: Commit**
661
+
662
+ ```bash
663
+ git add src/index.ts
664
+ git commit -m "feat: wire RepoRegistry into index.ts with GitHub sync and Claude resolution"
665
+ ```
666
+
667
+ ---
668
+
669
+ ### Task 5: Update schedule handling for wildcard repos
670
+
671
+ **Files:**
672
+ - Modify: `src/index.ts` (schedule section in handleMessage)
673
+
674
+ **Step 1: Update schedule repo resolution**
675
+
676
+ The schedule handler at line 264 uses `getUserRepos(config, msg.userId)` to get the repo list. Update it to resolve wildcard:
677
+
678
+ ```typescript
679
+ if (parsed.type === "schedule") {
680
+ await msg.updateStatus("Parsing your schedule...");
681
+ const rawRepos = getUserRepos(config, msg.userId);
682
+ const userRepos = rawRepos.includes("*") ? repoRegistry.getAllNames() : rawRepos;
683
+ // ... rest unchanged
684
+ }
685
+ ```
686
+
687
+ **Step 2: Run tests**
688
+
689
+ Run: `bun test`
690
+ Expected: All PASS
691
+
692
+ **Step 3: Commit**
693
+
694
+ ```bash
695
+ git add src/index.ts
696
+ git commit -m "feat: support wildcard repos in schedule handling"
697
+ ```
698
+
699
+ ---
700
+
701
+ ### Task 6: Integration tests + config.example.json update
702
+
703
+ **Files:**
704
+ - Modify: `config.example.json`
705
+ - Test: `src/repo-registry.test.ts` (add integration-style tests)
706
+
707
+ **Step 1: Add integration tests for sync + config migration flow**
708
+
709
+ Add to `src/repo-registry.test.ts`:
710
+
711
+ ```typescript
712
+ describe("config + registry integration", () => {
713
+ it("config repos + registry merge correctly", () => {
714
+ const db = new Database(":memory:");
715
+ const registry = new RepoRegistry(db);
716
+
717
+ // Simulate GitHub sync adding repos
718
+ registry.upsert({ name: "api", url: "git@github.com:org/api.git", owner: "org", source: "github-sync" });
719
+ registry.upsert({ name: "web", url: "git@github.com:org/web.git", owner: "org", source: "github-sync" });
720
+
721
+ // Simulate config migration (manual repo + override)
722
+ registry.migrateFromConfig({
723
+ "legacy": { url: "git@github.com:me/legacy.git", defaultBranch: "develop" },
724
+ });
725
+
726
+ // All three repos exist
727
+ expect(registry.getAllNames().sort()).toEqual(["api", "legacy", "web"]);
728
+
729
+ // Excluding a repo hides it from getAll but not getByName
730
+ registry.setExcluded("legacy", true);
731
+ expect(registry.getAllNames().sort()).toEqual(["api", "web"]);
732
+ expect(registry.getByName("legacy")).not.toBeNull();
733
+ });
734
+ });
735
+ ```
736
+
737
+ **Step 2: Run tests**
738
+
739
+ Run: `bun test`
740
+ Expected: All PASS
741
+
742
+ **Step 3: Update config.example.json**
743
+
744
+ ```json
745
+ {
746
+ "repos": {
747
+ "my-app": {
748
+ "url": "git@github.com:user/my-app.git",
749
+ "defaultBranch": "main"
750
+ },
751
+ "infra": {
752
+ "runner": { "name": "codex" },
753
+ "defaultBranch": "develop"
754
+ },
755
+ "old-legacy": {
756
+ "excluded": true
757
+ }
758
+ },
759
+ "users": {
760
+ "slack:U12345678": {
761
+ "name": "love",
762
+ "repos": ["my-app"]
763
+ },
764
+ "telegram:123456789": {
765
+ "name": "love",
766
+ "repos": ["*"]
767
+ }
768
+ },
769
+ "claude": {
770
+ "maxTurns": 25
771
+ },
772
+ "runner": {
773
+ "name": "claude"
774
+ },
775
+ "github": {
776
+ "syncInterval": 1800000,
777
+ "orgs": ["my-org"]
778
+ },
779
+ "mcpServers": {
780
+ "filesystem": {
781
+ "command": "npx",
782
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/YOUR_USER"]
783
+ }
784
+ },
785
+ "cron": [
786
+ {
787
+ "schedule": "0 9 * * 1-5",
788
+ "repo": "my-app",
789
+ "prompt": "Review all open PRs and post review comments.",
790
+ "userId": "slack:U12345678"
791
+ }
792
+ ]
793
+ }
794
+ ```
795
+
796
+ **Step 4: Commit**
797
+
798
+ ```bash
799
+ git add src/repo-registry.test.ts config.example.json
800
+ git commit -m "feat: integration tests and updated config example for auto-discovery"
801
+ ```
802
+
803
+ ---
804
+
805
+ ### Task 7: Final verification and docs
806
+
807
+ **Step 1: Run full test suite**
808
+
809
+ Run: `bun test`
810
+ Expected: All tests PASS
811
+
812
+ **Step 2: Manual smoke test**
813
+
814
+ Run: `bun run src/index.ts`
815
+ Expected:
816
+ - Ove starts without errors
817
+ - If `github` config is set, logs `github sync complete`
818
+ - Config repos appear in registry
819
+ - Wildcard user can target any discovered repo
820
+
821
+ **Step 3: Commit everything and push**
822
+
823
+ ```bash
824
+ git add -A
825
+ git commit -m "feat: repo auto-discovery via GitHub sync with Claude resolution"
826
+ ```