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.
- package/dist/commands/quality-benchmark/index.d.ts +2 -0
- package/dist/commands/quality-benchmark/index.js +33 -2
- package/dist/commands/session-turn/index.d.ts +15 -0
- package/dist/commands/session-turn/index.js +177 -0
- package/dist/commands/sync-org-repos/index.d.ts +1 -1
- package/dist/commands/sync-org-repos/index.js +2 -2
- package/dist/index.js +34 -4
- package/dist/phases/data-flow/index.js +27 -19
- package/dist/phases/flow-shared/clone-repos.d.ts +44 -0
- package/dist/phases/flow-shared/clone-repos.js +135 -0
- package/dist/phases/output-contracts.js +2 -1
- package/dist/phases/run-sheet/index.js +3 -2
- package/dist/phases/screen-flow/index.js +53 -24
- package/dist/phases/screen-flow/mcp-server.d.ts +6 -0
- package/dist/phases/screen-flow/mcp-server.js +10 -0
- package/dist/phases/screen-flow/prompts.js +3 -1
- package/dist/phases/screen-flow/types.d.ts +7 -0
- package/dist/phases/sync-org-repos/index.d.ts +3 -2
- package/dist/phases/sync-org-repos/index.js +36 -35
- package/package.json +3 -3
|
@@ -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
|
|
53
|
-
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
|
|
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
|
|
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} ·
|
|
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 {
|
|
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>',
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
|
6
|
-
*
|
|
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('
|
|
95
|
-
.select('
|
|
95
|
+
.from('repositories')
|
|
96
|
+
.select('full_name')
|
|
96
97
|
.eq('team_id', teamId)
|
|
97
|
-
.in('
|
|
98
|
-
const
|
|
99
|
-
const toCreate = orgRepos.filter((r) => !
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
120
|
+
logDebug(` Will create repository: ${r.full_name}`);
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
130
|
+
message: `Failed to sync repositories: ${upsertError.message}`,
|
|
130
131
|
total: orgRepos.length,
|
|
131
132
|
created: 0,
|
|
132
|
-
skipped:
|
|
133
|
+
skipped: existingRepos.size,
|
|
133
134
|
};
|
|
134
135
|
}
|
|
135
136
|
return {
|
|
136
137
|
status: 'success',
|
|
137
|
-
message: `
|
|
138
|
+
message: `Synced ${orgRepos.length} repos: ${toCreate.length} new, ${existingRepos.size} updated`,
|
|
138
139
|
total: orgRepos.length,
|
|
139
140
|
created: toCreate.length,
|
|
140
|
-
skipped:
|
|
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.
|
|
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.
|
|
54
|
-
"edsger-tools": "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
|
},
|