edsger 0.67.0 → 0.69.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.
@@ -50,6 +50,29 @@ export declare function getGitHubConfigByProduct(productId: string, verbose?: bo
50
50
  repo?: string;
51
51
  message?: string;
52
52
  }>;
53
+ export interface RepositoryBasics {
54
+ teamId: string | null;
55
+ fullName: string | null;
56
+ description: string | null;
57
+ }
58
+ /**
59
+ * Fetch a repository row's team_id, full_name and description (RLS-scoped).
60
+ * Used by repo-scoped analysis to resolve the team (recipes are team-scoped)
61
+ * and to build prompt "product basics" from the repo instead of a product.
62
+ */
63
+ export declare function getRepositoryBasics(repositoryId: string): Promise<RepositoryBasics>;
64
+ /**
65
+ * Get GitHub config and token by repository ID (no product / issue required).
66
+ * Used for repo-scoped analysis (quality-benchmark / recipes / flows running
67
+ * against a single `repositories` row with no product context).
68
+ */
69
+ export declare function getGitHubConfigByRepository(repositoryId: string, verbose?: boolean): Promise<{
70
+ configured: boolean;
71
+ token?: string;
72
+ owner?: string;
73
+ repo?: string;
74
+ message?: string;
75
+ }>;
53
76
  /**
54
77
  * Get GitHub config and token in one call
55
78
  * This is the main entry point for getting GitHub config
@@ -2,6 +2,7 @@
2
2
  * GitHub API client for accessing GitHub via MCP server
3
3
  * Uses product developer configuration from the database
4
4
  */
5
+ import { getSupabase } from '../supabase/client.js';
5
6
  import { logDebug, logError, logSuccess, logWarning } from '../utils/logger.js';
6
7
  import { callMcpEndpoint } from './mcp-client.js';
