edsger 0.59.0 → 0.60.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/recipes/index.d.ts +15 -0
- package/dist/commands/recipes/index.js +34 -0
- package/dist/index.js +7 -7
- package/dist/phases/recipes/index.d.ts +56 -0
- package/dist/phases/recipes/index.js +301 -0
- package/dist/phases/recipes/mcp-server.d.ts +63 -0
- package/dist/phases/recipes/mcp-server.js +204 -0
- package/dist/phases/recipes/prompts.d.ts +35 -0
- package/dist/phases/recipes/prompts.js +105 -0
- package/dist/phases/recipes/types.d.ts +42 -0
- package/dist/phases/recipes/types.js +16 -0
- package/package.json +1 -1
- package/vitest.config.ts +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger recipes <productId> --scan-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Clones the product's repo, asks Claude to identify implementation recipes
|
|
5
|
+
* the product uses, and persists them via the recipes / product_recipes
|
|
6
|
+
* tables. The desktop UI creates a pending recipe_scans row first then
|
|
7
|
+
* invokes the CLI with --scan-id; the CLI flips status running →
|
|
8
|
+
* success/failed and writes recipes via the MCP toolkit.
|
|
9
|
+
*/
|
|
10
|
+
export interface RecipesCliOptions {
|
|
11
|
+
scanId: string;
|
|
12
|
+
guidance?: string;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function runRecipes(productId: string, options: RecipesCliOptions): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger recipes <productId> --scan-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Clones the product's repo, asks Claude to identify implementation recipes
|
|
5
|
+
* the product uses, and persists them via the recipes / product_recipes
|
|
6
|
+
* tables. The desktop UI creates a pending recipe_scans row first then
|
|
7
|
+
* invokes the CLI with --scan-id; the CLI flips status running →
|
|
8
|
+
* success/failed and writes recipes via the MCP toolkit.
|
|
9
|
+
*/
|
|
10
|
+
import { runRecipesPhase } from '../../phases/recipes/index.js';
|
|
11
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
12
|
+
export async function runRecipes(productId, options) {
|
|
13
|
+
const { scanId, guidance, verbose } = options;
|
|
14
|
+
if (!productId) {
|
|
15
|
+
throw new Error('Product ID is required for recipes');
|
|
16
|
+
}
|
|
17
|
+
if (!scanId) {
|
|
18
|
+
throw new Error('--scan-id is required (the pending recipe_scans row id)');
|
|
19
|
+
}
|
|
20
|
+
logInfo(`Starting recipes scan for product ${productId}`);
|
|
21
|
+
const result = await runRecipesPhase({
|
|
22
|
+
productId,
|
|
23
|
+
scanId,
|
|
24
|
+
guidance,
|
|
25
|
+
verbose,
|
|
26
|
+
});
|
|
27
|
+
if (result.status === 'success') {
|
|
28
|
+
logSuccess(result.message);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
logError(result.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,8 +24,8 @@ import { runIntelligence } from './commands/intelligence/index.js';
|
|
|
24
24
|
import { runIssueAnalysisCommand } from './commands/issue-analysis/index.js';
|
|
25
25
|
import { runPRResolve } from './commands/pr-resolve/index.js';
|
|
26
26
|
import { runPRReview } from './commands/pr-review/index.js';
|
|
27
|
-
import { runProductTechniques } from './commands/product-techniques/index.js';
|
|
28
27
|
import { runProductTestCases } from './commands/product-test-cases/index.js';
|
|
28
|
+
import { runRecipes } from './commands/recipes/index.js';
|
|
29
29
|
import { runQualityBenchmarkCli } from './commands/quality-benchmark/index.js';
|
|
30
30
|
import { runRefactor } from './commands/refactor/refactor.js';
|
|
31
31
|
import { runReleaseSyncCommand } from './commands/release-sync/index.js';
|
|
@@ -695,18 +695,18 @@ program
|
|
|
695
695
|
}
|
|
696
696
|
});
|
|
697
697
|
// ============================================================
|
|
698
|
-
// Subcommand: edsger
|
|
698
|
+
// Subcommand: edsger recipes <productId>
|
|
699
699
|
// ============================================================
|
|
700
700
|
program
|
|
701
|
-
.command('
|
|
702
|
-
.description("
|
|
703
|
-
.requiredOption('--
|
|
701
|
+
.command('recipes <productId>')
|
|
702
|
+
.description("Scan a product's repo for the implementation recipes it uses (which services/tools are chained together to deliver each capability) and persist them via the recipes / product_recipes tables. Writes against the pending recipe_scans row identified by --scan-id.")
|
|
703
|
+
.requiredOption('--scan-id <id>', 'Pending recipe_scans row id to drive (created by the desktop UI before invocation)')
|
|
704
704
|
.option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
|
|
705
705
|
.option('-v, --verbose', 'Verbose output')
|
|
706
706
|
.action(async (productId, opts) => {
|
|
707
707
|
try {
|
|
708
|
-
await
|
|
709
|
-
|
|
708
|
+
await runRecipes(productId, {
|
|
709
|
+
scanId: opts.scanId,
|
|
710
710
|
guidance: opts.guidance,
|
|
711
711
|
verbose: opts.verbose,
|
|
712
712
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipes phase: clone the product's repo, ask Claude to identify each
|
|
3
|
+
* non-trivial capability the product implements, and persist HOW it's built
|
|
4
|
+
* via the recipes / product_recipes tables.
|
|
5
|
+
*
|
|
6
|
+
* Production-grade behaviours layered on top of the basic agent loop:
|
|
7
|
+
*
|
|
8
|
+
* - Heartbeat: `last_heartbeat_at` on the recipe_scans row is refreshed
|
|
9
|
+
* on every assistant message so the reader can detect stalled / crashed
|
|
10
|
+
* runs (see desktop-app/.../services/db/recipe-scans.ts for the lazy
|
|
11
|
+
* reaper).
|
|
12
|
+
* - Cancellation-safe writes: markRunning / markSuccess / markFailed only
|
|
13
|
+
* touch rows whose status is in {pending, running}. If the user clicked
|
|
14
|
+
* Stop and the row is now 'cancelled', the final write no-ops.
|
|
15
|
+
* - Per-call MCP writes: agent commits each create / update / link /
|
|
16
|
+
* unlink as it goes. There is no "submit at the end" buffer — partial
|
|
17
|
+
* progress survives even if the agent later errors out.
|
|
18
|
+
*/
|
|
19
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
20
|
+
import type { RecipeSummary } from './types.js';
|
|
21
|
+
export interface RecipesPhaseOptions {
|
|
22
|
+
productId: string;
|
|
23
|
+
scanId: string;
|
|
24
|
+
guidance?: string;
|
|
25
|
+
verbose?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface RecipesPhaseResult {
|
|
28
|
+
status: 'success' | 'error' | 'cancelled';
|
|
29
|
+
message: string;
|
|
30
|
+
counts?: {
|
|
31
|
+
created: number;
|
|
32
|
+
updated: number;
|
|
33
|
+
linked: number;
|
|
34
|
+
unlinked: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export declare function runRecipesPhase(options: RecipesPhaseOptions): Promise<RecipesPhaseResult>;
|
|
38
|
+
export declare function getProductTeamId(supabase: SupabaseClient, productId: string): Promise<string | null>;
|
|
39
|
+
export declare function getScanCreator(supabase: SupabaseClient, scanId: string): Promise<{
|
|
40
|
+
created_by: string;
|
|
41
|
+
} | null>;
|
|
42
|
+
export declare function listTeamRecipes(supabase: SupabaseClient, teamId: string): Promise<RecipeSummary[]>;
|
|
43
|
+
export declare function listProductRecipeLinks(supabase: SupabaseClient, productId: string): Promise<{
|
|
44
|
+
recipe_id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
}[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Claim the row by flipping `pending` → `running`. Returns true on success
|
|
49
|
+
* (we won the claim) and false when the row has already moved on (e.g. user
|
|
50
|
+
* cancelled before the CLI started). Bounded by the status filter so we
|
|
51
|
+
* can't accidentally resurrect a 'cancelled' row.
|
|
52
|
+
*/
|
|
53
|
+
export declare function markRunning(supabase: SupabaseClient, scanId: string): Promise<boolean>;
|
|
54
|
+
export declare function heartbeat(supabase: SupabaseClient, scanId: string): Promise<void>;
|
|
55
|
+
export declare function markFailed(supabase: SupabaseClient, scanId: string, errorMessage: string): Promise<boolean>;
|
|
56
|
+
export declare function markSuccess(supabase: SupabaseClient, scanId: string): Promise<boolean>;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipes phase: clone the product's repo, ask Claude to identify each
|
|
3
|
+
* non-trivial capability the product implements, and persist HOW it's built
|
|
4
|
+
* via the recipes / product_recipes tables.
|
|
5
|
+
*
|
|
6
|
+
* Production-grade behaviours layered on top of the basic agent loop:
|
|
7
|
+
*
|
|
8
|
+
* - Heartbeat: `last_heartbeat_at` on the recipe_scans row is refreshed
|
|
9
|
+
* on every assistant message so the reader can detect stalled / crashed
|
|
10
|
+
* runs (see desktop-app/.../services/db/recipe-scans.ts for the lazy
|
|
11
|
+
* reaper).
|
|
12
|
+
* - Cancellation-safe writes: markRunning / markSuccess / markFailed only
|
|
13
|
+
* touch rows whose status is in {pending, running}. If the user clicked
|
|
14
|
+
* Stop and the row is now 'cancelled', the final write no-ops.
|
|
15
|
+
* - Per-call MCP writes: agent commits each create / update / link /
|
|
16
|
+
* unlink as it goes. There is no "submit at the end" buffer — partial
|
|
17
|
+
* progress survives even if the agent later errors out.
|
|
18
|
+
*/
|
|
19
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
21
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
22
|
+
import { getSupabase } from '../../supabase/client.js';
|
|
23
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
24
|
+
import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
|
|
25
|
+
import { fetchProductBasics } from '../find-shared/mcp.js';
|
|
26
|
+
import { createPromptGenerator, extractTextFromContent, } from '../pr-shared/agent-utils.js';
|
|
27
|
+
import { createRecipesMcpServer, createRecipesMutationCounts, } from './mcp-server.js';
|
|
28
|
+
import { createRecipesSystemPrompt, createRecipesUserPrompt, } from './prompts.js';
|
|
29
|
+
const WORKSPACE_KEY = 'recipes';
|
|
30
|
+
const MAX_TURNS = 200;
|
|
31
|
+
// Heartbeat cadence: at most one DB write per HEARTBEAT_MIN_INTERVAL_MS.
|
|
32
|
+
// Triggered on every assistant message so a stalled agent (no messages
|
|
33
|
+
// flowing) lets the row go stale and the reader can mark it failed.
|
|
34
|
+
const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
|
|
35
|
+
export async function runRecipesPhase(options) {
|
|
36
|
+
const { productId, scanId, guidance, verbose } = options;
|
|
37
|
+
logInfo(`Starting recipes scan for product ${productId}`);
|
|
38
|
+
const supabase = getSupabase();
|
|
39
|
+
const claimed = await markRunning(supabase, scanId);
|
|
40
|
+
if (!claimed) {
|
|
41
|
+
return {
|
|
42
|
+
status: 'cancelled',
|
|
43
|
+
message: 'Recipe scan row is no longer in a runnable state (likely cancelled before the CLI started)',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const teamId = await getProductTeamId(supabase, productId);
|
|
47
|
+
if (!teamId) {
|
|
48
|
+
const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
|
|
49
|
+
await markFailed(supabase, scanId, msg);
|
|
50
|
+
return { status: 'error', message: msg };
|
|
51
|
+
}
|
|
52
|
+
const githubConfig = await getGitHubConfigByProduct(productId, verbose);
|
|
53
|
+
if (!githubConfig.configured ||
|
|
54
|
+
!githubConfig.token ||
|
|
55
|
+
!githubConfig.owner ||
|
|
56
|
+
!githubConfig.repo) {
|
|
57
|
+
const msg = githubConfig.message ||
|
|
58
|
+
'GitHub repository not configured for this product. Connect a repo first.';
|
|
59
|
+
await markFailed(supabase, scanId, msg);
|
|
60
|
+
return { status: 'error', message: msg };
|
|
61
|
+
}
|
|
62
|
+
let repoPath;
|
|
63
|
+
let succeeded = false;
|
|
64
|
+
try {
|
|
65
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
66
|
+
const repoKey = `${WORKSPACE_KEY}-${productId}`;
|
|
67
|
+
({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
|
|
68
|
+
const [product, scanMeta, teamRecipes, existingLinks] = await Promise.all([
|
|
69
|
+
fetchProductBasics(productId),
|
|
70
|
+
getScanCreator(supabase, scanId),
|
|
71
|
+
listTeamRecipes(supabase, teamId),
|
|
72
|
+
listProductRecipeLinks(supabase, productId),
|
|
73
|
+
]);
|
|
74
|
+
if (!scanMeta) {
|
|
75
|
+
const msg = 'recipe_scans row vanished mid-run; aborting';
|
|
76
|
+
await markFailed(supabase, scanId, msg);
|
|
77
|
+
return { status: 'error', message: msg };
|
|
78
|
+
}
|
|
79
|
+
const systemPrompt = createRecipesSystemPrompt();
|
|
80
|
+
const userPrompt = createRecipesUserPrompt({
|
|
81
|
+
productName: product.name,
|
|
82
|
+
productDescription: product.description,
|
|
83
|
+
guidance,
|
|
84
|
+
teamRecipes,
|
|
85
|
+
existingLinks: existingLinks.map((l) => ({
|
|
86
|
+
recipeId: l.recipe_id,
|
|
87
|
+
name: l.name,
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
const counts = createRecipesMutationCounts();
|
|
91
|
+
const existingLinkIds = new Set(existingLinks.map((l) => l.recipe_id));
|
|
92
|
+
const mcpServer = createRecipesMcpServer({
|
|
93
|
+
supabase,
|
|
94
|
+
teamId,
|
|
95
|
+
productId,
|
|
96
|
+
createdBy: scanMeta.created_by,
|
|
97
|
+
}, counts, teamRecipes, existingLinkIds);
|
|
98
|
+
logInfo('Running Claude agent to identify recipes...');
|
|
99
|
+
let lastHeartbeatAt = 0;
|
|
100
|
+
for await (const message of query({
|
|
101
|
+
prompt: createPromptGenerator(userPrompt),
|
|
102
|
+
options: {
|
|
103
|
+
systemPrompt: {
|
|
104
|
+
type: 'preset',
|
|
105
|
+
preset: 'claude_code',
|
|
106
|
+
append: systemPrompt,
|
|
107
|
+
},
|
|
108
|
+
model: DEFAULT_MODEL,
|
|
109
|
+
maxTurns: MAX_TURNS,
|
|
110
|
+
permissionMode: 'bypassPermissions',
|
|
111
|
+
cwd: repoPath,
|
|
112
|
+
mcpServers: {
|
|
113
|
+
recipes: mcpServer,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
})) {
|
|
117
|
+
if (message.type === 'assistant') {
|
|
118
|
+
extractTextFromContent(message.message?.content ?? [], verbose);
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (now - lastHeartbeatAt >= HEARTBEAT_MIN_INTERVAL_MS) {
|
|
121
|
+
lastHeartbeatAt = now;
|
|
122
|
+
await heartbeat(supabase, scanId);
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (message.type !== 'result') {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (message.subtype !== 'success') {
|
|
130
|
+
const msg = `Recipes scan failed: agent ${message.subtype}`;
|
|
131
|
+
const written = await markFailed(supabase, scanId, msg);
|
|
132
|
+
return {
|
|
133
|
+
status: written ? 'error' : 'cancelled',
|
|
134
|
+
message: written
|
|
135
|
+
? msg
|
|
136
|
+
: 'Scan was cancelled while the agent was running',
|
|
137
|
+
counts,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const written = await markSuccess(supabase, scanId);
|
|
141
|
+
if (!written) {
|
|
142
|
+
return {
|
|
143
|
+
status: 'cancelled',
|
|
144
|
+
message: 'Scan was cancelled before the result could be written',
|
|
145
|
+
counts,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
succeeded = true;
|
|
149
|
+
const summary = `created ${counts.created}, updated ${counts.updated}, linked ${counts.linked}, unlinked ${counts.unlinked}`;
|
|
150
|
+
logSuccess(`Recipes scan complete — ${summary}`);
|
|
151
|
+
return {
|
|
152
|
+
status: 'success',
|
|
153
|
+
message: `Recipes scan complete (${summary})`,
|
|
154
|
+
counts,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const msg = 'Recipes scan ended without a result message';
|
|
158
|
+
await markFailed(supabase, scanId, msg);
|
|
159
|
+
return { status: 'error', message: msg, counts };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
163
|
+
logError(`Recipes scan failed: ${errorMessage}`);
|
|
164
|
+
await markFailed(supabase, scanId, errorMessage);
|
|
165
|
+
return { status: 'error', message: errorMessage };
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (succeeded) {
|
|
169
|
+
cleanupIssueRepo(repoPath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// DB helpers — exported for unit tests
|
|
175
|
+
// ============================================================================
|
|
176
|
+
export async function getProductTeamId(supabase, productId) {
|
|
177
|
+
const { data, error } = await supabase
|
|
178
|
+
.from('products')
|
|
179
|
+
.select('team_id')
|
|
180
|
+
.eq('id', productId)
|
|
181
|
+
.maybeSingle();
|
|
182
|
+
if (error || !data) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return data.team_id ?? null;
|
|
186
|
+
}
|
|
187
|
+
export async function getScanCreator(supabase, scanId) {
|
|
188
|
+
const { data, error } = await supabase
|
|
189
|
+
.from('recipe_scans')
|
|
190
|
+
.select('created_by')
|
|
191
|
+
.eq('id', scanId)
|
|
192
|
+
.maybeSingle();
|
|
193
|
+
if (error || !data) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return data;
|
|
197
|
+
}
|
|
198
|
+
export async function listTeamRecipes(supabase, teamId) {
|
|
199
|
+
const { data, error } = await supabase
|
|
200
|
+
.from('recipes')
|
|
201
|
+
.select('id, name, summary, services')
|
|
202
|
+
.eq('team_id', teamId)
|
|
203
|
+
.order('updated_at', { ascending: false });
|
|
204
|
+
if (error || !data) {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
return data.map((r) => ({
|
|
208
|
+
id: r.id,
|
|
209
|
+
name: r.name,
|
|
210
|
+
summary: r.summary,
|
|
211
|
+
services: Array.isArray(r.services) ? r.services : [],
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
export async function listProductRecipeLinks(supabase, productId) {
|
|
215
|
+
const { data, error } = await supabase
|
|
216
|
+
.from('product_recipes')
|
|
217
|
+
.select('recipe_id, recipes(name)')
|
|
218
|
+
.eq('product_id', productId);
|
|
219
|
+
if (error || !data) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const rows = data;
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const r of rows) {
|
|
225
|
+
const recipe = Array.isArray(r.recipes) ? r.recipes[0] : r.recipes;
|
|
226
|
+
if (recipe) {
|
|
227
|
+
out.push({ recipe_id: r.recipe_id, name: recipe.name });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Claim the row by flipping `pending` → `running`. Returns true on success
|
|
234
|
+
* (we won the claim) and false when the row has already moved on (e.g. user
|
|
235
|
+
* cancelled before the CLI started). Bounded by the status filter so we
|
|
236
|
+
* can't accidentally resurrect a 'cancelled' row.
|
|
237
|
+
*/
|
|
238
|
+
export async function markRunning(supabase, scanId) {
|
|
239
|
+
const { data, error } = await supabase
|
|
240
|
+
.from('recipe_scans')
|
|
241
|
+
.update({
|
|
242
|
+
status: 'running',
|
|
243
|
+
error: null,
|
|
244
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
245
|
+
})
|
|
246
|
+
.eq('id', scanId)
|
|
247
|
+
.in('status', ['pending', 'running'])
|
|
248
|
+
.select('id')
|
|
249
|
+
.maybeSingle();
|
|
250
|
+
if (error) {
|
|
251
|
+
logWarning(`Could not mark scan as running: ${error.message}`);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
return data !== null;
|
|
255
|
+
}
|
|
256
|
+
export async function heartbeat(supabase, scanId) {
|
|
257
|
+
const { error } = await supabase
|
|
258
|
+
.from('recipe_scans')
|
|
259
|
+
.update({ last_heartbeat_at: new Date().toISOString() })
|
|
260
|
+
.eq('id', scanId)
|
|
261
|
+
.eq('status', 'running');
|
|
262
|
+
if (error) {
|
|
263
|
+
logWarning(`Heartbeat failed: ${error.message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export async function markFailed(supabase, scanId, errorMessage) {
|
|
267
|
+
const { data, error } = await supabase
|
|
268
|
+
.from('recipe_scans')
|
|
269
|
+
.update({
|
|
270
|
+
status: 'failed',
|
|
271
|
+
error: errorMessage,
|
|
272
|
+
completed_at: new Date().toISOString(),
|
|
273
|
+
})
|
|
274
|
+
.eq('id', scanId)
|
|
275
|
+
.in('status', ['pending', 'running'])
|
|
276
|
+
.select('id')
|
|
277
|
+
.maybeSingle();
|
|
278
|
+
if (error) {
|
|
279
|
+
logWarning(`Could not mark scan as failed: ${error.message}`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return data !== null;
|
|
283
|
+
}
|
|
284
|
+
export async function markSuccess(supabase, scanId) {
|
|
285
|
+
const { data, error } = await supabase
|
|
286
|
+
.from('recipe_scans')
|
|
287
|
+
.update({
|
|
288
|
+
status: 'success',
|
|
289
|
+
error: null,
|
|
290
|
+
completed_at: new Date().toISOString(),
|
|
291
|
+
})
|
|
292
|
+
.eq('id', scanId)
|
|
293
|
+
.in('status', ['pending', 'running'])
|
|
294
|
+
.select('id')
|
|
295
|
+
.maybeSingle();
|
|
296
|
+
if (error) {
|
|
297
|
+
logWarning(`Could not mark scan as success: ${error.message}`);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return data !== null;
|
|
301
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing the recipes toolkit. Five tools:
|
|
3
|
+
*
|
|
4
|
+
* - get_recipe_detail read-only: fetch full content of a team recipe
|
|
5
|
+
* - create_recipe INSERT recipe + INSERT product_recipe link
|
|
6
|
+
* - update_recipe UPDATE recipe (latest-wins overwrite) + UPSERT link
|
|
7
|
+
* - link_recipe UPSERT product_recipe link only (no content change)
|
|
8
|
+
* - unlink_recipe DELETE product_recipe link for a previously-linked
|
|
9
|
+
* recipe the agent confirmed is no longer present
|
|
10
|
+
*
|
|
11
|
+
* All writes are scoped to:
|
|
12
|
+
* - the active recipe_scan's product_id (the link target)
|
|
13
|
+
* - the team that owns that product (recipes are team-scoped)
|
|
14
|
+
*
|
|
15
|
+
* The handlers persist directly via the Supabase client passed in by the
|
|
16
|
+
* orchestrator — there is no captured-extraction buffer to flush at the
|
|
17
|
+
* end. Each tool call is its own committed effect.
|
|
18
|
+
*/
|
|
19
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { type RecipeSummary } from './types.js';
|
|
22
|
+
export interface RecipesToolContext {
|
|
23
|
+
supabase: SupabaseClient;
|
|
24
|
+
teamId: string;
|
|
25
|
+
productId: string;
|
|
26
|
+
createdBy: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Records how many of each kind of mutation the agent made — exported so the
|
|
30
|
+
* orchestrator can print a summary line at the end of the scan.
|
|
31
|
+
*/
|
|
32
|
+
export interface RecipesMutationCounts {
|
|
33
|
+
created: number;
|
|
34
|
+
updated: number;
|
|
35
|
+
linked: number;
|
|
36
|
+
unlinked: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function createRecipesMutationCounts(): RecipesMutationCounts;
|
|
39
|
+
export declare function createGetRecipeDetailTool(ctx: RecipesToolContext, teamRecipeIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
40
|
+
recipe_id: z.ZodString;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function createCreateRecipeTool(ctx: RecipesToolContext, counts: RecipesMutationCounts, teamRecipeIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
43
|
+
name: z.ZodString;
|
|
44
|
+
summary: z.ZodString;
|
|
45
|
+
content: z.ZodString;
|
|
46
|
+
services: z.ZodArray<z.ZodString>;
|
|
47
|
+
evidence: z.ZodString;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function createUpdateRecipeTool(ctx: RecipesToolContext, counts: RecipesMutationCounts, teamRecipeIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
50
|
+
recipe_id: z.ZodString;
|
|
51
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
52
|
+
content: z.ZodOptional<z.ZodString>;
|
|
53
|
+
services: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
54
|
+
evidence: z.ZodString;
|
|
55
|
+
}>;
|
|
56
|
+
export declare function createLinkRecipeTool(ctx: RecipesToolContext, counts: RecipesMutationCounts, teamRecipeIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
57
|
+
recipe_id: z.ZodString;
|
|
58
|
+
evidence: z.ZodString;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function createUnlinkRecipeTool(ctx: RecipesToolContext, counts: RecipesMutationCounts, existingLinkIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
61
|
+
recipe_id: z.ZodString;
|
|
62
|
+
}>;
|
|
63
|
+
export declare function createRecipesMcpServer(ctx: RecipesToolContext, counts: RecipesMutationCounts, teamRecipes: RecipeSummary[], existingLinkIds: Set<string>): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing the recipes toolkit. Five tools:
|
|
3
|
+
*
|
|
4
|
+
* - get_recipe_detail read-only: fetch full content of a team recipe
|
|
5
|
+
* - create_recipe INSERT recipe + INSERT product_recipe link
|
|
6
|
+
* - update_recipe UPDATE recipe (latest-wins overwrite) + UPSERT link
|
|
7
|
+
* - link_recipe UPSERT product_recipe link only (no content change)
|
|
8
|
+
* - unlink_recipe DELETE product_recipe link for a previously-linked
|
|
9
|
+
* recipe the agent confirmed is no longer present
|
|
10
|
+
*
|
|
11
|
+
* All writes are scoped to:
|
|
12
|
+
* - the active recipe_scan's product_id (the link target)
|
|
13
|
+
* - the team that owns that product (recipes are team-scoped)
|
|
14
|
+
*
|
|
15
|
+
* The handlers persist directly via the Supabase client passed in by the
|
|
16
|
+
* orchestrator — there is no captured-extraction buffer to flush at the
|
|
17
|
+
* end. Each tool call is its own committed effect.
|
|
18
|
+
*/
|
|
19
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { RECIPE_CONTENT_MAX, RECIPE_EVIDENCE_MAX, RECIPE_NAME_MAX, RECIPE_SERVICE_NAME_MAX, RECIPE_SERVICES_MAX, RECIPE_SUMMARY_MAX, } from './types.js';
|
|
22
|
+
export function createRecipesMutationCounts() {
|
|
23
|
+
return { created: 0, updated: 0, linked: 0, unlinked: 0 };
|
|
24
|
+
}
|
|
25
|
+
const servicesSchema = z
|
|
26
|
+
.array(z.string().min(1).max(RECIPE_SERVICE_NAME_MAX))
|
|
27
|
+
.max(RECIPE_SERVICES_MAX)
|
|
28
|
+
.describe('Sorted list of canonical service/tool/library names this recipe chains together (e.g. ["ElevenLabs","ffmpeg","Whisper"]). Sort alphabetically.');
|
|
29
|
+
function normalizeServices(services) {
|
|
30
|
+
const cleaned = services.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
31
|
+
return Array.from(new Set(cleaned)).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
32
|
+
}
|
|
33
|
+
function textError(message) {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: 'text', text: message }],
|
|
36
|
+
isError: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function textOk(message) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text: message }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function createGetRecipeDetailTool(ctx, teamRecipeIds) {
|
|
45
|
+
return tool('get_recipe_detail', 'Fetch the full markdown content of an existing team recipe by id. Call this when the prompt summary is not enough to decide between link / update / create.', {
|
|
46
|
+
recipe_id: z.string().uuid().describe('id from the team recipe list'),
|
|
47
|
+
}, async (args) => {
|
|
48
|
+
if (!teamRecipeIds.has(args.recipe_id)) {
|
|
49
|
+
return textError(`recipe_id ${args.recipe_id} is not in this team's recipe list.`);
|
|
50
|
+
}
|
|
51
|
+
const { data, error } = await ctx.supabase
|
|
52
|
+
.from('recipes')
|
|
53
|
+
.select('id, name, summary, content, services')
|
|
54
|
+
.eq('id', args.recipe_id)
|
|
55
|
+
.eq('team_id', ctx.teamId)
|
|
56
|
+
.maybeSingle();
|
|
57
|
+
if (error) {
|
|
58
|
+
return textError(`Failed to read recipe: ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
if (!data) {
|
|
61
|
+
return textError(`Recipe ${args.recipe_id} not found.`);
|
|
62
|
+
}
|
|
63
|
+
return textOk(JSON.stringify(data));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function createCreateRecipeTool(ctx, counts, teamRecipeIds) {
|
|
67
|
+
return tool('create_recipe', 'Register a brand-new recipe in the team library AND link it to this product. Use only when no existing recipe matches the capability. Pass a fully-formed recipe — name, one-paragraph summary, markdown content, sorted services list, and product-specific evidence.', {
|
|
68
|
+
name: z.string().min(1).max(RECIPE_NAME_MAX),
|
|
69
|
+
summary: z.string().min(1).max(RECIPE_SUMMARY_MAX),
|
|
70
|
+
content: z.string().min(1).max(RECIPE_CONTENT_MAX),
|
|
71
|
+
services: servicesSchema,
|
|
72
|
+
evidence: z
|
|
73
|
+
.string()
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(RECIPE_EVIDENCE_MAX)
|
|
76
|
+
.describe('Plain-text paragraph saying where in THIS product the recipe shows up (file paths, entry points).'),
|
|
77
|
+
}, async (args) => {
|
|
78
|
+
const services = normalizeServices(args.services);
|
|
79
|
+
const insertRecipe = await ctx.supabase
|
|
80
|
+
.from('recipes')
|
|
81
|
+
.insert({
|
|
82
|
+
team_id: ctx.teamId,
|
|
83
|
+
name: args.name.trim(),
|
|
84
|
+
summary: args.summary.trim(),
|
|
85
|
+
content: args.content,
|
|
86
|
+
services,
|
|
87
|
+
created_by: ctx.createdBy,
|
|
88
|
+
})
|
|
89
|
+
.select('id')
|
|
90
|
+
.single();
|
|
91
|
+
if (insertRecipe.error || !insertRecipe.data) {
|
|
92
|
+
return textError(`Failed to create recipe: ${insertRecipe.error?.message ?? 'unknown error'}`);
|
|
93
|
+
}
|
|
94
|
+
const recipeId = insertRecipe.data.id;
|
|
95
|
+
const linkErr = await ctx.supabase.from('product_recipes').insert({
|
|
96
|
+
product_id: ctx.productId,
|
|
97
|
+
recipe_id: recipeId,
|
|
98
|
+
evidence: args.evidence.trim(),
|
|
99
|
+
});
|
|
100
|
+
if (linkErr.error) {
|
|
101
|
+
return textError(`Recipe created (${recipeId}) but linking to product failed: ${linkErr.error.message}`);
|
|
102
|
+
}
|
|
103
|
+
teamRecipeIds.add(recipeId);
|
|
104
|
+
counts.created += 1;
|
|
105
|
+
return textOk(`Created recipe ${recipeId} and linked to this product.`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function createUpdateRecipeTool(ctx, counts, teamRecipeIds) {
|
|
109
|
+
return tool('update_recipe', 'Overwrite an existing recipe (latest-wins) with better information you discovered in this repo AND link this product to it. Use when the existing entry is broadly correct but lacks detail / has outdated services / has a vague summary. Omit fields you do not want to change.', {
|
|
110
|
+
recipe_id: z.string().uuid(),
|
|
111
|
+
summary: z.string().min(1).max(RECIPE_SUMMARY_MAX).optional(),
|
|
112
|
+
content: z.string().min(1).max(RECIPE_CONTENT_MAX).optional(),
|
|
113
|
+
services: servicesSchema.optional(),
|
|
114
|
+
evidence: z.string().min(1).max(RECIPE_EVIDENCE_MAX),
|
|
115
|
+
}, async (args) => {
|
|
116
|
+
if (!teamRecipeIds.has(args.recipe_id)) {
|
|
117
|
+
return textError(`recipe_id ${args.recipe_id} is not in this team's recipe list. Use create_recipe for new entries.`);
|
|
118
|
+
}
|
|
119
|
+
const patch = {};
|
|
120
|
+
if (args.summary !== undefined) {
|
|
121
|
+
patch.summary = args.summary.trim();
|
|
122
|
+
}
|
|
123
|
+
if (args.content !== undefined) {
|
|
124
|
+
patch.content = args.content;
|
|
125
|
+
}
|
|
126
|
+
if (args.services !== undefined) {
|
|
127
|
+
patch.services = normalizeServices(args.services);
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(patch).length > 0) {
|
|
130
|
+
const updateErr = await ctx.supabase
|
|
131
|
+
.from('recipes')
|
|
132
|
+
.update(patch)
|
|
133
|
+
.eq('id', args.recipe_id)
|
|
134
|
+
.eq('team_id', ctx.teamId);
|
|
135
|
+
if (updateErr.error) {
|
|
136
|
+
return textError(`Failed to update recipe: ${updateErr.error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const linkErr = await ctx.supabase.from('product_recipes').upsert({
|
|
140
|
+
product_id: ctx.productId,
|
|
141
|
+
recipe_id: args.recipe_id,
|
|
142
|
+
evidence: args.evidence.trim(),
|
|
143
|
+
}, { onConflict: 'product_id,recipe_id' });
|
|
144
|
+
if (linkErr.error) {
|
|
145
|
+
return textError(`Recipe updated but linking to product failed: ${linkErr.error.message}`);
|
|
146
|
+
}
|
|
147
|
+
counts.updated += 1;
|
|
148
|
+
return textOk(`Updated recipe ${args.recipe_id} and linked to this product.`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
export function createLinkRecipeTool(ctx, counts, teamRecipeIds) {
|
|
152
|
+
return tool('link_recipe', 'Link this product to an existing recipe that already describes the capability accurately. Does NOT modify the recipe content. Use this whenever possible — the whole point of the team library is cross-product reuse.', {
|
|
153
|
+
recipe_id: z.string().uuid(),
|
|
154
|
+
evidence: z.string().min(1).max(RECIPE_EVIDENCE_MAX),
|
|
155
|
+
}, async (args) => {
|
|
156
|
+
if (!teamRecipeIds.has(args.recipe_id)) {
|
|
157
|
+
return textError(`recipe_id ${args.recipe_id} is not in this team's recipe list. Use create_recipe for new entries.`);
|
|
158
|
+
}
|
|
159
|
+
const linkErr = await ctx.supabase.from('product_recipes').upsert({
|
|
160
|
+
product_id: ctx.productId,
|
|
161
|
+
recipe_id: args.recipe_id,
|
|
162
|
+
evidence: args.evidence.trim(),
|
|
163
|
+
}, { onConflict: 'product_id,recipe_id' });
|
|
164
|
+
if (linkErr.error) {
|
|
165
|
+
return textError(`Failed to link recipe: ${linkErr.error.message}`);
|
|
166
|
+
}
|
|
167
|
+
counts.linked += 1;
|
|
168
|
+
return textOk(`Linked recipe ${args.recipe_id} to this product.`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export function createUnlinkRecipeTool(ctx, counts, existingLinkIds) {
|
|
172
|
+
return tool('unlink_recipe', 'Drop a previously-linked recipe that you have confirmed is NO LONGER present in this repo. Only call this for ids in the "Currently linked to this product" list. Does not delete the recipe itself.', {
|
|
173
|
+
recipe_id: z.string().uuid(),
|
|
174
|
+
}, async (args) => {
|
|
175
|
+
if (!existingLinkIds.has(args.recipe_id)) {
|
|
176
|
+
return textError(`recipe_id ${args.recipe_id} is not in the "Currently linked to this product" list — nothing to unlink.`);
|
|
177
|
+
}
|
|
178
|
+
const { error } = await ctx.supabase
|
|
179
|
+
.from('product_recipes')
|
|
180
|
+
.delete()
|
|
181
|
+
.eq('product_id', ctx.productId)
|
|
182
|
+
.eq('recipe_id', args.recipe_id);
|
|
183
|
+
if (error) {
|
|
184
|
+
return textError(`Failed to unlink recipe: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
existingLinkIds.delete(args.recipe_id);
|
|
187
|
+
counts.unlinked += 1;
|
|
188
|
+
return textOk(`Unlinked recipe ${args.recipe_id} from this product.`);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
export function createRecipesMcpServer(ctx, counts, teamRecipes, existingLinkIds) {
|
|
192
|
+
const teamRecipeIds = new Set(teamRecipes.map((r) => r.id));
|
|
193
|
+
return createSdkMcpServer({
|
|
194
|
+
name: 'recipes',
|
|
195
|
+
version: '1.0.0',
|
|
196
|
+
tools: [
|
|
197
|
+
createGetRecipeDetailTool(ctx, teamRecipeIds),
|
|
198
|
+
createCreateRecipeTool(ctx, counts, teamRecipeIds),
|
|
199
|
+
createUpdateRecipeTool(ctx, counts, teamRecipeIds),
|
|
200
|
+
createLinkRecipeTool(ctx, counts, teamRecipeIds),
|
|
201
|
+
createUnlinkRecipeTool(ctx, counts, existingLinkIds),
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the recipes phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned product repo and identify each non-trivial
|
|
5
|
+
* CAPABILITY the product implements ("AI video generation", "PDF export",
|
|
6
|
+
* "real-time presence", "Stripe subscription billing", etc.) and document
|
|
7
|
+
* HOW the capability is built — which services / tools / libraries are
|
|
8
|
+
* chained together. NOT a tech-stack inventory.
|
|
9
|
+
*
|
|
10
|
+
* The agent is fed the team's existing recipes (compact form: id + name +
|
|
11
|
+
* summary + services) and must, for each capability it identifies, pick one
|
|
12
|
+
* of:
|
|
13
|
+
* - link_recipe — existing recipe describes this capability correctly,
|
|
14
|
+
* just record this product uses it
|
|
15
|
+
* - update_recipe — existing recipe is close but the agent has better /
|
|
16
|
+
* more accurate info from this repo; overwrite
|
|
17
|
+
* - create_recipe — no existing recipe matches; new entry
|
|
18
|
+
* - unlink_recipe — this product was previously linked to a recipe that
|
|
19
|
+
* is no longer present in the repo
|
|
20
|
+
*
|
|
21
|
+
* Submission is via MCP tool calls only — no fenced JSON.
|
|
22
|
+
*/
|
|
23
|
+
import type { RecipeSummary } from './types.js';
|
|
24
|
+
export interface RecipesPromptContext {
|
|
25
|
+
productName: string;
|
|
26
|
+
productDescription?: string;
|
|
27
|
+
guidance?: string;
|
|
28
|
+
teamRecipes: RecipeSummary[];
|
|
29
|
+
existingLinks: {
|
|
30
|
+
recipeId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
}[];
|
|
33
|
+
}
|
|
34
|
+
export declare function createRecipesSystemPrompt(): string;
|
|
35
|
+
export declare function createRecipesUserPrompt(ctx: RecipesPromptContext): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the recipes phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned product repo and identify each non-trivial
|
|
5
|
+
* CAPABILITY the product implements ("AI video generation", "PDF export",
|
|
6
|
+
* "real-time presence", "Stripe subscription billing", etc.) and document
|
|
7
|
+
* HOW the capability is built — which services / tools / libraries are
|
|
8
|
+
* chained together. NOT a tech-stack inventory.
|
|
9
|
+
*
|
|
10
|
+
* The agent is fed the team's existing recipes (compact form: id + name +
|
|
11
|
+
* summary + services) and must, for each capability it identifies, pick one
|
|
12
|
+
* of:
|
|
13
|
+
* - link_recipe — existing recipe describes this capability correctly,
|
|
14
|
+
* just record this product uses it
|
|
15
|
+
* - update_recipe — existing recipe is close but the agent has better /
|
|
16
|
+
* more accurate info from this repo; overwrite
|
|
17
|
+
* - create_recipe — no existing recipe matches; new entry
|
|
18
|
+
* - unlink_recipe — this product was previously linked to a recipe that
|
|
19
|
+
* is no longer present in the repo
|
|
20
|
+
*
|
|
21
|
+
* Submission is via MCP tool calls only — no fenced JSON.
|
|
22
|
+
*/
|
|
23
|
+
export function createRecipesSystemPrompt() {
|
|
24
|
+
return `You are a senior staff engineer cataloguing the IMPLEMENTATION RECIPES a product uses.
|
|
25
|
+
|
|
26
|
+
The current working directory is a fresh clone of the product's repository. Use Glob/Grep/Read (and Bash for git/log when helpful) to explore it before writing.
|
|
27
|
+
|
|
28
|
+
## What is a "recipe"?
|
|
29
|
+
|
|
30
|
+
A recipe describes ONE non-trivial capability the product delivers, and HOW it is built: which services, libraries, tools, and techniques are chained together to make it work end-to-end.
|
|
31
|
+
|
|
32
|
+
Good recipe names: "AI video generation", "PDF export with custom fonts", "Real-time collaborative editing", "Stripe subscription billing with proration", "GitHub OAuth device flow".
|
|
33
|
+
Bad recipe names: "React frontend", "TypeScript", "Tailwind", "Authentication" (too vague).
|
|
34
|
+
|
|
35
|
+
A recipe is the WHAT + HOW pair. The WHAT is the user-facing capability. The HOW is the specific stack of services chained to deliver it. Two products can implement the same capability with the same stack — that is one recipe, linked from both products. Two products that implement "OCR" but one uses Tesseract and the other uses PaddleOCR are TWO different recipes (same capability name, different services).
|
|
36
|
+
|
|
37
|
+
## Output protocol
|
|
38
|
+
|
|
39
|
+
The MCP server exposes these tools — use them, do NOT paste content as fenced code:
|
|
40
|
+
|
|
41
|
+
1. \`get_recipe_detail({ recipe_id })\` — fetch the full content of an existing recipe when you need to decide whether to link, update, or create. The prompt only gives you names + summaries + services to keep your context small.
|
|
42
|
+
|
|
43
|
+
2. \`create_recipe({ name, summary, content, services, evidence })\` — register a brand-new recipe in the team library AND link this product to it.
|
|
44
|
+
|
|
45
|
+
3. \`update_recipe({ recipe_id, summary?, content?, services?, evidence })\` — overwrite an existing recipe with better information you discovered in this repo AND link this product to it. Use when the existing recipe is broadly correct but lacks detail / has outdated services / has a vague summary.
|
|
46
|
+
|
|
47
|
+
4. \`link_recipe({ recipe_id, evidence })\` — just link this product to an existing recipe that already describes the capability accurately. The recipe's content is not touched.
|
|
48
|
+
|
|
49
|
+
5. \`unlink_recipe({ recipe_id })\` — drop a previously-linked recipe that the agent has confirmed is NO LONGER present in this repo (capability was removed, or the previous scan was wrong). Only unlink things in the "Currently linked to this product" list.
|
|
50
|
+
|
|
51
|
+
Call exactly one of create / update / link for each capability you identify. Call unlink at the end for any previously-linked recipe you could not corroborate. When you are done with every capability and every unlink, end your turn — no summary message, no JSON dump.
|
|
52
|
+
|
|
53
|
+
## Rules
|
|
54
|
+
|
|
55
|
+
- \`services\` is a SORTED list of canonical service / tool / library names ("ElevenLabs", "ffmpeg", "Whisper", "Stripe", "Resend"). Sort alphabetically so equivalent recipes compare cleanly. Don't include language/framework names ("React", "Node") unless they're the actual differentiator.
|
|
56
|
+
- \`content\` is markdown. Aim for 200–800 words per recipe. Structure: a short intro sentence, then numbered steps describing the flow, then a "Files" subsection with relative paths (e.g. \`src/services/video/encoder.ts\`).
|
|
57
|
+
- \`evidence\` is one paragraph of plain text saying where in THIS product's repo the recipe shows up: file paths, key entry points. Used to help reviewers verify the link.
|
|
58
|
+
- Don't invent capabilities. If the repo is small or only does one thing, emit one recipe — don't pad.
|
|
59
|
+
- Prefer LINK over UPDATE over CREATE in that order. Reuse the team library aggressively; the whole point is cross-product visibility into the same recipe.
|
|
60
|
+
- When the same capability is implemented with a clearly different stack, that's a NEW recipe even if the name collides. Disambiguate the name (\"PDF export (Puppeteer)\" vs \"PDF export (pdf-lib)\") so the team can tell them apart.`;
|
|
61
|
+
}
|
|
62
|
+
export function createRecipesUserPrompt(ctx) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(`# Product: ${ctx.productName}`);
|
|
65
|
+
if (ctx.productDescription) {
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('## Description');
|
|
68
|
+
lines.push(ctx.productDescription);
|
|
69
|
+
}
|
|
70
|
+
if (ctx.guidance && ctx.guidance.trim()) {
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push('## Reviewer guidance (focus or exclusions)');
|
|
73
|
+
lines.push(ctx.guidance.trim());
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push('## Existing recipes in this team');
|
|
77
|
+
if (ctx.teamRecipes.length === 0) {
|
|
78
|
+
lines.push('(none — every recipe you identify will be a `create_recipe`.)');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push('Prefer `link_recipe` / `update_recipe` over `create_recipe` when one of these matches the capability you found. Call `get_recipe_detail` if you need to see the full content before deciding.');
|
|
82
|
+
lines.push('');
|
|
83
|
+
for (const r of ctx.teamRecipes) {
|
|
84
|
+
const svc = r.services.length > 0 ? ` services=[${r.services.join(', ')}]` : '';
|
|
85
|
+
const sum = r.summary ? ` — ${r.summary}` : '';
|
|
86
|
+
lines.push(`- id=${r.id} "${r.name}"${svc}${sum}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('## Currently linked to this product');
|
|
91
|
+
if (ctx.existingLinks.length === 0) {
|
|
92
|
+
lines.push('(none)');
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
lines.push('If you confirm any of these is no longer present in the repo, call `unlink_recipe` with its id.');
|
|
96
|
+
lines.push('');
|
|
97
|
+
for (const l of ctx.existingLinks) {
|
|
98
|
+
lines.push(`- id=${l.recipeId} "${l.name}"`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push('## Task');
|
|
103
|
+
lines.push('Explore the cloned repository, identify every non-trivial capability the product delivers, and for each call exactly one of `create_recipe` / `update_recipe` / `link_recipe`. Then call `unlink_recipe` for any previously-linked recipe you could not corroborate. End your turn when done.');
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the recipes MCP tools accept and return. The Zod schemas live in
|
|
3
|
+
* mcp-server.ts; these plain TS types are what the rest of the phase
|
|
4
|
+
* (orchestrator, persistence helpers, tests) consumes so they don't have
|
|
5
|
+
* to inflate Zod into their dependency graph.
|
|
6
|
+
*/
|
|
7
|
+
export interface RecipeSummary {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
summary: string | null;
|
|
11
|
+
services: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface RecipeDetail extends RecipeSummary {
|
|
14
|
+
content: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface CreateRecipeArgs {
|
|
17
|
+
name: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
content: string;
|
|
20
|
+
services: string[];
|
|
21
|
+
evidence: string;
|
|
22
|
+
}
|
|
23
|
+
export interface UpdateRecipeArgs {
|
|
24
|
+
recipeId: string;
|
|
25
|
+
summary?: string;
|
|
26
|
+
content?: string;
|
|
27
|
+
services?: string[];
|
|
28
|
+
evidence: string;
|
|
29
|
+
}
|
|
30
|
+
export interface LinkRecipeArgs {
|
|
31
|
+
recipeId: string;
|
|
32
|
+
evidence: string;
|
|
33
|
+
}
|
|
34
|
+
export interface UnlinkRecipeArgs {
|
|
35
|
+
recipeId: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const RECIPE_SUMMARY_MAX = 500;
|
|
38
|
+
export declare const RECIPE_CONTENT_MAX = 50000;
|
|
39
|
+
export declare const RECIPE_NAME_MAX = 200;
|
|
40
|
+
export declare const RECIPE_SERVICES_MAX = 20;
|
|
41
|
+
export declare const RECIPE_SERVICE_NAME_MAX = 80;
|
|
42
|
+
export declare const RECIPE_EVIDENCE_MAX = 4000;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the recipes MCP tools accept and return. The Zod schemas live in
|
|
3
|
+
* mcp-server.ts; these plain TS types are what the rest of the phase
|
|
4
|
+
* (orchestrator, persistence helpers, tests) consumes so they don't have
|
|
5
|
+
* to inflate Zod into their dependency graph.
|
|
6
|
+
*/
|
|
7
|
+
// Defensive caps mirroring the DB CHECK constraints from
|
|
8
|
+
// 20260521000000_create_recipes.sql. Keeping the limits here lets the MCP
|
|
9
|
+
// tool reject oversized output with an actionable error message instead of
|
|
10
|
+
// getting a Postgres constraint violation.
|
|
11
|
+
export const RECIPE_SUMMARY_MAX = 500;
|
|
12
|
+
export const RECIPE_CONTENT_MAX = 50_000;
|
|
13
|
+
export const RECIPE_NAME_MAX = 200;
|
|
14
|
+
export const RECIPE_SERVICES_MAX = 20;
|
|
15
|
+
export const RECIPE_SERVICE_NAME_MAX = 80;
|
|
16
|
+
export const RECIPE_EVIDENCE_MAX = 4000;
|
package/package.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -16,7 +16,7 @@ export default defineConfig({
|
|
|
16
16
|
'src/phases/sync-sentry-issues/__tests__/**/*.test.ts',
|
|
17
17
|
'src/phases/sync-shared/__tests__/**/*.test.ts',
|
|
18
18
|
'src/phases/screen-flow/__tests__/**/*.test.ts',
|
|
19
|
-
'src/phases/
|
|
19
|
+
'src/phases/recipes/__tests__/**/*.test.ts',
|
|
20
20
|
'src/types/__tests__/**/*.test.ts',
|
|
21
21
|
'src/commands/find-smells/__tests__/**/*.test.ts',
|
|
22
22
|
],
|