edsger 0.62.0 → 0.63.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.
@@ -26,6 +26,8 @@
26
26
  export interface QualityBenchmarkCliOptions {
27
27
  /** Optional local-checkout override; when absent the CLI clones the product's repo. */
28
28
  repo?: string;
29
+ /** Benchmark a specific linked repository (id) instead of the product's primary repo. */
30
+ repoId?: string;
29
31
  branch?: string;
30
32
  pkgManager?: string;
31
33
  install?: boolean;
@@ -30,12 +30,15 @@ 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';
32
32
  import { prepareQualityWorkspace } from '../../phases/quality-benchmark/workspace.js';
33
+ import { getSupabase } from '../../supabase/client.js';
33
34
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
34
35
  export async function runQualityBenchmarkCli(productId, options) {
35
36
  const installEnabled = options.install !== false;
36
37
  logInfo(`Starting quality benchmark for product ${productId}`);
37
38
  let repoRoot;
38
39
  let resolvedBranch;
40
+ // Recorded on the report so it's scoped to the repo that was benchmarked.
41
+ let repositoryId = options.repoId ?? null;
39
42
  if (options.repo) {
40
43
  repoRoot = resolve(options.repo);
41
44
  resolvedBranch = options.branch;
@@ -48,9 +51,27 @@ export async function runQualityBenchmarkCli(productId, options) {
48
51
  'GitHub is not configured for this product. Connect a repo in product settings.'}`);
49
52
  process.exit(1);
50
53
  }
54
+ // Default to the product's primary repo; if a specific repo id was passed,
55
+ // resolve its owner/repo so we benchmark that codebase instead.
56
+ let { owner } = gh;
57
+ let { repo } = gh;
58
+ if (options.repoId) {
59
+ const fullName = await resolveRepositoryFullName(options.repoId);
60
+ if (fullName) {
61
+ const [o, r] = fullName.split('/');
62
+ if (o && r) {
63
+ owner = o;
64
+ repo = r;
65
+ }
66
+ }
67
+ else {
68
+ logWarning(`repo-id ${options.repoId} not found; falling back to the product's primary repo.`);
69
+ repositoryId = null;
70
+ }
71
+ }
51
72
  const ws = prepareQualityWorkspace({
52
- owner: gh.owner,
53
- repo: gh.repo,
73
+ owner,
74
+ repo,
54
75
  token: gh.token,
55
76
  verbose: options.verbose,
56
77
  });
@@ -116,6 +137,7 @@ export async function runQualityBenchmarkCli(productId, options) {
116
137
  try {
117
138
  const saved = (await callMcpEndpoint('quality_reports/save', {
118
139
  product_id: productId,
140
+ repository_id: repositoryId,
119
141
  commit_sha: outcome.commitSha,
120
142
  rubric_version: outcome.report.rubric_version,
121
143
  branch: resolvedBranch ?? null,
@@ -152,3 +174,12 @@ export async function runQualityBenchmarkCli(productId, options) {
152
174
  logInfo(outcome.report.executive_summary);
153
175
  }
154
176
  }
177
+ /** Resolve a repository id to its "owner/repo" full name (RLS-scoped). */
178
+ async function resolveRepositoryFullName(repositoryId) {
179
+ const { data } = await getSupabase()
180
+ .from('repositories')
181
+ .select('full_name')
182
+ .eq('id', repositoryId)
183
+ .maybeSingle();
184
+ return (data)?.full_name ?? null;
185
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Marker emitted on stdout carrying the SDK session id, so the desktop can
3
+ * persist it on the channel and pass it back via --resume next turn. Kept on
4
+ * its own line and free of logger formatting so it parses cleanly.
5
+ */
6
+ export declare const SESSION_ID_MARKER = "__EDSGER_SESSION_ID__=";
7
+ export interface SessionTurnCliOptions {
8
+ channelId: string;
9
+ productId: string;
10
+ repositoryIds?: string[];
11
+ /** SDK session id from a previous turn, to resume the same conversation. */
12
+ resumeSessionId?: string;
13
+ verbose?: boolean;
14
+ }
15
+ export declare function runSessionTurnCommand(options: SessionTurnCliOptions): Promise<void>;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Marker emitted on stdout carrying the SDK session id, so the desktop can
3
+ * persist it on the channel and pass it back via --resume next turn. Kept on
4
+ * its own line and free of logger formatting so it parses cleanly.
5
+ */
6
+ export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
7
+ /**
8
+ * `edsger session-turn <channelId>` — run one conversational agent turn for a
9
+ * chat session (an ai_assistant channel bound to a product).
10
+ *
11
+ * Spawned by the desktop app when the user sends a message in a session. It
12
+ * loads the channel's pending human messages, runs a single Claude Agent SDK
13
+ * turn with the full session toolbelt (createSessionMcpServer — query/action/
14
+ * engineering/cli + conversational + git/PR tools), then posts the reply back
15
+ * to the channel and marks the messages processed. The agent decides what to
16
+ * do (answer, generate stories/test cases, launch cli_* analyses, open PRs);
17
+ * there is no fixed pipeline.
18
+ */
19
+ import { query } from '@anthropic-ai/claude-agent-sdk';
20
+ import { createSessionMcpServer } from 'edsger-tools';
21
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
22
+ import { DEFAULT_MODEL } from '../../constants.js';
23
+ import { getToolDeps } from '../../tools/bootstrap.js';
24
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
25
+ const MAX_TURNS = 40;
26
+ function buildSystemPrompt(opts) {
27
+ const { channelId, productId, repositoryIds } = opts;
28
+ const repoLine = repositoryIds.length > 0
29
+ ? repositoryIds.join(', ')
30
+ : '(none provided — call list_session_repositories to discover them)';
31
+ return `You are an engineering agent working in a chat session with a user, like a pair-programming assistant.
32
+
33
+ ## This session
34
+ - Session channel_id: ${channelId}
35
+ - Product product_id: ${productId}
36
+ - In-scope repository_ids: ${repoLine}
37
+
38
+ ## How you work
39
+ You are an orchestrator, not a fixed pipeline. Do only what the user asked:
40
+ - A question or status check → just answer (you can read code with Read/Grep/Glob).
41
+ - "Write the test cases" / "add user stories" → use create_user_story / create_test_case / create_test_report. No code.
42
+ - "Check quality" / "find bugs" → launch the matching cli_* tool (asynchronous: it returns a run handle, results appear later; report progress with get_cli_run).
43
+
44
+ ## Implementing code changes (you hand this off — you do NOT write code or open PRs yourself)
45
+ When the user wants code changes:
46
+ 1. Optionally list_session_repositories to understand the codebase scope.
47
+ 2. create_issue with a clear, implementable description (the product may span multiple repos — say which).
48
+ 3. Mark it ready for AI with update_issue_status so the engineering pipeline picks it up, clones the repos, implements the change, and opens the pull request(s).
49
+ 4. Tell the user you filed the issue and that the pipeline will produce the PR(s); offer to check status later.
50
+ Only create issues/tasks when the work warrants tracking — a pure question or quality check should not leave an issue behind.
51
+
52
+ ## Talking to the user
53
+ - send_chat_message for explanations, summaries, clarifying questions (channel_id "${channelId}").
54
+ - provide_options when there are 2-4 clear next steps.
55
+ - Be concise; report what you did and what you need.`;
56
+ }
57
+ function buildUserPrompt(messages) {
58
+ const conversation = messages
59
+ .map((m) => {
60
+ const who = m.sender_agent_id
61
+ ? `[Agent: ${m.sender_name || 'Unknown'}]`
62
+ : `[${m.sender_name || 'User'}]`;
63
+ return `${who}: ${m.content}`;
64
+ })
65
+ .join('\n\n');
66
+ return `New message(s) in this session:\n\n${conversation}\n\nDecide what to do and act. Keep the user informed via send_chat_message.`;
67
+ }
68
+ export async function runSessionTurnCommand(options) {
69
+ const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
70
+ // Emit the SDK session id so the desktop can persist it for the next turn.
71
+ const emitSessionId = (id) => {
72
+ if (id) {
73
+ process.stdout.write(`\n${SESSION_ID_MARKER}${id}\n`);
74
+ }
75
+ };
76
+ // 1. Load the pending human messages that triggered this turn.
77
+ const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
78
+ channel_id: channelId,
79
+ }));
80
+ const messages = pendingResult.messages ?? [];
81
+ if (messages.length === 0) {
82
+ logInfo('No pending messages for this session — nothing to do.');
83
+ return;
84
+ }
85
+ logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
86
+ // 2. Build the session toolbelt + prompts.
87
+ const deps = getToolDeps({
88
+ verbose,
89
+ context: { productId, channelId, repositoryIds },
90
+ });
91
+ const sessionServer = createSessionMcpServer(deps);
92
+ const systemPrompt = buildSystemPrompt({
93
+ channelId,
94
+ productId,
95
+ repositoryIds,
96
+ });
97
+ const userPrompt = buildUserPrompt(messages);
98
+ // 3. Run one SDK turn. Resume the prior SDK session when we have its id so
99
+ // the conversation (and prompt cache) carries across turns.
100
+ let finalResponse = '';
101
+ let sdkSessionId = resumeSessionId ?? '';
102
+ try {
103
+ for await (const message of query({
104
+ prompt: userPrompt,
105
+ options: {
106
+ agent: 'session-agent',
107
+ agents: {
108
+ 'session-agent': {
109
+ description: 'Conversational engineering agent for a session',
110
+ prompt: systemPrompt,
111
+ maxTurns: MAX_TURNS,
112
+ mcpServers: ['edsger-session'],
113
+ disallowedTools: ['Edit', 'Write', 'Bash', 'NotebookEdit', 'Agent'],
114
+ },
115
+ },
116
+ mcpServers: { 'edsger-session': sessionServer },
117
+ tools: ['Read', 'Grep', 'Glob'],
118
+ permissionMode: 'bypassPermissions',
119
+ allowDangerouslySkipPermissions: true,
120
+ model: DEFAULT_MODEL,
121
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
122
+ },
123
+ })) {
124
+ const msg = message;
125
+ // The system/init and result messages carry the (possibly new) session
126
+ // id — keep the latest so we persist what the next turn should resume.
127
+ if (msg.session_id) {
128
+ sdkSessionId = msg.session_id;
129
+ }
130
+ if (verbose && msg.type === 'assistant') {
131
+ logInfo('· agent step');
132
+ }
133
+ if (msg.type === 'result') {
134
+ finalResponse = msg.result || '';
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ catch (error) {
140
+ const reason = error instanceof Error ? error.message : String(error);
141
+ logError(`Session turn failed: ${reason}`);
142
+ await callMcpEndpoint('chat/messages/send_ai', {
143
+ channel_id: channelId,
144
+ content: `I hit an error processing that: ${reason}`,
145
+ message_type: 'text',
146
+ metadata: {},
147
+ }).catch(() => undefined);
148
+ emitSessionId(sdkSessionId);
149
+ await markProcessed(messages);
150
+ return;
151
+ }
152
+ // 4. Post the final reply (the agent may also have posted via
153
+ // send_chat_message during the turn; this carries the closing summary).
154
+ const reply = finalResponse.trim();
155
+ if (reply) {
156
+ await callMcpEndpoint('chat/messages/send_ai', {
157
+ channel_id: channelId,
158
+ content: reply,
159
+ message_type: 'text',
160
+ metadata: {},
161
+ }).catch((e) => {
162
+ logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
163
+ });
164
+ }
165
+ // 5. Mark the triggering messages processed so they aren't re-run.
166
+ await markProcessed(messages);
167
+ // 6. Emit the SDK session id so the desktop persists it for the next turn.
168
+ emitSessionId(sdkSessionId);
169
+ logSuccess('Session turn complete.');
170
+ }
171
+ async function markProcessed(messages) {
172
+ for (const m of messages) {
173
+ await callMcpEndpoint('chat/messages/mark_processed', {
174
+ message_id: m.id,
175
+ }).catch(() => undefined);
176
+ }
177
+ }
@@ -2,7 +2,7 @@
2
2
  * CLI command: `edsger sync-org-repos <teamId>`
3
3
  *
4
4
  * Reads the team's configured github_org, fetches all repos from that org
5
- * via the local `gh` CLI, and creates a product for each repo not yet linked.
5
+ * via the local `gh` CLI, and upserts a repositories row for each repo.
6
6
  */
7
7
  export interface SyncOrgReposCliOptions {
8
8
  verbose?: boolean;
@@ -2,7 +2,7 @@
2
2
  * CLI command: `edsger sync-org-repos <teamId>`
3
3
  *
4
4
  * Reads the team's configured github_org, fetches all repos from that org
5
- * via the local `gh` CLI, and creates a product for each repo not yet linked.
5
+ * via the local `gh` CLI, and upserts a repositories row for each repo.
6
6
  */
7
7
  import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
8
8
  import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
@@ -50,7 +50,7 @@ export async function runSyncOrgRepos(teamId, options = {}) {
50
50
  });
51
51
  if (result.status === 'success') {
52
52
  logSuccess(result.message);
53
- logInfo(`Total: ${result.total} · created: ${result.created} · skipped: ${result.skipped}`);
53
+ logInfo(`Total: ${result.total} · created: ${result.created} · updated: ${result.skipped}`);
54
54
  }
55
55
  else {
56
56
  logError(result.message);
package/dist/index.js CHANGED
@@ -32,13 +32,14 @@ import { runRefactor } from './commands/refactor/refactor.js';
32
32
  import { runReleaseSyncCommand } from './commands/release-sync/index.js';
33
33
  import { runRunSheetCommand } from './commands/run-sheet/index.js';
34
34
  import { runScreenFlow } from './commands/screen-flow/index.js';
35
+ import { runSessionTurnCommand } from './commands/session-turn/index.js';
35
36
  import { runSmokeTestCommand } from './commands/smoke-test/index.js';
36
- import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
37
- import { runSyncOrgRepos } from './commands/sync-org-repos/index.js';
38
37
  import { runSyncAws } from './commands/sync-aws/index.js';
39
38
  import { runSyncDatadog } from './commands/sync-datadog/index.js';
40
- import { runSyncTerraform } from './commands/sync-terraform/index.js';
39
+ import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
40
+ import { runSyncOrgRepos } from './commands/sync-org-repos/index.js';
41
41
  import { runSyncSentryIssues } from './commands/sync-sentry-issues/index.js';
42
+ import { runSyncTerraform } from './commands/sync-terraform/index.js';
42
43
  import { runTaskWorker } from './commands/task-worker/index.js';
43
44
  import { runTechnicalDesignCommand } from './commands/technical-design/index.js';
44
45
  import { runTestCasesAnalysisCommand } from './commands/test-cases-analysis/index.js';
@@ -432,6 +433,34 @@ program
432
433
  }
433
434
  });
434
435
  // ============================================================
436
+ // Subcommand: edsger session-turn <channelId>
437
+ // ============================================================
438
+ program
439
+ .command('session-turn <channelId>')
440
+ .description('Run one conversational agent turn for a chat session (ai_assistant channel)')
441
+ .requiredOption('--product <productId>', 'Product the session is bound to')
442
+ .option('--repos <ids>', 'Comma-separated repository IDs the agent may touch', '')
443
+ .option('--resume <sessionId>', 'SDK session id from a previous turn to resume the same conversation')
444
+ .option('-v, --verbose', 'Verbose output')
445
+ .action(async (channelId, opts) => {
446
+ try {
447
+ await runSessionTurnCommand({
448
+ channelId,
449
+ productId: opts.product,
450
+ repositoryIds: (opts.repos ?? '')
451
+ .split(',')
452
+ .map((s) => s.trim())
453
+ .filter(Boolean),
454
+ resumeSessionId: opts.resume,
455
+ verbose: opts.verbose,
456
+ });
457
+ }
458
+ catch (error) {
459
+ logError(error instanceof Error ? error.message : String(error));
460
+ process.exit(1);
461
+ }
462
+ });
463
+ // ============================================================
435
464
  // Subcommand: edsger user-stories-analysis <issueId>
436
465
  // ============================================================
437
466
  program
@@ -569,6 +598,7 @@ program
569
598
  .command('quality-benchmark <productId>')
570
599
  .description("Run an industrial-grade code quality benchmark against the product's GitHub repo")
571
600
  .option('--repo <path>', "Override the auto-clone with a local checkout (default: clone the product's repo into ~/edsger/quality-<owner>-<repo>)")
601
+ .option('--repo-id <id>', "Benchmark a specific linked repository (id) instead of the product's primary repo")
572
602
  .option('--branch <name>', 'Override the detected default branch on the report envelope')
573
603
  .option('--pkg-manager <name>', 'npm | pnpm | yarn (auto-detected if absent)')
574
604
  .option('--no-install', 'Refuse to install missing tools; mark them unmeasured')
@@ -608,7 +638,7 @@ program
608
638
  .command('sync-org-repos <teamId>')
609
639
  .description("Sync a GitHub org's repos as products under a team. Uses the local gh CLI.")
610
640
  .option('-v, --verbose', 'Verbose output')
611
- .option('--org <name>', 'GitHub org name (defaults to the team\'s configured github_org)')
641
+ .option('--org <name>', "GitHub org name (defaults to the team's configured github_org)")
612
642
  .action(async (teamId, opts) => {
613
643
  try {
614
644
  await runSyncOrgRepos(teamId, opts);
@@ -10,12 +10,12 @@
10
10
  * domain.
11
11
  */
12
12
  import { query } from '@anthropic-ai/claude-agent-sdk';
13
- import { getGitHubConfigByProduct } from '../../api/github.js';
14
13
  import { DEFAULT_MODEL } from '../../constants.js';
15
14
  import { getSupabase } from '../../supabase/client.js';
16
15
  import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
17
- import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
16
+ import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
18
17
  import { fetchProductBasics } from '../find-shared/mcp.js';
18
+ import { cloneFlowRepos, describeRepoScope } from '../flow-shared/clone-repos.js';
19
19
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
20
20
  import { createDataFlowCaptureState, createDataFlowMcpServer, validateConsistency, } from './mcp-server.js';
21
21
  import { createDataFlowSystemPrompt, createDataFlowUserPrompt, } from './prompts.js';
@@ -31,31 +31,30 @@ export async function runDataFlowPhase(options) {
31
31
  logInfo(`Starting data-flow generation for product ${productId}`);
32
32
  const supabase = getSupabase();
33
33
  await markFlowRunning(supabase, flowId);
34
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
35
- if (!githubConfig.configured ||
36
- !githubConfig.token ||
37
- !githubConfig.owner ||
38
- !githubConfig.repo) {
39
- const msg = githubConfig.message ||
40
- 'GitHub repository not configured for this product. Connect a repo first.';
41
- await markFlowFailed(supabase, flowId, msg);
42
- return { status: 'error', message: msg };
34
+ const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
35
+ const cloneResult = await cloneFlowRepos({
36
+ productId,
37
+ repositoryIds,
38
+ workspaceKey: WORKSPACE_KEY,
39
+ verbose,
40
+ });
41
+ if (!cloneResult.ok) {
42
+ await markFlowFailed(supabase, flowId, cloneResult.message);
43
+ return { status: 'error', message: cloneResult.message };
43
44
  }
44
- let repoPath;
45
+ const { projectDir, cleanupDir, repos } = cloneResult;
45
46
  let succeeded = false;
46
47
  try {
47
- const workspaceRoot = ensureWorkspaceDir();
48
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
49
- ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
50
48
  const product = await fetchProductBasics(productId);
51
49
  const systemPrompt = await createDataFlowSystemPrompt({
52
- projectDir: repoPath,
50
+ projectDir,
53
51
  hasCodebase: true,
54
52
  });
53
+ const repoScope = describeRepoScope(repos);
55
54
  const userPrompt = createDataFlowUserPrompt({
56
55
  productName: product.name,
57
56
  productDescription: product.description,
58
- guidance,
57
+ guidance: [guidance, repoScope].filter(Boolean).join('\n\n') || undefined,
59
58
  });
60
59
  logInfo('Running Claude data-flow extraction...');
61
60
  const captureState = createDataFlowCaptureState();
@@ -77,7 +76,7 @@ export async function runDataFlowPhase(options) {
77
76
  model: DEFAULT_MODEL,
78
77
  maxTurns: MAX_TURNS,
79
78
  permissionMode: 'bypassPermissions',
80
- cwd: repoPath,
79
+ cwd: projectDir,
81
80
  mcpServers: {
82
81
  'data-flow': mcpServer,
83
82
  },
@@ -115,10 +114,19 @@ export async function runDataFlowPhase(options) {
115
114
  }
116
115
  finally {
117
116
  if (succeeded) {
118
- cleanupIssueRepo(repoPath);
117
+ cleanupIssueRepo(cleanupDir);
119
118
  }
120
119
  }
121
120
  }
121
+ /** Read the ordered repo set a flow was scoped to (may be empty). */
122
+ async function getFlowRepositoryIds(supabase, flowId) {
123
+ const { data } = await supabase
124
+ .from('flows')
125
+ .select('repository_ids')
126
+ .eq('id', flowId)
127
+ .single();
128
+ return (data?.repository_ids ?? []).filter(Boolean);
129
+ }
122
130
  function processSdkMessage(
123
131
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
132
  message, assistantBuffer, captureState, verbose) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared multi-repo cloning for flow generation (screen-flow / data-flow).
3
+ *
4
+ * A flow can be scoped to one or several repositories (the user picks them in
5
+ * the desktop UI; the chosen set is stored on `flows.repository_ids`). This
6
+ * helper resolves that set, clones each repo into a per-flow parent workspace
7
+ * directory, and returns the directory the agent should run against:
8
+ * - single repo → the repo's own clone dir
9
+ * - many repos → the parent dir holding every clone as a subdirectory,
10
+ * so the agent can explore them all and produce one unified
11
+ * flow.
12
+ *
13
+ * Falls back to the product's primary repo when `repository_ids` is empty
14
+ * (older flows, or single-repo products).
15
+ */
16
+ export interface ClonedRepo {
17
+ fullName: string;
18
+ owner: string;
19
+ repo: string;
20
+ dir: string;
21
+ }
22
+ export interface CloneFlowReposSuccess {
23
+ ok: true;
24
+ /** Directory to point the agent at (parent dir for multi-repo). */
25
+ projectDir: string;
26
+ /** Directory to clean up afterwards (always the per-flow parent). */
27
+ cleanupDir: string;
28
+ repos: ClonedRepo[];
29
+ }
30
+ export interface CloneFlowReposFailure {
31
+ ok: false;
32
+ message: string;
33
+ }
34
+ export declare function cloneFlowRepos(opts: {
35
+ productId: string;
36
+ repositoryIds: string[];
37
+ workspaceKey: string;
38
+ verbose?: boolean;
39
+ }): Promise<CloneFlowReposSuccess | CloneFlowReposFailure>;
40
+ /**
41
+ * Build a short note describing the repo scope, appended to the agent's user
42
+ * prompt so it knows whether to map one repo or unify several.
43
+ */
44
+ export declare function describeRepoScope(repos: ClonedRepo[]): string;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shared multi-repo cloning for flow generation (screen-flow / data-flow).
3
+ *
4
+ * A flow can be scoped to one or several repositories (the user picks them in
5
+ * the desktop UI; the chosen set is stored on `flows.repository_ids`). This
6
+ * helper resolves that set, clones each repo into a per-flow parent workspace
7
+ * directory, and returns the directory the agent should run against:
8
+ * - single repo → the repo's own clone dir
9
+ * - many repos → the parent dir holding every clone as a subdirectory,
10
+ * so the agent can explore them all and produce one unified
11
+ * flow.
12
+ *
13
+ * Falls back to the product's primary repo when `repository_ids` is empty
14
+ * (older flows, or single-repo products).
15
+ */
16
+ import { getGitHubConfigByProduct } from '../../api/github.js';
17
+ import { getSupabase } from '../../supabase/client.js';
18
+ import { logInfo, logWarning } from '../../utils/logger.js';
19
+ import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
20
+ function safeDirName(fullName) {
21
+ return fullName.replace(/[^a-zA-Z0-9._-]/g, '_');
22
+ }
23
+ /**
24
+ * Resolve the repositories a flow targets (by id, preserving the stored
25
+ * order), falling back to the product's primary repo.
26
+ */
27
+ async function resolveTargetRepos(productId, repositoryIds, fallback) {
28
+ if (repositoryIds.length === 0) {
29
+ return [
30
+ {
31
+ fullName: `${fallback.owner}/${fallback.repo}`,
32
+ owner: fallback.owner,
33
+ repo: fallback.repo,
34
+ },
35
+ ];
36
+ }
37
+ const supabase = getSupabase();
38
+ const { data } = await supabase
39
+ .from('repositories')
40
+ .select('id, full_name')
41
+ .in('id', repositoryIds);
42
+ const byId = new Map(((data) ?? []).map((r) => [
43
+ r.id,
44
+ r.full_name,
45
+ ]));
46
+ // Preserve the caller's order (flows.repository_ids is ordered).
47
+ const resolved = [];
48
+ for (const id of repositoryIds) {
49
+ const fullName = byId.get(id);
50
+ if (!fullName) {
51
+ continue;
52
+ }
53
+ const [owner, repo] = fullName.split('/');
54
+ if (!owner || !repo) {
55
+ continue;
56
+ }
57
+ resolved.push({ fullName, owner, repo });
58
+ }
59
+ // If none resolved (deleted repos / RLS), fall back to the primary repo so
60
+ // generation still produces something useful.
61
+ if (resolved.length === 0) {
62
+ return [
63
+ {
64
+ fullName: `${fallback.owner}/${fallback.repo}`,
65
+ owner: fallback.owner,
66
+ repo: fallback.repo,
67
+ },
68
+ ];
69
+ }
70
+ return resolved;
71
+ }
72
+ export async function cloneFlowRepos(opts) {
73
+ const { productId, repositoryIds, workspaceKey, verbose } = opts;
74
+ const gh = await getGitHubConfigByProduct(productId, verbose);
75
+ if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
76
+ return {
77
+ ok: false,
78
+ message: gh.message ||
79
+ 'GitHub repository not configured for this product. Connect a repo first.',
80
+ };
81
+ }
82
+ const targets = await resolveTargetRepos(productId, repositoryIds, {
83
+ owner: gh.owner,
84
+ repo: gh.repo,
85
+ });
86
+ const workspaceRoot = ensureWorkspaceDir();
87
+ const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${productId}`);
88
+ const repos = [];
89
+ for (const target of targets) {
90
+ try {
91
+ // The product-level token (installation or user PAT/OAuth) is reused for
92
+ // every repo; if it can't access one, that clone fails and we skip it
93
+ // rather than aborting the whole generation.
94
+ const { repoPath } = cloneIssueRepo(parentDir, safeDirName(target.fullName), target.owner, target.repo, gh.token);
95
+ repos.push({
96
+ fullName: target.fullName,
97
+ owner: target.owner,
98
+ repo: target.repo,
99
+ dir: repoPath,
100
+ });
101
+ }
102
+ catch (err) {
103
+ logWarning(`Skipping ${target.fullName}: clone failed (${err instanceof Error ? err.message : String(err)})`);
104
+ }
105
+ }
106
+ if (repos.length === 0) {
107
+ return { ok: false, message: 'Failed to clone any of the selected repositories.' };
108
+ }
109
+ if (repos.length > 1) {
110
+ logInfo(`Cloned ${repos.length} repos for ${workspaceKey}: ${repos.map((r) => r.fullName).join(', ')}`);
111
+ }
112
+ return {
113
+ ok: true,
114
+ // Single repo: run directly in its dir. Multi-repo: run in the parent so
115
+ // the agent sees every clone as a subdirectory.
116
+ projectDir: repos.length === 1 ? repos[0].dir : parentDir,
117
+ cleanupDir: parentDir,
118
+ repos,
119
+ };
120
+ }
121
+ /**
122
+ * Build a short note describing the repo scope, appended to the agent's user
123
+ * prompt so it knows whether to map one repo or unify several.
124
+ */
125
+ export function describeRepoScope(repos) {
126
+ if (repos.length <= 1) {
127
+ return '';
128
+ }
129
+ const list = repos.map((r) => `- ${r.fullName} (subdirectory: ${safeDirName(r.fullName)})`);
130
+ return [
131
+ `This product spans ${repos.length} repositories, each cloned into its own subdirectory of the working directory:`,
132
+ ...list,
133
+ 'Explore all of them and produce a single unified flow that spans the repositories.',
134
+ ].join('\n');
135
+ }
@@ -900,11 +900,12 @@ You MUST end your response with a JSON object containing the code refine results
900
900
  **CRITICAL — How to return the result**:
901
901
 
902
902
  Return the extraction by calling the MCP tool
903
- \`mcp__screen-flow__submit_screen_flow\` **exactly once** with three arguments:
903
+ \`mcp__screen-flow__submit_screen_flow\` **exactly once** with these arguments:
904
904
 
905
905
  - \`summary\` — 1-3 sentence narrative of what kind of app this is and its primary user flows
906
906
  - \`nodes\` — array of ScreenSchema objects (every user-facing screen, modal, drawer, tab, or named state)
907
907
  - \`edges\` — array of ScreenEdge objects (transitions between screens)
908
+ - \`theme?\` — the product's visual design tokens \`{ primary?, neutral?, radius?, font? }\`, read from the **user-facing frontend**. When the product spans several repos, decide which one renders the UI users actually see (look for Tailwind config, global CSS variables like \`--primary\`/\`--radius\`, or a design-tokens file) and read its tokens — ignore backend/infra repos. Omit any field you can't determine, and omit \`theme\` entirely if there is no frontend.
908
909
 
909
910
  The tool validates the arguments against the schema. If it returns an error,
910
911
  fix the issue it describes and call the tool again. After a successful call,
@@ -243,6 +243,7 @@ export async function runRunSheet(options) {
243
243
  }
244
244
  const gh = await getGitHubConfigByProduct(release.product_id, verbose);
245
245
  const repoConfigured = gh.configured && gh.token && gh.owner && gh.repo;
246
+ const repoFullName = gh.owner && gh.repo ? `${gh.owner}/${gh.repo}` : null;
246
247
  let repoDir = null;
247
248
  let cloneError = null;
248
249
  let lockPath = null;
@@ -324,7 +325,7 @@ export async function runRunSheet(options) {
324
325
  const agentResult = await generateRunSheetWithAgent({
325
326
  product: {
326
327
  name: product?.name ?? 'Unknown product',
327
- github_repository_full_name: product?.github_repository_full_name ?? null,
328
+ github_repository_full_name: repoFullName,
328
329
  },
329
330
  release: {
330
331
  tag: release.tag,
@@ -348,7 +349,7 @@ export async function runRunSheet(options) {
348
349
  template_snapshot: template,
349
350
  metadata: {
350
351
  clone_error: cloneError,
351
- repo: product?.github_repository_full_name ?? null,
352
+ repo: repoFullName,
352
353
  tag: release.tag,
353
354
  is_draft: isDraft,
354
355
  draft_base_ref: draftBaseRef,
@@ -8,12 +8,12 @@
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 { getGitHubConfigByProduct } from '../../api/github.js';
12
11
  import { DEFAULT_MODEL } from '../../constants.js';
13
12
  import { getSupabase } from '../../supabase/client.js';
14
13
  import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
15
- import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
14
+ import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
16
15
  import { fetchProductBasics } from '../find-shared/mcp.js';
16
+ import { cloneFlowRepos, describeRepoScope } from '../flow-shared/clone-repos.js';
17
17
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
18
18
  import { createScreenFlowCaptureState, createScreenFlowMcpServer, validateConsistency, } from './mcp-server.js';
19
19
  import { createScreenFlowSystemPrompt, createScreenFlowUserPrompt, } from './prompts.js';
@@ -30,36 +30,30 @@ export async function runScreenFlowPhase(options) {
30
30
  logInfo(`Starting screen-flow generation for product ${productId}`);
31
31
  const supabase = getSupabase();
32
32
  await markFlowRunning(supabase, flowId);
33
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
34
- if (!githubConfig.configured ||
35
- !githubConfig.token ||
36
- !githubConfig.owner ||
37
- !githubConfig.repo) {
38
- const msg = githubConfig.message ||
39
- 'GitHub repository not configured for this product. Connect a repo first.';
40
- await markFlowFailed(supabase, flowId, msg);
41
- return { status: 'error', message: msg };
33
+ const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
34
+ const cloneResult = await cloneFlowRepos({
35
+ productId,
36
+ repositoryIds,
37
+ workspaceKey: WORKSPACE_KEY,
38
+ verbose,
39
+ });
40
+ if (!cloneResult.ok) {
41
+ await markFlowFailed(supabase, flowId, cloneResult.message);
42
+ return { status: 'error', message: cloneResult.message };
42
43
  }
43
- let repoPath;
44
+ const { projectDir, cleanupDir, repos } = cloneResult;
44
45
  let succeeded = false;
45
46
  try {
46
- const workspaceRoot = ensureWorkspaceDir();
47
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
48
- ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
49
47
  const product = await fetchProductBasics(productId);
50
- const theme = extractTheme(repoPath);
51
- if (Object.keys(theme).length > 0) {
52
- logInfo(`Extracted theme: ${Object.entries(theme).map(([k, v]) => `${k}=${v}`).join(', ')}`);
53
- await persistTheme(supabase, flowId, theme);
54
- }
55
48
  const systemPrompt = await createScreenFlowSystemPrompt({
56
- projectDir: repoPath,
49
+ projectDir,
57
50
  hasCodebase: true,
58
51
  });
52
+ const repoScope = describeRepoScope(repos);
59
53
  const userPrompt = createScreenFlowUserPrompt({
60
54
  productName: product.name,
61
55
  productDescription: product.description,
62
- guidance,
56
+ guidance: [guidance, repoScope].filter(Boolean).join('\n\n') || undefined,
63
57
  });
64
58
  logInfo('Running Claude screen-flow extraction...');
65
59
  // The agent submits the extraction by calling submit_screen_flow on the
@@ -86,7 +80,7 @@ export async function runScreenFlowPhase(options) {
86
80
  model: DEFAULT_MODEL,
87
81
  maxTurns: MAX_TURNS,
88
82
  permissionMode: 'bypassPermissions',
89
- cwd: repoPath,
83
+ cwd: projectDir,
90
84
  mcpServers: {
91
85
  'screen-flow': mcpServer,
92
86
  },
@@ -104,6 +98,11 @@ export async function runScreenFlowPhase(options) {
104
98
  return { status: 'error', message: msg };
105
99
  }
106
100
  logInfo(`Extraction produced ${extraction.nodes.length} screens / ${extraction.edges.length} transitions`);
101
+ const theme = resolveTheme(extraction.theme, repos);
102
+ if (Object.keys(theme).length > 0) {
103
+ logInfo(`Theme: ${Object.entries(theme).map(([k, v]) => `${k}=${v}`).join(', ')}`);
104
+ await persistTheme(supabase, flowId, theme);
105
+ }
107
106
  const { nodesCreated, edgesCreated } = await persistFlow(supabase, flowId, extraction);
108
107
  await markFlowSuccess(supabase, flowId, extraction.summary);
109
108
  succeeded = true;
@@ -124,9 +123,39 @@ export async function runScreenFlowPhase(options) {
124
123
  }
125
124
  finally {
126
125
  if (succeeded) {
127
- cleanupIssueRepo(repoPath);
126
+ cleanupIssueRepo(cleanupDir);
127
+ }
128
+ }
129
+ }
130
+ /**
131
+ * Settle on the theme to persist. The agent decides which repo's design
132
+ * tokens represent the product (it can see every clone), so its answer wins.
133
+ * Only if it returned nothing do we fall back to deterministic static
134
+ * extraction, scanning each repo and taking the first non-empty result.
135
+ */
136
+ function resolveTheme(agentTheme, repos) {
137
+ const cleaned = agentTheme
138
+ ? Object.fromEntries(Object.entries(agentTheme).filter(([, v]) => v !== null && v !== undefined && v !== ''))
139
+ : {};
140
+ if (Object.keys(cleaned).length > 0) {
141
+ return cleaned;
142
+ }
143
+ for (const repo of repos) {
144
+ const parsed = extractTheme(repo.dir);
145
+ if (Object.keys(parsed).length > 0) {
146
+ return parsed;
128
147
  }
129
148
  }
149
+ return {};
150
+ }
151
+ /** Read the ordered repo set a flow was scoped to (may be empty). */
152
+ async function getFlowRepositoryIds(supabase, flowId) {
153
+ const { data } = await supabase
154
+ .from('flows')
155
+ .select('repository_ids')
156
+ .eq('id', flowId)
157
+ .single();
158
+ return (data?.repository_ids ?? []).filter(Boolean);
130
159
  }
131
160
  // Per-message handler — extracted out of the SDK loop to keep
132
161
  // runScreenFlowPhase under the eslint complexity ceiling.
@@ -172,6 +172,12 @@ export declare function createSubmitScreenFlowTool(state: ScreenFlowCaptureState
172
172
  back: "back";
173
173
  }>;
174
174
  }, z.core.$strip>>;
175
+ theme: z.ZodOptional<z.ZodObject<{
176
+ primary: z.ZodOptional<z.ZodString>;
177
+ neutral: z.ZodOptional<z.ZodString>;
178
+ radius: z.ZodOptional<z.ZodString>;
179
+ font: z.ZodOptional<z.ZodString>;
180
+ }, z.core.$strip>>;
175
181
  }>;
176
182
  /**
177
183
  * Build the `record_progress` tool. A side-channel that lets the agent
@@ -152,6 +152,12 @@ const screenEdgeSchema = z.object({
152
152
  triggerFile: z.string().optional(),
153
153
  kind: z.enum(['navigate', 'modal', 'redirect', 'back']),
154
154
  });
155
+ const themeSchema = z.object({
156
+ primary: z.string().optional(),
157
+ neutral: z.string().optional(),
158
+ radius: z.string().optional(),
159
+ font: z.string().optional(),
160
+ });
155
161
  // ---------------------------------------------------------------------------
156
162
  // Cross-field consistency (Zod can't express this)
157
163
  // ---------------------------------------------------------------------------
@@ -206,11 +212,15 @@ export function createSubmitScreenFlowTool(state) {
206
212
  edges: z
207
213
  .array(screenEdgeSchema)
208
214
  .describe('Transitions between screens. Every fromSlug / toSlug MUST reference a slug present in nodes; drop edges whose endpoints you did not emit.'),
215
+ theme: themeSchema
216
+ .optional()
217
+ .describe("The product's visual design tokens, read from its user-facing frontend (Tailwind config, CSS variables, or a tokens file). For a multi-repo product, pick the repo that renders the UI users see. Omit any field you cannot determine; omit the whole object if there is no frontend."),
209
218
  }, async (args) => {
210
219
  const extraction = {
211
220
  summary: args.summary,
212
221
  nodes: args.nodes,
213
222
  edges: args.edges,
223
+ theme: args.theme,
214
224
  };
215
225
  const { error } = validateConsistency(extraction);
216
226
  if (error) {
@@ -37,5 +37,7 @@ Start by detecting the framework (check package.json / pubspec.yaml / Package.sw
37
37
 
38
38
  Call \`mcp__screen-flow__record_progress\` at each phase boundary so the user can see your progress (otherwise the CLI looks frozen).
39
39
 
40
- When you are done, return the result by **calling the \`mcp__screen-flow__submit_screen_flow\` tool exactly once** with \`summary\`, \`nodes\`, and \`edges\` as arguments. Do not paste the JSON as a fenced text block the tool call is the deliverable. If the tool returns an error, fix the issue it describes and call the tool again.`;
40
+ Also determine the product's visual theme: find the user-facing frontend (when several repos are present, that's the one rendering the UI users see, not a backend or infra repo) and read its design tokens primary/neutral colors, corner radius, and font from its Tailwind config, global CSS variables, or a design-tokens file.
41
+
42
+ When you are done, return the result by **calling the \`mcp__screen-flow__submit_screen_flow\` tool exactly once** with \`summary\`, \`nodes\`, \`edges\`, and (when you found a frontend) \`theme\` as arguments. Do not paste the JSON as a fenced text block — the tool call is the deliverable. If the tool returns an error, fix the issue it describes and call the tool again.`;
41
43
  }
@@ -11,6 +11,7 @@
11
11
  * agent encounters something it can't model cleanly, it falls back to
12
12
  * `{ type: 'custom', label }` rather than guessing.
13
13
  */
14
+ import type { ScreenFlowTheme } from './theme.js';
14
15
  export type ScreenKind = 'page' | 'modal' | 'drawer' | 'tab' | 'state';
15
16
  export type ScreenLayout = 'centered' | 'sidebar' | 'split' | 'list-detail' | 'tabs' | 'stacked';
16
17
  export interface ScreenAction {
@@ -126,5 +127,11 @@ export interface ScreenFlowExtraction {
126
127
  summary: string;
127
128
  nodes: ScreenSchema[];
128
129
  edges: ScreenEdge[];
130
+ /**
131
+ * Visual theme the agent judged best represents the product's user-facing
132
+ * surface. For multi-repo products the agent decides which repo's design
133
+ * tokens to use; omitted when it can't find any.
134
+ */
135
+ theme?: ScreenFlowTheme;
129
136
  }
130
137
  export declare function isScreenFlowExtraction(value: unknown): value is ScreenFlowExtraction;
@@ -2,8 +2,9 @@
2
2
  * Phase: sync-org-repos
3
3
  *
4
4
  * Fetches all repositories from a GitHub organization using the local `gh` CLI,
5
- * then creates a product for each repo that isn't already linked to one
6
- * within the same team.
5
+ * then upserts a row into the team-scoped `repositories` table for each repo.
6
+ * Products are NOT created here — a product is a higher-level grouping that a
7
+ * user links one or more repositories to afterwards.
7
8
  *
8
9
  * Uses `gh api --paginate` for truly unlimited pagination (no hardcoded cap).
9
10
  * Forks and archived repos are filtered out client-side.
@@ -2,8 +2,9 @@
2
2
  * Phase: sync-org-repos
3
3
  *
4
4
  * Fetches all repositories from a GitHub organization using the local `gh` CLI,
5
- * then creates a product for each repo that isn't already linked to one
6
- * within the same team.
5
+ * then upserts a row into the team-scoped `repositories` table for each repo.
6
+ * Products are NOT created here — a product is a higher-level grouping that a
7
+ * user links one or more repositories to afterwards.
7
8
  *
8
9
  * Uses `gh api --paginate` for truly unlimited pagination (no hardcoded cap).
9
10
  * Forks and archived repos are filtered out client-side.
@@ -21,7 +22,7 @@ async function fetchOrgRepos(orgLogin, verbose) {
21
22
  `--paginate`,
22
23
  `/orgs/${encodeURIComponent(orgLogin)}/repos?per_page=100&type=sources&sort=updated`,
23
24
  '--jq',
24
- '.[] | select(.archived == false) | {name, full_name, description, html_url}',
25
+ '.[] | select(.archived == false) | {id, name, full_name, description, html_url, default_branch, private, language}',
25
26
  ], { timeout: 120_000 });
26
27
  if (!stdout.trim()) {
27
28
  return [];
@@ -91,53 +92,53 @@ export async function syncOrgRepos(opts) {
91
92
  logInfo(`Found ${orgRepos.length} repos in ${orgLogin}`);
92
93
  const repoFullNames = orgRepos.map((r) => r.full_name);
93
94
  const { data: existing } = await supabase
94
- .from('products')
95
- .select('github_repository_full_name')
95
+ .from('repositories')
96
+ .select('full_name')
96
97
  .eq('team_id', teamId)
97
- .in('github_repository_full_name', repoFullNames);
98
- const linkedRepos = new Set((existing || []).map((p) => p.github_repository_full_name));
99
- const toCreate = orgRepos.filter((r) => !linkedRepos.has(r.full_name));
100
- if (toCreate.length === 0) {
101
- const msg = `All ${linkedRepos.size} repos already linked to products`;
102
- logInfo(msg);
103
- return {
104
- status: 'success',
105
- message: msg,
106
- total: orgRepos.length,
107
- created: 0,
108
- skipped: linkedRepos.size,
109
- };
110
- }
98
+ .in('full_name', repoFullNames);
99
+ const existingRepos = new Set((existing || []).map((r) => r.full_name));
100
+ const toCreate = orgRepos.filter((r) => !existingRepos.has(r.full_name));
101
+ // Refresh metadata + last_synced_at on every synced repo (new and existing)
102
+ // via upsert keyed on (team_id, full_name).
103
+ const now = new Date().toISOString();
104
+ const rows = orgRepos.map((repo) => ({
105
+ team_id: teamId,
106
+ created_by: userId,
107
+ github_repo_id: repo.id,
108
+ full_name: repo.full_name,
109
+ owner: repo.full_name.split('/')[0] ?? null,
110
+ name: repo.name,
111
+ url: repo.html_url,
112
+ description: repo.description,
113
+ default_branch: repo.default_branch,
114
+ private: repo.private,
115
+ language: repo.language,
116
+ last_synced_at: now,
117
+ }));
111
118
  if (verbose) {
112
119
  for (const r of toCreate) {
113
- logDebug(` Will create product: ${r.full_name}`);
120
+ logDebug(` Will create repository: ${r.full_name}`);
114
121
  }
115
122
  }
116
- const rows = toCreate.map((repo) => ({
117
- name: repo.name,
118
- description: repo.description,
119
- team_id: teamId,
120
- created_by: userId,
121
- github_repository_full_name: repo.full_name,
122
- github_repository_url: repo.html_url,
123
- }));
124
- const { error: insertError } = await supabase.from('products').insert(rows);
125
- if (insertError) {
126
- logError(`Failed to insert products: ${insertError.message}`);
123
+ const { error: upsertError } = await supabase
124
+ .from('repositories')
125
+ .upsert(rows, { onConflict: 'team_id,full_name' });
126
+ if (upsertError) {
127
+ logError(`Failed to upsert repositories: ${upsertError.message}`);
127
128
  return {
128
129
  status: 'error',
129
- message: `Failed to create products: ${insertError.message}`,
130
+ message: `Failed to sync repositories: ${upsertError.message}`,
130
131
  total: orgRepos.length,
131
132
  created: 0,
132
- skipped: linkedRepos.size,
133
+ skipped: existingRepos.size,
133
134
  };
134
135
  }
135
136
  return {
136
137
  status: 'success',
137
- message: `Created ${toCreate.length} products, ${linkedRepos.size} already existed`,
138
+ message: `Synced ${orgRepos.length} repos: ${toCreate.length} new, ${existingRepos.size} updated`,
138
139
  total: orgRepos.length,
139
140
  created: toCreate.length,
140
- skipped: linkedRepos.size,
141
+ skipped: existingRepos.size,
141
142
  repos: toCreate.map((r) => r.full_name),
142
143
  };
143
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -50,8 +50,8 @@
50
50
  "commander": "^12.0.0",
51
51
  "cosmiconfig": "^9.0.0",
52
52
  "dotenv": "^16.4.5",
53
- "edsger-contract": "0.5.0",
54
- "edsger-tools": "0.5.0",
53
+ "edsger-contract": "0.6.0",
54
+ "edsger-tools": "0.6.0",
55
55
  "gray-matter": "^4.0.3",
56
56
  "zod": "^4.0.0"
57
57
  },