7
8
  /**
@@ -98,6 +99,66 @@ export async function getGitHubConfigByProduct(productId, verbose) {
98
99
  };
99
100
  }
100
101
  }
102
+ /**
103
+ * Fetch a repository row's team_id, full_name and description (RLS-scoped).
104
+ * Used by repo-scoped analysis to resolve the team (recipes are team-scoped)
105
+ * and to build prompt "product basics" from the repo instead of a product.
106
+ */
107
+ export async function getRepositoryBasics(repositoryId) {
108
+ const { data } = await getSupabase()
109
+ .from('repositories')
110
+ .select('team_id, full_name, description')
111
+ .eq('id', repositoryId)
112
+ .maybeSingle();
113
+ return {
114
+ teamId: data?.team_id ?? null,
115
+ fullName: data?.full_name ?? null,
116
+ description: data?.description ?? null,
117
+ };
118
+ }
119
+ /**
120
+ * Get GitHub config and token by repository ID (no product / issue required).
121
+ * Used for repo-scoped analysis (quality-benchmark / recipes / flows running
122
+ * against a single `repositories` row with no product context).
123
+ */
124
+ export async function getGitHubConfigByRepository(repositoryId, verbose) {
125
+ logDebug(`Fetching GitHub config for repository: ${repositoryId}`, verbose);
126
+ try {
127
+ const result = (await callMcpEndpoint('github/config_and_token_by_repository', {
128
+ repository_id: repositoryId,
129
+ }));
130
+ if (verbose) {
131
+ if (result.configured) {
132
+ logSuccess(`GitHub ready: ${result.repository_full_name}`);
133
+ }
134
+ else {
135
+ logWarning(`GitHub not configured: ${result.message}`);
136
+ }
137
+ }
138
+ if (result.configured && result.token && result.owner && result.repo) {
139
+ return {
140
+ configured: true,
141
+ token: result.token,
142
+ owner: result.owner,
143
+ repo: result.repo,
144
+ };
145
+ }
146
+ return {
147
+ configured: false,
148
+ message: result.message ||
149
+ 'GitHub not configured for this repository. Connect the repo first.',
150
+ };
151
+ }
152
+ catch (error) {
153
+ if (verbose) {
154
+ logError(`Failed to get GitHub config for repository: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ return {
157
+ configured: false,
158
+ message: error instanceof Error ? error.message : 'Failed to get GitHub config',
159
+ };
160
+ }
161
+ }
101
162
  /**
102
163
  * Get GitHub config and token in one call
103
164
  * This is the main entry point for getting GitHub config
@@ -11,7 +11,9 @@
11
11
  */
12
12
  export interface DataFlowCliOptions {
13
13
  flowId: string;
14
+ /** Repo-only mode: generate against a single repositories row, no product. */
15
+ repoId?: string;
14
16
  guidance?: string;
15
17
  verbose?: boolean;
16
18
  }
17
- export declare function runDataFlow(productId: string, options: DataFlowCliOptions): Promise<void>;
19
+ export declare function runDataFlow(productId: string | undefined, options: DataFlowCliOptions): Promise<void>;
@@ -13,18 +13,27 @@ import { runDataFlowPhase } from '../../phases/data-flow/index.js';
13
13
  import { deregisterSession, registerSession, } from '../../system/session-manager.js';
14
14
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
15
15
  export async function runDataFlow(productId, options) {
16
- const { flowId, guidance, verbose } = options;
17
- if (!productId) {
18
- throw new Error('Product ID is required for data-flow');
16
+ const { flowId, repoId, guidance, verbose } = options;
17
+ if (!productId && !repoId) {
18
+ throw new Error('Either a product ID or --repo-id is required for data-flow');
19
19
  }
20
20
  if (!flowId) {
21
21
  throw new Error('--flow-id is required (the pending flows row id)');
22
22
  }
23
- await registerSession({ command: 'data-flow', productId });
24
- logInfo(`Starting data flow generation for product ${productId}`);
23
+ await registerSession({
24
+ command: 'data-flow',
25
+ ...(productId ? { productId } : {}),
26
+ });
27
+ if (productId) {
28
+ logInfo(`Starting data flow generation for product ${productId}`);
29
+ }
30
+ else {
31
+ logInfo(`Starting data flow generation for repository ${repoId}`);
32
+ }
25
33
  try {
26
34
  const result = await runDataFlowPhase({
27
35
  productId,
36
+ repoId,
28
37
  flowId,
29
38
  guidance,
30
39
  verbose,
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { mkdirSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, resolve } from 'node:path';
28
- import { getGitHubConfigByProduct } from '../../api/github.js';
28
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
29
29
  import { callMcpEndpoint } from '../../api/mcp-client.js';
30
30
  import { fetchProductBasics } from '../../phases/find-shared/mcp.js';
31
31
  import { runQualityBenchmark, } from '../../phases/quality-benchmark/index.js';
@@ -34,15 +34,46 @@ import { getSupabase } from '../../supabase/client.js';
34
34
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
35
35
  export async function runQualityBenchmarkCli(productId, options) {
36
36
  const installEnabled = options.install !== false;
37
- logInfo(`Starting quality benchmark for product ${productId}`);
37
+ // Repo-only mode: no product context, benchmark a single repositories row.
38
+ const repoOnly = !productId && Boolean(options.repoId);
39
+ if (repoOnly) {
40
+ logInfo(`Starting quality benchmark for repository ${options.repoId}`);
41
+ }
42
+ else {
43
+ logInfo(`Starting quality benchmark for product ${productId}`);
44
+ }
38
45
  let repoRoot;
39
46
  let resolvedBranch;
40
47
  // Recorded on the report so it's scoped to the repo that was benchmarked.
41
48
  let repositoryId = options.repoId ?? null;
49
+ // Display name used on the prompt + report (repo full_name in repo-only mode).
50
+ let productName = productId;
42
51
  if (options.repo) {
43
52
  repoRoot = resolve(options.repo);
44
53
  resolvedBranch = options.branch;
45
54
  logInfo(`Repo (override): ${repoRoot}`);
55
+ if (repoOnly) {
56
+ const basics = await getRepositoryBasics(options.repoId).catch(() => null);
57
+ productName = basics?.fullName ?? options.repoId;
58
+ }
59
+ }
60
+ else if (repoOnly) {
61
+ const gh = await getGitHubConfigByRepository(options.repoId, options.verbose);
62
+ if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
63
+ logError(`Cannot run quality benchmark: ${gh.message ??
64
+ 'GitHub is not configured for this repository. Connect the repo first.'}`);
65
+ process.exit(1);
66
+ }
67
+ const ws = prepareQualityWorkspace({
68
+ owner: gh.owner,
69
+ repo: gh.repo,
70
+ token: gh.token,
71
+ verbose: options.verbose,
72
+ });
73
+ repoRoot = ws.repoPath;
74
+ resolvedBranch = options.branch ?? ws.branch;
75
+ productName = `${gh.owner}/${gh.repo}`;
76
+ logInfo(`Repo: ${repoRoot} (branch: ${resolvedBranch})`);
46
77
  }
47
78
  else {
48
79
  const gh = await getGitHubConfigByProduct(productId, options.verbose);
@@ -82,10 +113,12 @@ export async function runQualityBenchmarkCli(productId, options) {
82
113
  if (!installEnabled) {
83
114
  logWarning('Install consent NOT granted (--no-install). Missing tools will be marked unmeasured.');
84
115
  }
85
- const basics = await fetchProductBasics(productId).catch(() => ({
86
- name: productId,
87
- }));
88
- const productName = basics.name;
116
+ if (!repoOnly) {
117
+ const basics = await fetchProductBasics(productId).catch(() => ({
118
+ name: productId,
119
+ }));
120
+ productName = basics.name;
121
+ }
89
122
  const onProgress = (event) => {
90
123
  if (options.verbose) {
91
124
  logInfo(`[${event.phase}] ${event.message}`);
@@ -100,7 +133,7 @@ export async function runQualityBenchmarkCli(productId, options) {
100
133
  }
101
134
  };
102
135
  const outcome = await runQualityBenchmark({
103
- productId,
136
+ productId: repoOnly ? '' : productId,
104
137
  productName,
105
138
  repoRoot,
106
139
  branch: resolvedBranch,
@@ -121,7 +154,8 @@ export async function runQualityBenchmarkCli(productId, options) {
121
154
  resolve(process.cwd(), `quality-report-${outcome.commitSha.slice(0, 8)}.json`);
122
155
  const reportEnvelope = {
123
156
  run_id: outcome.runId,
124
- product_id: productId,
157
+ product_id: repoOnly ? null : productId,
158
+ repository_id: repositoryId,
125
159
  commit_sha: outcome.commitSha,
126
160
  branch: resolvedBranch ?? null,
127
161
  started_at: outcome.startedAt,
@@ -136,7 +170,7 @@ export async function runQualityBenchmarkCli(productId, options) {
136
170
  if (options.save !== false) {
137
171
  try {
138
172
  const saved = (await callMcpEndpoint('quality_reports/save', {
139
- product_id: productId,
173
+ product_id: repoOnly ? null : productId,
140
174
  repository_id: repositoryId,
141
175
  commit_sha: outcome.commitSha,
142
176
  rubric_version: outcome.report.rubric_version,
@@ -9,7 +9,9 @@
9
9
  */
10
10
  export interface RecipesCliOptions {
11
11
  scanId: string;
12
+ /** Repo-only mode: scan a single repositories row with no product context. */
13
+ repoId?: string;
12
14
  guidance?: string;
13
15
  verbose?: boolean;
14
16
  }
15
- export declare function runRecipes(productId: string, options: RecipesCliOptions): Promise<void>;
17
+ export declare function runRecipes(productId: string | undefined, options: RecipesCliOptions): Promise<void>;
@@ -10,16 +10,22 @@
10
10
  import { runRecipesPhase } from '../../phases/recipes/index.js';
11
11
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
12
12
  export async function runRecipes(productId, options) {
13
- const { scanId, guidance, verbose } = options;
14
- if (!productId) {
15
- throw new Error('Product ID is required for recipes');
13
+ const { scanId, repoId, guidance, verbose } = options;
14
+ if (!productId && !repoId) {
15
+ throw new Error('Either a product ID or --repo-id is required for recipes');
16
16
  }
17
17
  if (!scanId) {
18
18
  throw new Error('--scan-id is required (the pending recipe_scans row id)');
19
19
  }
20
- logInfo(`Starting recipes scan for product ${productId}`);
20
+ if (productId) {
21
+ logInfo(`Starting recipes scan for product ${productId}`);
22
+ }
23
+ else {
24
+ logInfo(`Starting recipes scan for repository ${repoId}`);
25
+ }
21
26
  const result = await runRecipesPhase({
22
27
  productId,
28
+ repoId,
23
29
  scanId,
24
30
  guidance,
25
31
  verbose,
@@ -10,7 +10,9 @@
10
10
  */
11
11
  export interface ScreenFlowCliOptions {
12
12
  flowId: string;
13
+ /** Repo-only mode: generate against a single repositories row, no product. */
14
+ repoId?: string;
13
15
  guidance?: string;
14
16
  verbose?: boolean;
15
17
  }
16
- export declare function runScreenFlow(productId: string, options: ScreenFlowCliOptions): Promise<void>;
18
+ export declare function runScreenFlow(productId: string | undefined, options: ScreenFlowCliOptions): Promise<void>;
@@ -12,18 +12,27 @@ import { runScreenFlowPhase } from '../../phases/screen-flow/index.js';
12
12
  import { deregisterSession, registerSession, } from '../../system/session-manager.js';
13
13
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
14
14
  export async function runScreenFlow(productId, options) {
15
- const { flowId, guidance, verbose } = options;
16
- if (!productId) {
17
- throw new Error('Product ID is required for screen-flow');
15
+ const { flowId, repoId, guidance, verbose } = options;
16
+ if (!productId && !repoId) {
17
+ throw new Error('Either a product ID or --repo-id is required for screen-flow');
18
18
  }
19
19
  if (!flowId) {
20
20
  throw new Error('--flow-id is required (the pending flows row id)');
21
21
  }
22
- await registerSession({ command: 'screen-flow', productId });
23
- logInfo(`Starting screen flow generation for product ${productId}`);
22
+ await registerSession({
23
+ command: 'screen-flow',
24
+ ...(productId ? { productId } : {}),
25
+ });
26
+ if (productId) {
27
+ logInfo(`Starting screen flow generation for product ${productId}`);
28
+ }
29
+ else {
30
+ logInfo(`Starting screen flow generation for repository ${repoId}`);
31
+ }
24
32
  try {
25
33
  const result = await runScreenFlowPhase({
26
34
  productId,
35
+ repoId,
27
36
  flowId,
28
37
  guidance,
29
38
  verbose,
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * CLI command: `edsger sync-org-repos <teamId>`
3
3
  *
4
- * Reads the team's configured github_org, fetches all repos from that org
5
- * via the local `gh` CLI, and upserts a repositories row for each repo.
4
+ * Reads the team's configured github_org and fetches all repos from that org
5
+ * server-side (via the Edsger GitHub App, falling back to the user's personal
6
+ * GitHub token), then upserts a repositories row for each repo.
6
7
  */
7
8
  export interface SyncOrgReposCliOptions {
8
9
  verbose?: boolean;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * CLI command: `edsger sync-org-repos <teamId>`
3
3
  *
4
- * Reads the team's configured github_org, fetches all repos from that org
5
- * via the local `gh` CLI, and upserts a repositories row for each repo.
4
+ * Reads the team's configured github_org and fetches all repos from that org
5
+ * server-side (via the Edsger GitHub App, falling back to the user's personal
6
+ * GitHub token), then upserts a repositories row for each repo.
6
7
  */
7
8
  import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
8
9
  import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
package/dist/index.js CHANGED
@@ -172,15 +172,20 @@ program
172
172
  // Subcommand: edsger screen-flow <productId>
173
173
  // ============================================================
174
174
  program
175
- .command('screen-flow <productId>')
176
- .description('Generate a structured screen flow (screens + transitions) for a product from its source code')
175
+ .command('screen-flow [productId]')
176
+ .description('Generate a structured screen flow (screens + transitions) for a product (or standalone repository) from its source code')
177
177
  .requiredOption('--flow-id <id>', 'Pending flows row id to populate')
178
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
178
179
  .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
179
180
  .option('-v, --verbose', 'Verbose output')
180
181
  .action(async (productId, opts) => {
181
182
  try {
183
+ if (!productId && !opts.repoId) {
184
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for screen-flow');
185
+ }
182
186
  await runScreenFlow(productId, {
183
187
  flowId: opts.flowId,
188
+ repoId: opts.repoId,
184
189
  guidance: opts.guidance,
185
190
  verbose: opts.verbose,
186
191
  });
@@ -194,15 +199,20 @@ program
194
199
  // Subcommand: edsger data-flow <productId>
195
200
  // ============================================================
196
201
  program
197
- .command('data-flow <productId>')
198
- .description('Generate a structured data flow (sources, datasets, transforms, sinks, queues, models) for a product from its source code')
202
+ .command('data-flow [productId]')
203
+ .description('Generate a structured data flow (sources, datasets, transforms, sinks, queues, models) for a product (or standalone repository) from its source code')
199
204
  .requiredOption('--flow-id <id>', 'Pending flows row id to populate')
205
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
200
206
  .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
201
207
  .option('-v, --verbose', 'Verbose output')
202
208
  .action(async (productId, opts) => {
203
209
  try {
210
+ if (!productId && !opts.repoId) {
211
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for data-flow');
212
+ }
204
213
  await runDataFlow(productId, {
205
214
  flowId: opts.flowId,
215
+ repoId: opts.repoId,
206
216
  guidance: opts.guidance,
207
217
  verbose: opts.verbose,
208
218
  });
@@ -646,10 +656,10 @@ program
646
656
  // Subcommand: edsger quality-benchmark <productId>
647
657
  // ============================================================
648
658
  program
649
- .command('quality-benchmark <productId>')
650
- .description("Run an industrial-grade code quality benchmark against the product's GitHub repo")
659
+ .command('quality-benchmark [productId]')
660
+ .description("Run an industrial-grade code quality benchmark against the product's (or a standalone repository's) GitHub repo")
651
661
  .option('--repo <path>', "Override the auto-clone with a local checkout (default: clone the product's repo into ~/edsger/quality-<owner>-<repo>)")
652
- .option('--repo-id <id>', "Benchmark a specific linked repository (id) instead of the product's primary repo")
662
+ .option('--repo-id <id>', "Benchmark a specific repository (id). With a productId, targets that linked repo; without a productId, runs in repo-only mode (no product)")
653
663
  .option('--branch <name>', 'Override the detected default branch on the report envelope')
654
664
  .option('--pkg-manager <name>', 'npm | pnpm | yarn (auto-detected if absent)')
655
665
  .option('--no-install', 'Refuse to install missing tools; mark them unmeasured')
@@ -659,7 +669,10 @@ program
659
669
  .option('-v, --verbose', 'Print every progress event')
660
670
  .action(async (productId, opts) => {
661
671
  try {
662
- await runQualityBenchmarkCli(productId, opts);
672
+ if (!productId && !opts.repoId) {
673
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for quality-benchmark');
674
+ }
675
+ await runQualityBenchmarkCli(productId ?? '', opts);
663
676
  }
664
677
  catch (error) {
665
678
  logError(error instanceof Error ? error.message : String(error));
@@ -687,7 +700,7 @@ program
687
700
  // ============================================================
688
701
  program
689
702
  .command('sync-org-repos <teamId>')
690
- .description("Sync a GitHub org's repos as products under a team. Uses the local gh CLI.")
703
+ .description("Sync a GitHub org's repos under a team via the Edsger GitHub App, falling back to your personal GitHub token.")
691
704
  .option('-v, --verbose', 'Verbose output')
692
705
  .option('--org <name>', "GitHub org name (defaults to the team's configured github_org)")
693
706
  .action(async (teamId, opts) => {
@@ -888,15 +901,20 @@ program
888
901
  // Subcommand: edsger recipes <productId>
889
902
  // ============================================================
890
903
  program
891
- .command('recipes <productId>')
892
- .description("Scan a product's repo for the implementation recipes it uses (which services/tools are chained together to deliver each capability) and persist them via the recipes / product_recipes tables. Writes against the pending recipe_scans row identified by --scan-id.")
904
+ .command('recipes [productId]')
905
+ .description("Scan a product's (or standalone repository's) repo for the implementation recipes it uses (which services/tools are chained together to deliver each capability) and persist them via the recipes / product_recipes (or repository_recipes) tables. Writes against the pending recipe_scans row identified by --scan-id.")
893
906
  .requiredOption('--scan-id <id>', 'Pending recipe_scans row id to drive (created by the desktop UI before invocation)')
907
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
894
908
  .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
895
909
  .option('-v, --verbose', 'Verbose output')
896
910
  .action(async (productId, opts) => {
897
911
  try {
912
+ if (!productId && !opts.repoId) {
913
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for recipes');
914
+ }
898
915
  await runRecipes(productId, {
899
916
  scanId: opts.scanId,
917
+ repoId: opts.repoId,
900
918
  guidance: opts.guidance,
901
919
  verbose: opts.verbose,
902
920
  });
@@ -10,7 +10,10 @@
10
10
  * domain.
11
11
  */
12
12
  export interface DataFlowPhaseOptions {
13
- productId: string;
13
+ /** Product-scoped flow. Mutually exclusive with `repoId`. */
14
+ productId?: string;
15
+ /** Repo-only flow: a single repositories row, no product context. */
16
+ repoId?: string;
14
17
  flowId: string;
15
18
  guidance?: string;
16
19
  verbose?: boolean;
@@ -10,6 +10,7 @@
10
10
  * domain.
11
11
  */
12
12
  import { query } from '@anthropic-ai/claude-agent-sdk';
13
+ import { getRepositoryBasics } from '../../api/github.js';
13
14
  import { DEFAULT_MODEL } from '../../constants.js';
14
15
  import { getSupabase } from '../../supabase/client.js';
15
16
  import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
@@ -27,13 +28,20 @@ const COLUMN_WIDTH = 320;
27
28
  const ROW_HEIGHT = 220;
28
29
  const COLUMNS = 4;
29
30
  export async function runDataFlowPhase(options) {
30
- const { productId, flowId, guidance, verbose } = options;
31
- logInfo(`Starting data-flow generation for product ${productId}`);
31
+ const { productId, repoId, flowId, guidance, verbose } = options;
32
+ const repoOnly = !productId && Boolean(repoId);
33
+ if (productId) {
34
+ logInfo(`Starting data-flow generation for product ${productId}`);
35
+ }
36
+ else {
37
+ logInfo(`Starting data-flow generation for repository ${repoId}`);
38
+ }
32
39
  const supabase = getSupabase();
33
40
  await markFlowRunning(supabase, flowId);
34
41
  const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
35
42
  const cloneResult = await cloneFlowRepos({
36
43
  productId,
44
+ repoId,
37
45
  repositoryIds,
38
46
  workspaceKey: WORKSPACE_KEY,
39
47
  verbose,
@@ -45,7 +53,9 @@ export async function runDataFlowPhase(options) {
45
53
  const { projectDir, cleanupDir, repos } = cloneResult;
46
54
  let succeeded = false;
47
55
  try {
48
- const product = await fetchProductBasics(productId);
56
+ const product = repoOnly
57
+ ? await resolveRepoBasics(repoId, repos)
58
+ : await fetchProductBasics(productId);
49
59
  const systemPrompt = await createDataFlowSystemPrompt({
50
60
  projectDir,
51
61
  hasCodebase: true,
@@ -118,6 +128,17 @@ export async function runDataFlowPhase(options) {
118
128
  }
119
129
  }
120
130
  }
131
+ /**
132
+ * Build "product basics" for repo-only mode from the repositories row,
133
+ * falling back to the cloned repo's full name when the row has no name.
134
+ */
135
+ async function resolveRepoBasics(repositoryId, repos) {
136
+ const basics = await getRepositoryBasics(repositoryId).catch(() => null);
137
+ return {
138
+ name: basics?.fullName ?? repos[0]?.fullName ?? repositoryId,
139
+ description: basics?.description ?? undefined,
140
+ };
141
+ }
121
142
  /** Read the ordered repo set a flow was scoped to (may be empty). */
122
143
  async function getFlowRepositoryIds(supabase, flowId) {
123
144
  const { data } = await supabase
@@ -35,8 +35,11 @@ export declare function safeDirName(fullName: string): string;
35
35
  /**
36
36
  * Resolve the repositories a flow targets (by id, preserving the stored
37
37
  * order), falling back to the product's primary repo.
38
+ *
39
+ * In repo-only mode there is no product, so no `fallback` is provided: the
40
+ * set is resolved purely from `repositoryIds`.
38
41
  */
39
- export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback: {
42
+ export declare function resolveTargetRepos(productId: string | undefined, repositoryIds: string[], fallback?: {
40
43
  owner: string;
41
44
  repo: string;
42
45
  }): Promise<{
@@ -45,7 +48,10 @@ export declare function resolveTargetRepos(productId: string, repositoryIds: str
45
48
  repo: string;
46
49
  }[]>;
47
50
  export declare function cloneFlowRepos(opts: {
48
- productId: string;
51
+ /** Product-scoped flow. Mutually exclusive with `repoId`. */
52
+ productId?: string;
53
+ /** Repo-only flow: a single repositories row, no product context. */
54
+ repoId?: string;
49
55
  repositoryIds: string[];
50
56
  workspaceKey: string;
51
57
  verbose?: boolean;
@@ -13,7 +13,7 @@
13
13
  * Falls back to the product's primary repo when `repository_ids` is empty
14
14
  * (older flows, or single-repo products).
15
15
  */
16
- import { getGitHubConfigByProduct } from '../../api/github.js';
16
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
17
17
  import { getSupabase } from '../../supabase/client.js';
18
18
  import { logInfo, logWarning } from '../../utils/logger.js';
19
19
  import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
@@ -23,16 +23,22 @@ export function safeDirName(fullName) {
23
23
  /**
24
24
  * Resolve the repositories a flow targets (by id, preserving the stored
25
25
  * order), falling back to the product's primary repo.
26
+ *
27
+ * In repo-only mode there is no product, so no `fallback` is provided: the
28
+ * set is resolved purely from `repositoryIds`.
26
29
  */
27
30
  export async function resolveTargetRepos(productId, repositoryIds, fallback) {
28
31
  if (repositoryIds.length === 0) {
29
- return [
30
- {
31
- fullName: `${fallback.owner}/${fallback.repo}`,
32
- owner: fallback.owner,
33
- repo: fallback.repo,
34
- },
35
- ];
32
+ if (fallback) {
33
+ return [
34
+ {
35
+ fullName: `${fallback.owner}/${fallback.repo}`,
36
+ owner: fallback.owner,
37
+ repo: fallback.repo,
38
+ },
39
+ ];
40
+ }
41
+ return [];
36
42
  }
37
43
  const supabase = getSupabase();
38
44
  const { data } = await supabase
@@ -54,8 +60,8 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
54
60
  resolved.push({ fullName, owner, repo });
55
61
  }
56
62
  // If none resolved (deleted repos / RLS), fall back to the primary repo so
57
- // generation still produces something useful.
58
- if (resolved.length === 0) {
63
+ // generation still produces something useful (product mode only).
64
+ if (resolved.length === 0 && fallback) {
59
65
  return [
60
66
  {
61
67
  fullName: `${fallback.owner}/${fallback.repo}`,
@@ -67,21 +73,33 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
67
73
  return resolved;
68
74
  }
69
75
  export async function cloneFlowRepos(opts) {
70
- const { productId, repositoryIds, workspaceKey, verbose } = opts;
71
- const gh = await getGitHubConfigByProduct(productId, verbose);
76
+ const { productId, repoId, repositoryIds, workspaceKey, verbose } = opts;
77
+ const repoOnly = !productId && Boolean(repoId);
78
+ // Resolve the auth token. Product mode reuses the product's installation /
79
+ // PAT for every repo; repo-only mode resolves it from the first (only) repo.
80
+ const gh = repoOnly
81
+ ? await getGitHubConfigByRepository(repositoryIds[0] ?? repoId, verbose)
82
+ : await getGitHubConfigByProduct(productId, verbose);
72
83
  if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
73
84
  return {
74
85
  ok: false,
75
86
  message: gh.message ||
76
- 'GitHub repository not configured for this product. Connect a repo first.',
87
+ (repoOnly
88
+ ? 'GitHub repository not configured. Connect the repo first.'
89
+ : 'GitHub repository not configured for this product. Connect a repo first.'),
90
+ };
91
+ }
92
+ // In repo-only mode there is no product primary-repo fallback; targets come
93
+ // purely from repositoryIds.
94
+ const targets = await resolveTargetRepos(productId, repositoryIds, repoOnly ? undefined : { owner: gh.owner, repo: gh.repo });
95
+ if (targets.length === 0) {
96
+ return {
97
+ ok: false,
98
+ message: 'No repositories resolved for this flow.',
77
99
  };
78
100
  }
79
- const targets = await resolveTargetRepos(productId, repositoryIds, {
80
- owner: gh.owner,
81
- repo: gh.repo,
82
- });
83
101
  const workspaceRoot = ensureWorkspaceDir();
84
- const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${productId}`);
102
+ const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${repoOnly ? `repo-${repoId}` : productId}`);
85
103
  const repos = [];
86
104
  for (const target of targets) {
87
105
  try {
@@ -19,7 +19,10 @@
19
19
  import type { SupabaseClient } from '@supabase/supabase-js';
20
20
  import type { RecipeSummary } from './types.js';
21
21
  export interface RecipesPhaseOptions {
22
- productId: string;
22
+ /** Product-scoped scan. Mutually exclusive with `repoId`. */
23
+ productId?: string;
24
+ /** Repo-only scan: a single repositories row, no product context. */
25
+ repoId?: string;
23
26
  scanId: string;
24
27
  guidance?: string;
25
28
  verbose?: boolean;
@@ -44,6 +47,10 @@ export declare function listProductRecipeLinks(supabase: SupabaseClient, product
44
47
  recipe_id: string;
45
48
  name: string;
46
49
  }[]>;
50
+ export declare function listRepositoryRecipeLinks(supabase: SupabaseClient, repositoryId: string): Promise<{
51
+ recipe_id: string;
52
+ name: string;
53
+ }[]>;
47
54
  /**
48
55
  * Claim the row by flipping `pending` → `running`. Returns true on success
49
56
  * (we won the claim) and false when the row has already moved on (e.g. user
@@ -17,7 +17,7 @@
17
17
  * progress survives even if the agent later errors out.
18
18
  */
19
19
  import { query } from '@anthropic-ai/claude-agent-sdk';
20
- import { getGitHubConfigByProduct } from '../../api/github.js';
20
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
21
21
  import { DEFAULT_MODEL } from '../../constants.js';
22
22
  import { getSupabase } from '../../supabase/client.js';
23
23
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
@@ -33,8 +33,14 @@ const MAX_TURNS = 200;
33
33
  // flowing) lets the row go stale and the reader can mark it failed.
34
34
  const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
35
35
  export async function runRecipesPhase(options) {
36
- const { productId, scanId, guidance, verbose } = options;
37
- logInfo(`Starting recipes scan for product ${productId}`);
36
+ const { productId, repoId, scanId, guidance, verbose } = options;
37
+ const repoOnly = !productId && Boolean(repoId);
38
+ if (productId) {
39
+ logInfo(`Starting recipes scan for product ${productId}`);
40
+ }
41
+ else {
42
+ logInfo(`Starting recipes scan for repository ${repoId}`);
43
+ }
38
44
  const supabase = getSupabase();
39
45
  const claimed = await markRunning(supabase, scanId);
40
46
  if (!claimed) {
@@ -43,19 +49,42 @@ export async function runRecipesPhase(options) {
43
49
  message: 'Recipe scan row is no longer in a runnable state (likely cancelled before the CLI started)',
44
50
  };
45
51
  }
46
- const teamId = await getProductTeamId(supabase, productId);
47
- if (!teamId) {
48
- const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
49
- await markFailed(supabase, scanId, msg);
50
- return { status: 'error', message: msg };
52
+ // Resolve the team (recipes are team-scoped) + repo basics. In repo-only
53
+ // mode both come off the repositories row; in product mode from the product.
54
+ let teamId;
55
+ let repoBasics = {
56
+ fullName: null,
57
+ description: null,
58
+ };
59
+ if (repoOnly) {
60
+ const basics = await getRepositoryBasics(repoId);
61
+ teamId = basics.teamId;
62
+ repoBasics = { fullName: basics.fullName, description: basics.description };
63
+ if (!teamId) {
64
+ const msg = 'Repository is not associated with a team; recipes are team-scoped and require one.';
65
+ await markFailed(supabase, scanId, msg);
66
+ return { status: 'error', message: msg };
67
+ }
51
68
  }
52
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
69
+ else {
70
+ teamId = await getProductTeamId(supabase, productId);
71
+ if (!teamId) {
72
+ const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
73
+ await markFailed(supabase, scanId, msg);
74
+ return { status: 'error', message: msg };
75
+ }
76
+ }
77
+ const githubConfig = repoOnly
78
+ ? await getGitHubConfigByRepository(repoId, verbose)
79
+ : await getGitHubConfigByProduct(productId, verbose);
53
80
  if (!githubConfig.configured ||
54
81
  !githubConfig.token ||
55
82
  !githubConfig.owner ||
56
83
  !githubConfig.repo) {
57
84
  const msg = githubConfig.message ||
58
- 'GitHub repository not configured for this product. Connect a repo first.';
85
+ (repoOnly
86
+ ? 'GitHub repository not configured. Connect the repo first.'
87
+ : 'GitHub repository not configured for this product. Connect a repo first.');
59
88
  await markFailed(supabase, scanId, msg);
60
89
  return { status: 'error', message: msg };
61
90
  }
@@ -63,13 +92,22 @@ export async function runRecipesPhase(options) {
63
92
  let succeeded = false;
64
93
  try {
65
94
  const workspaceRoot = ensureWorkspaceDir();
66
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
95
+ const repoKey = repoOnly
96
+ ? `${WORKSPACE_KEY}-repo-${repoId}`
97
+ : `${WORKSPACE_KEY}-${productId}`;
67
98
  ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
68
- const [product, scanMeta, teamRecipes, existingLinks] = await Promise.all([
69
- fetchProductBasics(productId),
99
+ const [basics, scanMeta, teamRecipes, existingLinks] = await Promise.all([
100
+ repoOnly
101
+ ? Promise.resolve({
102
+ name: repoBasics.fullName ?? `${githubConfig.owner}/${githubConfig.repo}`,
103
+ description: repoBasics.description ?? undefined,
104
+ })
105
+ : fetchProductBasics(productId),
70
106
  getScanCreator(supabase, scanId),
71
107
  listTeamRecipes(supabase, teamId),
72
- listProductRecipeLinks(supabase, productId),
108
+ repoOnly
109
+ ? listRepositoryRecipeLinks(supabase, repoId)
110
+ : listProductRecipeLinks(supabase, productId),
73
111
  ]);
74
112
  if (!scanMeta) {
75
113
  const msg = 'recipe_scans row vanished mid-run; aborting';
@@ -78,8 +116,8 @@ export async function runRecipesPhase(options) {
78
116
  }
79
117
  const systemPrompt = createRecipesSystemPrompt();
80
118
  const userPrompt = createRecipesUserPrompt({
81
- productName: product.name,
82
- productDescription: product.description,
119
+ productName: basics.name,
120
+ productDescription: basics.description,
83
121
  guidance,
84
122
  teamRecipes,
85
123
  existingLinks: existingLinks.map((l) => ({
@@ -92,7 +130,8 @@ export async function runRecipesPhase(options) {
92
130
  const mcpServer = createRecipesMcpServer({
93
131
  supabase,
94
132
  teamId,
95
- productId,
133
+ productId: repoOnly ? undefined : productId,
134
+ repositoryId: repoOnly ? repoId : undefined,
96
135
  createdBy: scanMeta.created_by,
97
136
  }, counts, teamRecipes, existingLinkIds);
98
137
  logInfo('Running Claude agent to identify recipes...');
@@ -229,6 +268,24 @@ export async function listProductRecipeLinks(supabase, productId) {
229
268
  }
230
269
  return out;
231
270
  }
271
+ export async function listRepositoryRecipeLinks(supabase, repositoryId) {
272
+ const { data, error } = await supabase
273
+ .from('repository_recipes')
274
+ .select('recipe_id, recipes(name)')
275
+ .eq('repository_id', repositoryId);
276
+ if (error || !data) {
277
+ return [];
278
+ }
279
+ const rows = data;
280
+ const out = [];
281
+ for (const r of rows) {
282
+ const recipe = Array.isArray(r.recipes) ? r.recipes[0] : r.recipes;
283
+ if (recipe) {
284
+ out.push({ recipe_id: r.recipe_id, name: recipe.name });
285
+ }
286
+ }
287
+ return out;
288
+ }
232
289
  /**
233
290
  * Claim the row by flipping `pending` → `running`. Returns true on success
234
291
  * (we won the claim) and false when the row has already moved on (e.g. user
@@ -22,7 +22,10 @@ import { type RecipeSummary } from './types.js';
22
22
  export interface RecipesToolContext {
23
23
  supabase: SupabaseClient;
24
24
  teamId: string;
25
- productId: string;
25
+ /** Set in product-scoped scans; links go to `product_recipes`. */
26
+ productId?: string;
27
+ /** Set in repo-only scans; links go to `repository_recipes`. */
28
+ repositoryId?: string;
26
29
  createdBy: string;
27
30
  }
28
31
  /**
@@ -19,6 +19,27 @@
19
19
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
20
20
  import { z } from 'zod';
21
21
  import { RECIPE_CONTENT_MAX, RECIPE_EVIDENCE_MAX, RECIPE_NAME_MAX, RECIPE_SERVICE_NAME_MAX, RECIPE_SERVICES_MAX, RECIPE_SUMMARY_MAX, } from './types.js';
22
+ function resolveLinkScope(ctx) {
23
+ if (ctx.repositoryId) {
24
+ return {
25
+ table: 'repository_recipes',
26
+ column: 'repository_id',
27
+ id: ctx.repositoryId,
28
+ onConflict: 'repository_id,recipe_id',
29
+ label: 'this repository',
30
+ };
31
+ }
32
+ if (ctx.productId) {
33
+ return {
34
+ table: 'product_recipes',
35
+ column: 'product_id',
36
+ id: ctx.productId,
37
+ onConflict: 'product_id,recipe_id',
38
+ label: 'this product',
39
+ };
40
+ }
41
+ throw new Error('RecipesToolContext requires either productId or repositoryId to scope links');
42
+ }
22
43
  export function createRecipesMutationCounts() {
23
44
  return { created: 0, updated: 0, linked: 0, unlinked: 0 };
24
45
  }
@@ -92,17 +113,18 @@ export function createCreateRecipeTool(ctx, counts, teamRecipeIds) {
92
113
  return textError(`Failed to create recipe: ${insertRecipe.error?.message ?? 'unknown error'}`);
93
114
  }
94
115
  const recipeId = insertRecipe.data.id;
95
- const linkErr = await ctx.supabase.from('product_recipes').insert({
96
- product_id: ctx.productId,
116
+ const scope = resolveLinkScope(ctx);
117
+ const linkErr = await ctx.supabase.from(scope.table).insert({
118
+ [scope.column]: scope.id,
97
119
  recipe_id: recipeId,
98
120
  evidence: args.evidence.trim(),
99
121
  });
100
122
  if (linkErr.error) {
101
- return textError(`Recipe created (${recipeId}) but linking to product failed: ${linkErr.error.message}`);
123
+ return textError(`Recipe created (${recipeId}) but linking to ${scope.label} failed: ${linkErr.error.message}`);
102
124
  }
103
125
  teamRecipeIds.add(recipeId);
104
126
  counts.created += 1;
105
- return textOk(`Created recipe ${recipeId} and linked to this product.`);
127
+ return textOk(`Created recipe ${recipeId} and linked to ${scope.label}.`);
106
128
  });
107
129
  }
108
130
  export function createUpdateRecipeTool(ctx, counts, teamRecipeIds) {
@@ -136,16 +158,17 @@ export function createUpdateRecipeTool(ctx, counts, teamRecipeIds) {
136
158
  return textError(`Failed to update recipe: ${updateErr.error.message}`);
137
159
  }
138
160
  }
139
- const linkErr = await ctx.supabase.from('product_recipes').upsert({
140
- product_id: ctx.productId,
161
+ const scope = resolveLinkScope(ctx);
162
+ const linkErr = await ctx.supabase.from(scope.table).upsert({
163
+ [scope.column]: scope.id,
141
164
  recipe_id: args.recipe_id,
142
165
  evidence: args.evidence.trim(),
143
- }, { onConflict: 'product_id,recipe_id' });
166
+ }, { onConflict: scope.onConflict });
144
167
  if (linkErr.error) {
145
- return textError(`Recipe updated but linking to product failed: ${linkErr.error.message}`);
168
+ return textError(`Recipe updated but linking to ${scope.label} failed: ${linkErr.error.message}`);
146
169
  }
147
170
  counts.updated += 1;
148
- return textOk(`Updated recipe ${args.recipe_id} and linked to this product.`);
171
+ return textOk(`Updated recipe ${args.recipe_id} and linked to ${scope.label}.`);
149
172
  });
150
173
  }
151
174
  export function createLinkRecipeTool(ctx, counts, teamRecipeIds) {
@@ -156,36 +179,38 @@ export function createLinkRecipeTool(ctx, counts, teamRecipeIds) {
156
179
  if (!teamRecipeIds.has(args.recipe_id)) {
157
180
  return textError(`recipe_id ${args.recipe_id} is not in this team's recipe list. Use create_recipe for new entries.`);
158
181
  }
159
- const linkErr = await ctx.supabase.from('product_recipes').upsert({
160
- product_id: ctx.productId,
182
+ const scope = resolveLinkScope(ctx);
183
+ const linkErr = await ctx.supabase.from(scope.table).upsert({
184
+ [scope.column]: scope.id,
161
185
  recipe_id: args.recipe_id,
162
186
  evidence: args.evidence.trim(),
163
- }, { onConflict: 'product_id,recipe_id' });
187
+ }, { onConflict: scope.onConflict });
164
188
  if (linkErr.error) {
165
189
  return textError(`Failed to link recipe: ${linkErr.error.message}`);
166
190
  }
167
191
  counts.linked += 1;
168
- return textOk(`Linked recipe ${args.recipe_id} to this product.`);
192
+ return textOk(`Linked recipe ${args.recipe_id} to ${scope.label}.`);
169
193
  });
