edsger 0.58.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/api/cross-product.js +0 -1
- package/dist/api/issues/issue-utils.js +0 -1
- package/dist/api/issues/update-issue.js +1 -1
- package/dist/commands/agent-workflow/chat-worker.js +1 -1
- package/dist/commands/checklists/index.js +1 -1
- package/dist/commands/product-techniques/index.d.ts +15 -0
- package/dist/commands/product-techniques/index.js +37 -0
- package/dist/commands/recipes/index.d.ts +15 -0
- package/dist/commands/recipes/index.js +34 -0
- package/dist/commands/workflow/executors/phase-executor.js +1 -1
- package/dist/index.js +24 -1
- package/dist/phases/analyze-logs/index.js +1 -1
- package/dist/phases/bug-fixing/context-fetcher.js +4 -2
- package/dist/phases/find-features/index.js +1 -1
- package/dist/phases/product-techniques/index.d.ts +52 -0
- package/dist/phases/product-techniques/index.js +268 -0
- package/dist/phases/product-techniques/mcp-server.d.ts +41 -0
- package/dist/phases/product-techniques/mcp-server.js +96 -0
- package/dist/phases/product-techniques/prompts.d.ts +19 -0
- package/dist/phases/product-techniques/prompts.js +66 -0
- package/dist/phases/product-techniques/types.d.ts +13 -0
- package/dist/phases/product-techniques/types.js +13 -0
- 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/dist/phases/screen-flow/mcp-server.d.ts +1 -1
- package/dist/services/branches.js +3 -3
- package/dist/services/phase-hooks/hook-executor.js +1 -1
- package/dist/services/phase-ratings.js +1 -1
- package/dist/services/product-logs.js +1 -1
- package/dist/services/pull-requests.js +3 -3
- package/package.json +1 -1
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing a single tool — `submit_techniques` — that
|
|
3
|
+
* the Claude Agent SDK session calls to return the final markdown catalogue.
|
|
4
|
+
*
|
|
5
|
+
* Using a tool call instead of parsing a fenced JSON block lets the SDK
|
|
6
|
+
* enforce the schema (via Zod) and lets the agent self-correct when
|
|
7
|
+
* validation fails: the error message goes back as the tool result and the
|
|
8
|
+
* agent can re-call the tool with corrected data.
|
|
9
|
+
*
|
|
10
|
+
* The capture pattern: callers pass in a `TechniquesCaptureState`. The tool
|
|
11
|
+
* handler stores the validated args on `state.captured`. The orchestrator
|
|
12
|
+
* reads it after the SDK loop ends.
|
|
13
|
+
*/
|
|
14
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { TECHNIQUES_CONTENT_MAX, TECHNIQUES_SUMMARY_MAX, } from './types.js';
|
|
17
|
+
export function createTechniquesCaptureState() {
|
|
18
|
+
return { captured: null };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validation that goes beyond the basic Zod schema: enforce that the
|
|
22
|
+
* markdown actually has the H2 sections the prompt asks for. The agent gets
|
|
23
|
+
* an actionable error and can re-submit.
|
|
24
|
+
*
|
|
25
|
+
* We don't require ALL six sections — agents on tiny repos often legitimately
|
|
26
|
+
* collapse some. We require at least the first one ("Languages & Runtime")
|
|
27
|
+
* plus "Notable Techniques", which the prompt calls out as the whole point.
|
|
28
|
+
*/
|
|
29
|
+
export function validateContent(content) {
|
|
30
|
+
const required = [
|
|
31
|
+
/^##\s+Languages\s*&\s*Runtime\b/im,
|
|
32
|
+
/^##\s+Notable\s+Techniques\b/im,
|
|
33
|
+
];
|
|
34
|
+
for (const re of required) {
|
|
35
|
+
if (!re.test(content)) {
|
|
36
|
+
return {
|
|
37
|
+
error: `content is missing the required section matching ${re.source}. Add the section and re-call submit_techniques.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { error: null };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build the `submit_techniques` tool. Exported separately from the server
|
|
45
|
+
* so tests can exercise the handler directly without going through MCP
|
|
46
|
+
* transport.
|
|
47
|
+
*/
|
|
48
|
+
export function createSubmitTechniquesTool(state) {
|
|
49
|
+
return tool('submit_techniques', [
|
|
50
|
+
'Submit the final techniques catalogue. Call this EXACTLY once, when',
|
|
51
|
+
'you have finished cataloguing every technique. Pass the summary and',
|
|
52
|
+
'the full markdown content as arguments. After this call succeeds, end',
|
|
53
|
+
'your turn — do NOT also paste the same content as a fenced code block.',
|
|
54
|
+
'If validation fails, the error message tells you what to fix; call the',
|
|
55
|
+
'tool again with corrected data.',
|
|
56
|
+
].join(' '), {
|
|
57
|
+
summary: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.max(TECHNIQUES_SUMMARY_MAX)
|
|
61
|
+
.describe('1-2 sentence summary suitable for a tab header. Plain text, no markdown.'),
|
|
62
|
+
content: z
|
|
63
|
+
.string()
|
|
64
|
+
.min(1)
|
|
65
|
+
.max(TECHNIQUES_CONTENT_MAX)
|
|
66
|
+
.describe('Full markdown body with H2 sections: Languages & Runtime, Frameworks & Libraries, Architecture Patterns, State & Data Techniques, Build & Deploy Techniques, Notable Techniques.'),
|
|
67
|
+
}, async (args) => {
|
|
68
|
+
const extraction = {
|
|
69
|
+
summary: args.summary,
|
|
70
|
+
content: args.content,
|
|
71
|
+
};
|
|
72
|
+
const { error } = validateContent(extraction.content);
|
|
73
|
+
if (error) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: error }],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
state.captured = extraction;
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: 'text',
|
|
84
|
+
text: `Captured techniques catalogue (${extraction.content.length} chars). End your turn now.`,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function createTechniquesMcpServer(state) {
|
|
91
|
+
return createSdkMcpServer({
|
|
92
|
+
name: 'product-techniques',
|
|
93
|
+
version: '1.0.0',
|
|
94
|
+
tools: [createSubmitTechniquesTool(state)],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the product-level techniques phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned product repo and write a markdown catalogue
|
|
5
|
+
* of the techniques it actually uses — languages, frameworks, patterns, state
|
|
6
|
+
* idioms, build/deploy choices, plus the clever non-obvious bits. Focused on
|
|
7
|
+
* what a new engineer needs to recognize and recreate, not feature lists.
|
|
8
|
+
*
|
|
9
|
+
* The final result is submitted via the `submit_techniques` MCP tool, NOT a
|
|
10
|
+
* fenced JSON block. Tool calls let the SDK enforce the schema with Zod and
|
|
11
|
+
* let the agent self-correct on validation errors. See mcp-server.ts.
|
|
12
|
+
*/
|
|
13
|
+
export interface ProductTechniquesPromptContext {
|
|
14
|
+
productName: string;
|
|
15
|
+
productDescription?: string;
|
|
16
|
+
guidance?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function createProductTechniquesSystemPrompt(): string;
|
|
19
|
+
export declare function createProductTechniquesUserPrompt(context: ProductTechniquesPromptContext): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the product-level techniques phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned product repo and write a markdown catalogue
|
|
5
|
+
* of the techniques it actually uses — languages, frameworks, patterns, state
|
|
6
|
+
* idioms, build/deploy choices, plus the clever non-obvious bits. Focused on
|
|
7
|
+
* what a new engineer needs to recognize and recreate, not feature lists.
|
|
8
|
+
*
|
|
9
|
+
* The final result is submitted via the `submit_techniques` MCP tool, NOT a
|
|
10
|
+
* fenced JSON block. Tool calls let the SDK enforce the schema with Zod and
|
|
11
|
+
* let the agent self-correct on validation errors. See mcp-server.ts.
|
|
12
|
+
*/
|
|
13
|
+
export function createProductTechniquesSystemPrompt() {
|
|
14
|
+
return `You are a senior staff engineer cataloguing the techniques a product's codebase uses.
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
Your audience: a new engineer joining the team. They need to recognize WHICH techniques this repo employs — what frameworks, what patterns, what idioms — so they can read code without surprise and contribute in the codebase's existing style. This is not a feature list and not an architecture proposal. Anchor every technique in the actual code; reference real file paths.
|
|
19
|
+
|
|
20
|
+
## Output protocol
|
|
21
|
+
|
|
22
|
+
When you are ready to submit, call the \`submit_techniques\` tool EXACTLY ONCE with:
|
|
23
|
+
- \`summary\` — 1-2 sentence summary suitable for a tab header (plain text, no markdown).
|
|
24
|
+
- \`content\` — the full markdown body, structured as described below.
|
|
25
|
+
|
|
26
|
+
Do NOT also paste the same content as a fenced code block — the tool call is the only channel for the result. If validation fails, the tool response tells you what to fix; call the tool again with corrected data.
|
|
27
|
+
|
|
28
|
+
## Required sections in \`content\`
|
|
29
|
+
|
|
30
|
+
The markdown body MUST contain these H2 sections, in this order:
|
|
31
|
+
|
|
32
|
+
1. **## Languages & Runtime** — languages used (with versions if pinned), runtime targets (Node / Bun / Deno / browser / native / etc), package manager.
|
|
33
|
+
2. **## Frameworks & Libraries** — the major frameworks and libraries this repo depends on, and what each one is doing here. Don't just list package names; explain the role. Group by area (UI, server, data, infra) if it helps.
|
|
34
|
+
3. **## Architecture Patterns** — the architectural choices the code embodies: layering / module boundaries / dependency direction / how features are organised. Include one Mermaid \`flowchart TD\` showing the dominant pattern (≤ 10 nodes). Name the pattern when you can ("repository pattern", "feature-sliced design", "hexagonal", "BFF", etc.).
|
|
35
|
+
4. **## State & Data Techniques** — state management, data fetching, caching, optimistic updates, validation, type-safety across boundaries. How is server state vs client state handled? How are forms managed? How is data flowing between server and client?
|
|
36
|
+
5. **## Build & Deploy Techniques** — bundler / compiler choices, code-splitting, environment handling, monorepo tooling, CI/CD specifics, deployment target (edge / serverless / containers / static).
|
|
37
|
+
6. **## Notable Techniques** — the genuinely interesting bits that aren't obvious from the stack: clever abstractions, performance optimizations, security hardening, custom hooks/utilities worth knowing, intentional deviations from defaults, surprising workarounds. Each one: what it is, where it lives (file path), why it matters. This section is the whole point — be specific and useful here.
|
|
38
|
+
|
|
39
|
+
The validator will reject content missing the "Languages & Runtime" or "Notable Techniques" headings.
|
|
40
|
+
|
|
41
|
+
## Rules
|
|
42
|
+
|
|
43
|
+
- Use relative file paths from the repo root (e.g. \`src/services/auth.ts\`). Link to specific files where the technique is most visible.
|
|
44
|
+
- Mermaid blocks must be valid (no unclosed quotes, no unsupported node shapes). Prefer simple \`flowchart TD\` syntax.
|
|
45
|
+
- Don't invent or guess. If a section has nothing distinctive ("just a stock CRA app"), say so briefly — don't pad.
|
|
46
|
+
- Focus on TECHNIQUES, not features. Bad: "the app has a login screen". Good: "uses next-auth's credentials provider with a custom JWT callback at src/lib/auth.ts:42".
|
|
47
|
+
- The whole document should fit in one screen-readable scroll — aim for ~1500-3000 words of content. Less is fine for small repos.`;
|
|
48
|
+
}
|
|
49
|
+
export function createProductTechniquesUserPrompt(context) {
|
|
50
|
+
const lines = [];
|
|
51
|
+
lines.push(`# Product: ${context.productName}`);
|
|
52
|
+
if (context.productDescription) {
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('## Description');
|
|
55
|
+
lines.push(context.productDescription);
|
|
56
|
+
}
|
|
57
|
+
if (context.guidance && context.guidance.trim()) {
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('## Reviewer guidance (focus or exclusions)');
|
|
60
|
+
lines.push(context.guidance.trim());
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('## Task');
|
|
64
|
+
lines.push('Explore the cloned repository and produce the techniques catalogue, then submit it via the `submit_techniques` MCP tool as specified in your system prompt. Pay particular attention to the "Notable Techniques" section — that is the real value to the reader.');
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape returned by the agent via the `submit_techniques` MCP tool. Mirrors
|
|
3
|
+
* the Zod schema in `mcp-server.ts` — kept duplicated as a plain TS type so
|
|
4
|
+
* consumers (the phase orchestrator, persistence helpers, tests) don't have
|
|
5
|
+
* to inflate Zod into their dependency graph.
|
|
6
|
+
*/
|
|
7
|
+
export interface TechniquesExtraction {
|
|
8
|
+
summary: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function isTechniquesExtraction(v: unknown): v is TechniquesExtraction;
|
|
12
|
+
export declare const TECHNIQUES_SUMMARY_MAX = 500;
|
|
13
|
+
export declare const TECHNIQUES_CONTENT_MAX = 200000;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isTechniquesExtraction(v) {
|
|
2
|
+
if (!v || typeof v !== 'object') {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
const obj = v;
|
|
6
|
+
return typeof obj.summary === 'string' && typeof obj.content === 'string';
|
|
7
|
+
}
|
|
8
|
+
// Defensive caps mirroring the DB CHECK constraints from
|
|
9
|
+
// 20260521000000_create_product_techniques.sql. Keeping the limits here
|
|
10
|
+
// too lets the MCP tool reject oversized output with an actionable error
|
|
11
|
+
// message instead of getting a Postgres constraint violation.
|
|
12
|
+
export const TECHNIQUES_SUMMARY_MAX = 500;
|
|
13
|
+
export const TECHNIQUES_CONTENT_MAX = 200_000;
|
|
@@ -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;
|