edsger 0.72.6 → 0.74.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 (33) hide show
  1. package/dist/api/intelligence-normalize.d.ts +25 -0
  2. package/dist/api/intelligence-normalize.js +103 -0
  3. package/dist/api/intelligence.js +113 -10
  4. package/dist/commands/features/index.d.ts +15 -0
  5. package/dist/commands/features/index.js +34 -0
  6. package/dist/commands/pr-resolve/index.d.ts +3 -1
  7. package/dist/commands/pr-resolve/index.js +12 -7
  8. package/dist/commands/pr-review/index.d.ts +3 -1
  9. package/dist/commands/pr-review/index.js +10 -6
  10. package/dist/commands/sync-github-pull-requests/index.d.ts +11 -0
  11. package/dist/commands/sync-github-pull-requests/index.js +42 -0
  12. package/dist/index.js +50 -4
  13. package/dist/phases/features/index.d.ts +65 -0
  14. package/dist/phases/features/index.js +292 -0
  15. package/dist/phases/features/mcp-server.d.ts +61 -0
  16. package/dist/phases/features/mcp-server.js +165 -0
  17. package/dist/phases/features/prompts.d.ts +32 -0
  18. package/dist/phases/features/prompts.js +92 -0
  19. package/dist/phases/features/types.d.ts +34 -0
  20. package/dist/phases/features/types.js +15 -0
  21. package/dist/phases/pr-resolve/index.d.ts +3 -1
  22. package/dist/phases/pr-resolve/index.js +12 -12
  23. package/dist/phases/pr-review/index.d.ts +3 -1
  24. package/dist/phases/pr-review/index.js +13 -16
  25. package/dist/phases/pr-shared/status.d.ts +18 -0
  26. package/dist/phases/pr-shared/status.js +37 -0
  27. package/dist/phases/sync-github-pull-requests/index.d.ts +23 -0
  28. package/dist/phases/sync-github-pull-requests/index.js +210 -0
  29. package/dist/phases/sync-github-pull-requests/state.d.ts +24 -0
  30. package/dist/phases/sync-github-pull-requests/state.js +16 -0
  31. package/dist/phases/sync-github-pull-requests/types.d.ts +22 -0
  32. package/dist/phases/sync-github-pull-requests/types.js +1 -0
  33. package/package.json +3 -3
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Report payload normalization for the direct-SDK write path.
3
+ *
4
+ * The LLM that produces intelligence reports sometimes returns structured
5
+ * arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
6
+ * keyed under synonyms (`description` instead of `finding`, `recommendation`
7
+ * instead of `action`, …). We canonicalize before writing so every reader — the
8
+ * desktop UI especially — can rely on the shape declared in the migration.
9
+ *
10
+ * This mirrors the normalizer in the MCP edge handler
11
+ * (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
12
+ * single module because the edge function runs on Deno (zod v3, URL imports)
13
+ * while this runs on Node — keep them in sync if either changes.
14
+ */
15
+ /**
16
+ * Normalize every structured array field present in the payload. Fields that
17
+ * are `undefined` are left untouched so partial updates don't overwrite stored
18
+ * values with empty arrays.
19
+ */
20
+ export declare function normalizeReportArrays<T extends Record<string, unknown>>(payload: T): T;
21
+ /**
22
+ * Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
23
+ * numeric strings, and null/garbage uniformly.
24
+ */
25
+ export declare function toNumberOrNull(value: unknown): number | null;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Report payload normalization for the direct-SDK write path.
3
+ *
4
+ * The LLM that produces intelligence reports sometimes returns structured
5
+ * arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
6
+ * keyed under synonyms (`description` instead of `finding`, `recommendation`
7
+ * instead of `action`, …). We canonicalize before writing so every reader — the
8
+ * desktop UI especially — can rely on the shape declared in the migration.
9
+ *
10
+ * This mirrors the normalizer in the MCP edge handler
11
+ * (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
12
+ * single module because the edge function runs on Deno (zod v3, URL imports)
13
+ * while this runs on Node — keep them in sync if either changes.
14
+ */
15
+ const NORMALIZE_SPECS = {
16
+ key_findings: {
17
+ primary: 'finding',
18
+ aliases: ['description', 'insight', 'text', 'title'],
19
+ defaults: { severity: 'fyi', category: '' },
20
+ },
21
+ recommendations: {
22
+ primary: 'action',
23
+ aliases: ['recommendation', 'description', 'text', 'title'],
24
+ defaults: { priority: 'medium', effort: '', impact: '', rationale: '' },
25
+ },
26
+ competitor_highlights: {
27
+ primary: 'highlight',
28
+ aliases: ['description', 'text', 'title'],
29
+ defaults: { competitor_name: '', sentiment: 'neutral' },
30
+ },
31
+ market_signals: {
32
+ primary: 'signal',
33
+ aliases: ['description', 'text', 'title'],
34
+ defaults: { source: '', relevance: '', url: '' },
35
+ },
36
+ trends: {
37
+ primary: 'trend',
38
+ aliases: ['description', 'text', 'title'],
39
+ defaults: { direction: '', evidence: '' },
40
+ },
41
+ };
42
+ function normalizeItem(item, spec) {
43
+ if (typeof item === 'string') {
44
+ return { [spec.primary]: item, ...spec.defaults };
45
+ }
46
+ if (!item || typeof item !== 'object') {
47
+ return { [spec.primary]: '', ...spec.defaults };
48
+ }
49
+ const obj = { ...item };
50
+ if (typeof obj[spec.primary] !== 'string' || !obj[spec.primary]) {
51
+ for (const alias of spec.aliases) {
52
+ const v = obj[alias];
53
+ if (typeof v === 'string' && v.trim().length > 0) {
54
+ obj[spec.primary] = v;
55
+ break;
56
+ }
57
+ }
58
+ if (typeof obj[spec.primary] !== 'string') {
59
+ obj[spec.primary] = '';
60
+ }
61
+ }
62
+ for (const [key, fallback] of Object.entries(spec.defaults)) {
63
+ if (obj[key] === undefined || obj[key] === null) {
64
+ obj[key] = fallback;
65
+ }
66
+ }
67
+ return obj;
68
+ }
69
+ function normalizeArray(value, spec) {
70
+ if (!Array.isArray(value)) {
71
+ return [];
72
+ }
73
+ return value.map((item) => normalizeItem(item, spec));
74
+ }
75
+ /**
76
+ * Normalize every structured array field present in the payload. Fields that
77
+ * are `undefined` are left untouched so partial updates don't overwrite stored
78
+ * values with empty arrays.
79
+ */
80
+ export function normalizeReportArrays(payload) {
81
+ const result = { ...payload };
82
+ for (const [field, spec] of Object.entries(NORMALIZE_SPECS)) {
83
+ if (result[field] !== undefined) {
84
+ ;
85
+ result[field] = normalizeArray(result[field], spec);
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
92
+ * numeric strings, and null/garbage uniformly.
93
+ */
94
+ export function toNumberOrNull(value) {
95
+ if (typeof value === 'number') {
96
+ return Number.isFinite(value) ? value : null;
97
+ }
98
+ if (typeof value === 'string' && value.trim() !== '') {
99
+ const n = Number(value);
100
+ return Number.isFinite(n) ? n : null;
101
+ }
102
+ return null;
103
+ }
@@ -1,5 +1,6 @@
1
- import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
1
+ import { ensureSupabaseSession, getSupabase, hasSupabaseSession, } from '../supabase/client.js';
2
2
  import { logError, logInfo } from '../utils/logger.js';
3
+ import { normalizeReportArrays, toNumberOrNull, } from './intelligence-normalize.js';
3
4
  import { callMcpEndpoint } from './mcp-client.js';
4
5
  // =============================================================================
5
6
  // MCP response helper
@@ -180,8 +181,41 @@ export async function saveSnapshot(snapshot, verbose) {
180
181
  logInfo(`Saving snapshot for competitor: ${snapshot.competitor_id}`);
181
182
  }
182
183
  try {
183
- const result = await callMcpEndpoint('intelligence/snapshots/save', snapshot);
184
- return parseMcpResponse(result, null);
184
+ // Direct SDK write under the user's RLS session — no MCP fallback.
185
+ // Await the synced session first so the insert carries the user JWT
186
+ // (otherwise it races out as `anon` and trips RLS).
187
+ const ready = await ensureSupabaseSession();
188
+ if (!ready) {
189
+ logError('Failed to save snapshot: no Supabase session (sign in to the desktop app)');
190
+ return null;
191
+ }
192
+ const source = ['ai_analysis', 'manual', 'scheduled'].includes(snapshot.source)
193
+ ? snapshot.source
194
+ : 'ai_analysis';
195
+ const { data, error } = await getSupabase()
196
+ .from('competitor_snapshots')
197
+ .insert({
198
+ competitor_id: snapshot.competitor_id,
199
+ product_id: snapshot.product_id,
200
+ issues: snapshot.issues ?? [],
201
+ pricing: snapshot.pricing ?? {},
202
+ tech_stack: snapshot.tech_stack ?? [],
203
+ app_rating: toNumberOrNull(snapshot.app_rating),
204
+ app_review_count: toNumberOrNull(snapshot.app_review_count),
205
+ app_version: snapshot.app_version ?? null,
206
+ app_last_updated: snapshot.app_last_updated ?? null,
207
+ recent_reviews: snapshot.recent_reviews ?? [],
208
+ social_mentions: snapshot.social_mentions ?? {},
209
+ changes_detected: snapshot.changes_detected ?? [],
210
+ source,
211
+ raw_data: snapshot.raw_data ?? null,
212
+ })
213
+ .select()
214
+ .single();
215
+ if (error) {
216
+ throw new Error(error.message);
217
+ }
218
+ return data ?? null;
185
219
  }
186
220
  catch (error) {
187
221
  logError(`Failed to save snapshot: ${error instanceof Error ? error.message : String(error)}`);
@@ -273,8 +307,54 @@ export async function saveReport(report, verbose) {
273
307
  logInfo(`Saving intelligence report: ${report.title}`);
274
308
  }
275
309
  try {
276
- const result = await callMcpEndpoint('intelligence/reports/save', report);
277
- return parseMcpResponse(result, null);
310
+ // Direct SDK write no MCP fallback. intelligence_reports.created_by is
311
+ // NOT NULL and RLS checks `created_by = auth.uid()`, so we resolve the
312
+ // user from the synced session and canonicalize the LLM arrays first.
313
+ const ready = await ensureSupabaseSession();
314
+ if (!ready) {
315
+ logError('Failed to save report: no Supabase session (sign in to the desktop app)');
316
+ return null;
317
+ }
318
+ const { data: sessionData } = await getSupabase().auth.getSession();
319
+ const userId = sessionData.session?.user?.id;
320
+ if (!userId) {
321
+ logError('Failed to save report: could not resolve user from session');
322
+ return null;
323
+ }
324
+ const normalized = normalizeReportArrays({
325
+ key_findings: report.key_findings ?? [],
326
+ recommendations: report.recommendations ?? [],
327
+ competitor_highlights: report.competitor_highlights ?? [],
328
+ market_signals: report.market_signals ?? [],
329
+ trends: report.trends ?? [],
330
+ });
331
+ const { data, error } = await getSupabase()
332
+ .from('intelligence_reports')
333
+ .insert({
334
+ product_id: report.product_id,
335
+ report_type: report.report_type,
336
+ title: report.title,
337
+ summary: report.summary ?? null,
338
+ full_report: report.full_report ?? null,
339
+ key_findings: normalized.key_findings,
340
+ recommendations: normalized.recommendations,
341
+ competitor_highlights: normalized.competitor_highlights,
342
+ market_signals: normalized.market_signals,
343
+ trends: normalized.trends,
344
+ persona_updates: report.persona_updates ?? null,
345
+ pmf_score: toNumberOrNull(report.pmf_score),
346
+ pmf_signals: report.pmf_signals ?? null,
347
+ guidance: report.guidance ?? null,
348
+ status: report.status ?? 'completed',
349
+ snapshot_ids: report.snapshot_ids ?? [],
350
+ created_by: userId,
351
+ })
352
+ .select()
353
+ .single();
354
+ if (error) {
355
+ throw new Error(error.message);
356
+ }
357
+ return data ?? null;
278
358
  }
279
359
  catch (error) {
280
360
  logError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
@@ -286,11 +366,34 @@ export async function updateReport(reportId, updates, verbose) {
286
366
  logInfo(`Updating report: ${reportId}`);
287
367
  }
288
368
  try {
289
- const result = await callMcpEndpoint('intelligence/reports/update', {
290
- report_id: reportId,
291
- ...updates,
292
- });
293
- return parseMcpResponse(result, null);
369
+ // Direct SDK write no MCP fallback.
370
+ const ready = await ensureSupabaseSession();
371
+ if (!ready) {
372
+ logError('Failed to update report: no Supabase session (sign in to the desktop app)');
373
+ return null;
374
+ }
375
+ // Only send keys the caller provided so a partial update doesn't clobber
376
+ // stored columns. Numeric/array fields are coerced/canonicalized.
377
+ const payload = {};
378
+ for (const [key, value] of Object.entries(updates)) {
379
+ if (value !== undefined) {
380
+ payload[key] = value;
381
+ }
382
+ }
383
+ if ('pmf_score' in payload) {
384
+ payload.pmf_score = toNumberOrNull(payload.pmf_score);
385
+ }
386
+ const normalized = normalizeReportArrays(payload);
387
+ const { data, error } = await getSupabase()
388
+ .from('intelligence_reports')
389
+ .update(normalized)
390
+ .eq('id', reportId)
391
+ .select()
392
+ .single();
393
+ if (error) {
394
+ throw new Error(error.message);
395
+ }
396
+ return data ?? null;
294
397
  }
295
398
  catch (error) {
296
399
  logError(`Failed to update report: ${error instanceof Error ? error.message : String(error)}`);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * CLI command: edsger features <productId> --scan-id <id>
3
+ *
4
+ * Clones every repository linked to the product, asks Claude to catalogue
5
+ * the user-facing features it delivers, and persists them via the
6
+ * product_features table. The desktop UI creates a pending feature_scans
7
+ * row first then invokes the CLI with --scan-id; the CLI flips status
8
+ * running → success/failed and writes features via the MCP toolkit.
9
+ */
10
+ export interface FeaturesCliOptions {
11
+ scanId: string;
12
+ guidance?: string;
13
+ verbose?: boolean;
14
+ }
15
+ export declare function runFeatures(productId: string, options: FeaturesCliOptions): Promise<void>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * CLI command: edsger features <productId> --scan-id <id>
3
+ *
4
+ * Clones every repository linked to the product, asks Claude to catalogue
5
+ * the user-facing features it delivers, and persists them via the
6
+ * product_features table. The desktop UI creates a pending feature_scans
7
+ * row first then invokes the CLI with --scan-id; the CLI flips status
8
+ * running → success/failed and writes features via the MCP toolkit.
9
+ */
10
+ import { runFeaturesPhase } from '../../phases/features/index.js';
11
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
12
+ export async function runFeatures(productId, options) {
13
+ const { scanId, guidance, verbose } = options;
14
+ if (!productId) {
15
+ throw new Error('A product ID is required for features');
16
+ }
17
+ if (!scanId) {
18
+ throw new Error('--scan-id is required (the pending feature_scans row id)');
19
+ }
20
+ logInfo(`Starting features scan for product ${productId}`);
21
+ const result = await runFeaturesPhase({
22
+ productId,
23
+ scanId,
24
+ guidance,
25
+ verbose,
26
+ });
27
+ if (result.status === 'success') {
28
+ logSuccess(result.message);
29
+ }
30
+ else {
31
+ logError(result.message);
32
+ process.exit(1);
33
+ }
34
+ }
@@ -5,7 +5,9 @@
5
5
  export interface PRResolveCliOptions {
6
6
  prUrl: string;
7
7
  prId?: string;
8
+ /** Repo-only mode: resolve GitHub config from a repositories row. */
9
+ repoId?: string;
8
10
  verbose?: boolean;
9
11
  learn?: boolean;
10
12
  }
11
- export declare function runPRResolve(productId: string, options: PRResolveCliOptions): Promise<void>;
13
+ export declare function runPRResolve(productId: string | undefined, options: PRResolveCliOptions): Promise<void>;
@@ -2,31 +2,36 @@
2
2
  * CLI command: edsger pr-resolve <productId> --pr-url <url>
3
3
  * Resolves PR change requests: fixes what's genuinely better, explains what it won't change.
4
4
  */
5
- import { getGitHubConfigByProduct } from '../../api/github.js';
5
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
6
6
  import { resolveStandalonePR } from '../../phases/pr-resolve/index.js';
7
7
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
8
8
  export async function runPRResolve(productId, options) {
9
- const { prUrl, prId, verbose } = options;
10
- logInfo(`Starting PR resolve for product ${productId}`);
9
+ const { prUrl, prId, repoId, verbose } = options;
10
+ const scopeLabel = repoId ? `repository ${repoId}` : `product ${productId}`;
11
+ logInfo(`Starting PR resolve for ${scopeLabel}`);
11
12
  logInfo(`PR URL: ${prUrl}`);
12
- // Get GitHub config via product developer settings
13
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
13
+ // Resolve GitHub config from the repository (repo-only mode) or the product.
14
+ const githubConfig = repoId
15
+ ? await getGitHubConfigByRepository(repoId, verbose)
16
+ : await getGitHubConfigByProduct(productId ?? '', verbose);
14
17
  if (!githubConfig.configured ||
15
18
  !githubConfig.token ||
16
19
  !githubConfig.owner ||
17
20
  !githubConfig.repo) {
18
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
21
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
19
22
  process.exit(1);
20
23
  }
21
24
  const result = await resolveStandalonePR({
22
25
  productId,
26
+ repositoryId: repoId,
23
27
  pullRequestUrl: prUrl,
24
28
  githubToken: githubConfig.token,
25
29
  owner: githubConfig.owner,
26
30
  repo: githubConfig.repo,
27
31
  prId,
28
32
  verbose,
29
- learn: options.learn,
33
+ // Checklist learning is product-scoped; skip it in repo-only mode.
34
+ learn: repoId ? false : options.learn,
30
35
  });
31
36
  if (result.status === 'success') {
32
37
  logSuccess(`PR resolve completed: ${result.message}`);
@@ -5,6 +5,8 @@
5
5
  export interface PRReviewCliOptions {
6
6
  prUrl: string;
7
7
  prId?: string;
8
+ /** Repo-only mode: resolve GitHub config from a repositories row. */
9
+ repoId?: string;
8
10
  verbose?: boolean;
9
11
  }
10
- export declare function runPRReview(productId: string, options: PRReviewCliOptions): Promise<void>;
12
+ export declare function runPRReview(productId: string | undefined, options: PRReviewCliOptions): Promise<void>;
@@ -2,24 +2,28 @@
2
2
  * CLI command: edsger pr-review <productId> --pr-url <url>
3
3
  * Reviews a standalone GitHub PR and posts comments.
4
4
  */
5
- import { getGitHubConfigByProduct } from '../../api/github.js';
5
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
6
6
  import { reviewStandalonePR } from '../../phases/pr-review/index.js';
7
7
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
8
8
  export async function runPRReview(productId, options) {
9
- const { prUrl, prId, verbose } = options;
10
- logInfo(`Starting PR review for product ${productId}`);
9
+ const { prUrl, prId, repoId, verbose } = options;
10
+ const scopeLabel = repoId ? `repository ${repoId}` : `product ${productId}`;
11
+ logInfo(`Starting PR review for ${scopeLabel}`);
11
12
  logInfo(`PR URL: ${prUrl}`);
12
- // Get GitHub config via product developer settings
13
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
13
+ // Resolve GitHub config from the repository (repo-only mode) or the product.
14
+ const githubConfig = repoId
15
+ ? await getGitHubConfigByRepository(repoId, verbose)
16
+ : await getGitHubConfigByProduct(productId ?? '', verbose);
14
17
  if (!githubConfig.configured ||
15
18
  !githubConfig.token ||
16
19
  !githubConfig.owner ||
17
20
  !githubConfig.repo) {
18
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
21
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
19
22
  process.exit(1);
20
23
  }
21
24
  const result = await reviewStandalonePR({
22
25
  productId,
26
+ repositoryId: repoId,
23
27
  pullRequestUrl: prUrl,
24
28
  githubToken: githubConfig.token,
25
29
  owner: githubConfig.owner,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * CLI command: `edsger sync-github-pull-requests <repoId>`
3
+ *
4
+ * Mirrors a connected GitHub repo's pull requests into the local
5
+ * `pull_requests` table (scoped by repository_id) for the repo detail page.
6
+ * Idempotent — already-synced PRs are refreshed, never duplicated.
7
+ */
8
+ export interface SyncGithubPullRequestsCliOptions {
9
+ verbose?: boolean;
10
+ }
11
+ export declare function runSyncGithubPullRequests(repositoryId: string, options?: SyncGithubPullRequestsCliOptions): Promise<void>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CLI command: `edsger sync-github-pull-requests <repoId>`
3
+ *
4
+ * Mirrors a connected GitHub repo's pull requests into the local
5
+ * `pull_requests` table (scoped by repository_id) for the repo detail page.
6
+ * Idempotent — already-synced PRs are refreshed, never duplicated.
7
+ */
8
+ import { getGitHubConfigByRepository } from '../../api/github.js';
9
+ import { syncGithubPullRequests } from '../../phases/sync-github-pull-requests/index.js';
10
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
11
+ export async function runSyncGithubPullRequests(repositoryId, options = {}) {
12
+ const { verbose } = options;
13
+ logInfo(`Starting GitHub pull request sync for repository ${repositoryId}`);
14
+ const githubConfig = await getGitHubConfigByRepository(repositoryId, verbose);
15
+ if (!githubConfig.configured ||
16
+ !githubConfig.token ||
17
+ !githubConfig.owner ||
18
+ !githubConfig.repo) {
19
+ logError(`GitHub not configured for repository ${repositoryId}: ${githubConfig.message || 'No installation found'}`);
20
+ process.exit(1);
21
+ }
22
+ const result = await syncGithubPullRequests({
23
+ repositoryId,
24
+ githubToken: githubConfig.token,
25
+ owner: githubConfig.owner,
26
+ repo: githubConfig.repo,
27
+ verbose,
28
+ });
29
+ if (result.status === 'success') {
30
+ logSuccess(`GitHub PR sync completed: ${result.message}`);
31
+ if (result.repository) {
32
+ logInfo(`Repository: ${result.repository}`);
33
+ }
34
+ if (result.fetchedCount !== undefined) {
35
+ logInfo(`Fetched ${result.fetchedCount} · created ${result.createdCount ?? 0} · updated ${result.updatedCount ?? 0}`);
36
+ }
37
+ }
38
+ else {
39
+ logError(`GitHub PR sync failed: ${result.message}`);
40
+ process.exit(1);
41
+ }
42
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './co
19
19
  import { runDataFlow } from './commands/data-flow/index.js';
20
20
  import { runDiscover } from './commands/discover/index.js';
21
21
  import { runErDiagram } from './commands/er-diagram/index.js';
22
+ import { runFeatures } from './commands/features/index.js';
22
23
  import { runFinancingDeck } from './commands/financing-deck/index.js';
23
24
  import { runFindArchitecture } from './commands/find-architecture/index.js';
24
25
  import { runFindBugs } from './commands/find-bugs/index.js';
@@ -46,6 +47,7 @@ import { runStateDiagram } from './commands/state-diagram/index.js';
46
47
  import { runSyncAws } from './commands/sync-aws/index.js';
47
48
  import { runSyncDatadog } from './commands/sync-datadog/index.js';
48
49
  import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
50
+ import { runSyncGithubPullRequests } from './commands/sync-github-pull-requests/index.js';
49
51
  import { runSyncOrgRepos } from './commands/sync-org-repos/index.js';
50
52
  import { runSyncSentryIssues } from './commands/sync-sentry-issues/index.js';
51
53
  import { runSyncTerraform } from './commands/sync-terraform/index.js';
@@ -763,13 +765,17 @@ program
763
765
  // Subcommand: edsger pr-review <productId>
764
766
  // ============================================================
765
767
  program
766
- .command('pr-review <productId>')
767
- .description('AI-review a GitHub PR for a product')
768
+ .command('pr-review [productId]')
769
+ .description('AI-review a GitHub PR for a product (or standalone repository)')
768
770
  .requiredOption('--pr-url <url>', 'GitHub PR URL')
769
771
  .option('--pr-id <id>', 'Pull request record ID in database')
772
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
770
773
  .option('-v, --verbose', 'Verbose output')
771
774
  .action(async (productId, opts) => {
772
775
  try {
776
+ if (!productId && !opts.repoId) {
777
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for pr-review');
778
+ }
773
779
  await runPRReview(productId, opts);
774
780
  }
775
781
  catch (error) {
@@ -842,6 +848,21 @@ program
842
848
  }
843
849
  });
844
850
  // ============================================================
851
+ // Subcommand: edsger sync-github-pull-requests <repoId>
852
+ // ============================================================
853
+ program
854
+ .command('sync-github-pull-requests <repoId>')
855
+ .description("Mirror a connected GitHub repo's pull requests into the local pull_requests list for the repo detail page (idempotent — refreshes, never duplicates)")
856
+ .option('-v, --verbose', 'Verbose output')
857
+ .action(async (repoId, opts) => {
858
+ try {
859
+ await runSyncGithubPullRequests(repoId, opts);
860
+ }
861
+ catch (error) {
862
+ exitWithError(error);
863
+ }
864
+ });
865
+ // ============================================================
845
866
  // Subcommand: edsger sync-org-repos <teamId>
846
867
  // ============================================================
847
868
  program
@@ -1081,17 +1102,42 @@ program
1081
1102
  }
1082
1103
  });
1083
1104
  // ============================================================
1105
+ // Subcommand: edsger features <productId>
1106
+ // ============================================================
1107
+ program
1108
+ .command('features <productId>')
1109
+ .description('Scan every repository linked to a product for the user-facing features it delivers and persist them via the product_features table. Manually defined features are enriched, never rewritten. Writes against the pending feature_scans row identified by --scan-id.')
1110
+ .requiredOption('--scan-id <id>', 'Pending feature_scans row id to drive (created by the desktop UI before invocation)')
1111
+ .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
1112
+ .option('-v, --verbose', 'Verbose output')
1113
+ .action(async (productId, opts) => {
1114
+ try {
1115
+ await runFeatures(productId, {
1116
+ scanId: opts.scanId,
1117
+ guidance: opts.guidance,
1118
+ verbose: opts.verbose,
1119
+ });
1120
+ }
1121
+ catch (error) {
1122
+ exitWithError(error);
1123
+ }
1124
+ });
1125
+ // ============================================================
1084
1126
  // Subcommand: edsger pr-resolve <productId>
1085
1127
  // ============================================================
1086
1128
  program
1087
- .command('pr-resolve <productId>')
1088
- .description('AI-resolve change requests on a GitHub PR')
1129
+ .command('pr-resolve [productId]')
1130
+ .description('AI-resolve change requests on a GitHub PR for a product (or standalone repository)')
1089
1131
  .requiredOption('--pr-url <url>', 'GitHub PR URL')
1090
1132
  .option('--pr-id <id>', 'Pull request record ID in database')
1133
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1091
1134
  .option('--no-learn', 'Skip checklist learning after resolve')
1092
1135
  .option('-v, --verbose', 'Verbose output')
1093
1136
  .action(async (productId, opts) => {
1094
1137
  try {
1138
+ if (!productId && !opts.repoId) {
1139
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for pr-resolve');
1140
+ }
1095
1141
  await runPRResolve(productId, opts);
1096
1142
  }
1097
1143
  catch (error) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Features phase: clone EVERY repository linked to the product
3
+ * (product_repositories), ask Claude to catalogue the user-facing features
4
+ * the product delivers, and persist them via the product_features table.
5
+ *
6
+ * Multi-repo: unlike recipes (single primary repo), features are discovered
7
+ * across the whole product. Repo resolution + cloning is shared with the
8
+ * diagram phases (cloneDiagramRepos): each repo is cloned into its own
9
+ * subdirectory of a per-product parent dir, and the agent runs in the
10
+ * parent so it can explore all of them in one pass.
11
+ *
12
+ * Production-grade behaviours layered on top of the basic agent loop
13
+ * (mirrors the recipes phase):
14
+ *
15
+ * - Heartbeat: `last_heartbeat_at` on the feature_scans row is refreshed
16
+ * on every assistant message so the reader can detect stalled / crashed
17
+ * runs (see desktop-app/.../services/db/feature-scans.ts for the lazy
18
+ * reaper).
19
+ * - Cancellation-safe writes: markRunning / markSuccess / markFailed only
20
+ * touch rows whose status is in {pending, running}. If the user clicked
21
+ * Stop and the row is now 'cancelled', the final write no-ops.
22
+ * - Per-call MCP writes: agent commits each create / update / remove as
23
+ * it goes. There is no "submit at the end" buffer — partial progress
24
+ * survives even if the agent later errors out.
25
+ */
26
+ import type { SupabaseClient } from '@supabase/supabase-js';
27
+ import { type ClonedRepo } from '../diagram-shared/clone-repos.js';
28
+ import type { FeatureSummary } from './types.js';
29
+ export interface FeaturesPhaseOptions {
30
+ productId: string;
31
+ scanId: string;
32
+ guidance?: string;
33
+ verbose?: boolean;
34
+ }
35
+ export interface FeaturesPhaseResult {
36
+ status: 'success' | 'error' | 'cancelled';
37
+ message: string;
38
+ counts?: {
39
+ created: number;
40
+ updated: number;
41
+ removed: number;
42
+ };
43
+ }
44
+ /**
45
+ * Repo-scope note for the agent's user prompt. Unlike the diagram phases'
46
+ * describeRepoScope (which asks for one unified flow), this tells the agent
47
+ * the exact full names it may use in the `repos` field of each feature.
48
+ */
49
+ export declare function describeFeatureRepoScope(repos: ClonedRepo[]): string;
50
+ export declare function runFeaturesPhase(options: FeaturesPhaseOptions): Promise<FeaturesPhaseResult>;
51
+ export declare function listProductRepositoryIds(supabase: SupabaseClient, productId: string): Promise<string[]>;
52
+ export declare function getScanCreator(supabase: SupabaseClient, scanId: string): Promise<{
53
+ created_by: string;
54
+ } | null>;
55
+ export declare function listProductFeatures(supabase: SupabaseClient, productId: string): Promise<FeatureSummary[]>;
56
+ /**
57
+ * Claim the row by flipping `pending` → `running`. Returns true on success
58
+ * (we won the claim) and false when the row has already moved on (e.g. user
59
+ * cancelled before the CLI started). Bounded by the status filter so we
60
+ * can't accidentally resurrect a 'cancelled' row.
61
+ */
62
+ export declare function markRunning(supabase: SupabaseClient, scanId: string): Promise<boolean>;
63
+ export declare function heartbeat(supabase: SupabaseClient, scanId: string): Promise<void>;
64
+ export declare function markFailed(supabase: SupabaseClient, scanId: string, errorMessage: string): Promise<boolean>;
65
+ export declare function markSuccess(supabase: SupabaseClient, scanId: string): Promise<boolean>;