170
194
  }
171
195
  export function createUnlinkRecipeTool(ctx, counts, existingLinkIds) {
172
- return tool('unlink_recipe', 'Drop a previously-linked recipe that you have confirmed is NO LONGER present in this repo. Only call this for ids in the "Currently linked to this product" list. Does not delete the recipe itself.', {
196
+ return tool('unlink_recipe', 'Drop a previously-linked recipe that you have confirmed is NO LONGER present in this repo. Only call this for ids in the "Currently linked" list. Does not delete the recipe itself.', {
173
197
  recipe_id: z.string().uuid(),
174
198
  }, async (args) => {
175
199
  if (!existingLinkIds.has(args.recipe_id)) {
176
- return textError(`recipe_id ${args.recipe_id} is not in the "Currently linked to this product" list — nothing to unlink.`);
200
+ return textError(`recipe_id ${args.recipe_id} is not in the "Currently linked" list — nothing to unlink.`);
177
201
  }
202
+ const scope = resolveLinkScope(ctx);
178
203
  const { error } = await ctx.supabase
179
- .from('product_recipes')
204
+ .from(scope.table)
180
205
  .delete()
181
- .eq('product_id', ctx.productId)
206
+ .eq(scope.column, scope.id)
182
207
  .eq('recipe_id', args.recipe_id);
183
208
  if (error) {
184
209
  return textError(`Failed to unlink recipe: ${error.message}`);
185
210
  }
186
211
  existingLinkIds.delete(args.recipe_id);
187
212
  counts.unlinked += 1;
188
- return textOk(`Unlinked recipe ${args.recipe_id} from this product.`);
213
+ return textOk(`Unlinked recipe ${args.recipe_id} from ${scope.label}.`);
189
214
  });
190
215
  }
191
216
  export function createRecipesMcpServer(ctx, counts, teamRecipes, existingLinkIds) {
@@ -8,7 +8,10 @@
8
8
  * pattern, but writes to its own tables rather than filing issues.
9
9
  */
10
10
  export interface ScreenFlowOptions {
11
- productId: string;
11
+ /** Product-scoped flow. Mutually exclusive with `repoId`. */
12
+ productId?: string;
13
+ /** Repo-only flow: a single repositories row, no product context. */
14
+ repoId?: string;
12
15
  flowId: string;
13
16
  guidance?: string;
14
17
  verbose?: boolean;
@@ -8,6 +8,7 @@
8
8
  * pattern, but writes to its own tables rather than filing issues.
9
9
  */
10
10
  import { query } from '@anthropic-ai/claude-agent-sdk';
11
+ import { getRepositoryBasics } from '../../api/github.js';
11
12
  import { DEFAULT_MODEL } from '../../constants.js';
12
13
  import { getSupabase } from '../../supabase/client.js';
13
14
  import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
@@ -26,13 +27,20 @@ const COLUMN_WIDTH = 380;
26
27
  const ROW_HEIGHT = 480;
27
28
  const COLUMNS = 4;
28
29
  export async function runScreenFlowPhase(options) {
29
- const { productId, flowId, guidance, verbose } = options;
30
- logInfo(`Starting screen-flow generation for product ${productId}`);
30
+ const { productId, repoId, flowId, guidance, verbose } = options;
31
+ const repoOnly = !productId && Boolean(repoId);
32
+ if (productId) {
33
+ logInfo(`Starting screen-flow generation for product ${productId}`);
34
+ }
35
+ else {
36
+ logInfo(`Starting screen-flow generation for repository ${repoId}`);
37
+ }
31
38
  const supabase = getSupabase();
32
39
  await markFlowRunning(supabase, flowId);
33
40
  const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
34
41
  const cloneResult = await cloneFlowRepos({
35
42
  productId,
43
+ repoId,
36
44
  repositoryIds,
37
45
  workspaceKey: WORKSPACE_KEY,
38
46
  verbose,
@@ -44,7 +52,9 @@ export async function runScreenFlowPhase(options) {
44
52
  const { projectDir, cleanupDir, repos } = cloneResult;
45
53
  let succeeded = false;
46
54
  try {
47
- const product = await fetchProductBasics(productId);
55
+ const product = repoOnly
56
+ ? await resolveRepoBasics(repoId, repos)
57
+ : await fetchProductBasics(productId);
48
58
  const systemPrompt = await createScreenFlowSystemPrompt({
49
59
  projectDir,
50
60
  hasCodebase: true,
@@ -148,6 +158,17 @@ function resolveTheme(agentTheme, repos) {
148
158
  }
149
159
  return {};
150
160
  }
161
+ /**
162
+ * Build "product basics" for repo-only mode from the repositories row,
163
+ * falling back to the cloned repo's full name when the row has no name.
164
+ */
165
+ async function resolveRepoBasics(repositoryId, repos) {
166
+ const basics = await getRepositoryBasics(repositoryId).catch(() => null);
167
+ return {
168
+ name: basics?.fullName ?? repos[0]?.fullName ?? repositoryId,
169
+ description: basics?.description ?? undefined,
170
+ };
171
+ }
151
172
  /** Read the ordered repo set a flow was scoped to (may be empty). */
152
173
  async function getFlowRepositoryIds(supabase, flowId) {
153
174
  const { data } = await supabase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.67.0",
3
+ "version": "0.69.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"