edsger 0.72.6 → 0.74.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/intelligence-normalize.d.ts +25 -0
- package/dist/api/intelligence-normalize.js +103 -0
- package/dist/api/intelligence.js +113 -10
- package/dist/commands/features/index.d.ts +15 -0
- package/dist/commands/features/index.js +34 -0
- package/dist/commands/pr-resolve/index.d.ts +3 -1
- package/dist/commands/pr-resolve/index.js +12 -7
- package/dist/commands/pr-review/index.d.ts +3 -1
- package/dist/commands/pr-review/index.js +10 -6
- package/dist/commands/sync-github-pull-requests/index.d.ts +11 -0
- package/dist/commands/sync-github-pull-requests/index.js +42 -0
- package/dist/index.js +50 -4
- package/dist/phases/features/index.d.ts +65 -0
- package/dist/phases/features/index.js +292 -0
- package/dist/phases/features/mcp-server.d.ts +61 -0
- package/dist/phases/features/mcp-server.js +165 -0
- package/dist/phases/features/prompts.d.ts +32 -0
- package/dist/phases/features/prompts.js +92 -0
- package/dist/phases/features/types.d.ts +34 -0
- package/dist/phases/features/types.js +15 -0
- package/dist/phases/pr-resolve/index.d.ts +3 -1
- package/dist/phases/pr-resolve/index.js +12 -12
- package/dist/phases/pr-review/index.d.ts +3 -1
- package/dist/phases/pr-review/index.js +13 -16
- package/dist/phases/pr-shared/status.d.ts +18 -0
- package/dist/phases/pr-shared/status.js +37 -0
- package/dist/phases/sync-github-pull-requests/index.d.ts +23 -0
- package/dist/phases/sync-github-pull-requests/index.js +210 -0
- package/dist/phases/sync-github-pull-requests/state.d.ts +24 -0
- package/dist/phases/sync-github-pull-requests/state.js +16 -0
- package/dist/phases/sync-github-pull-requests/types.d.ts +22 -0
- package/dist/phases/sync-github-pull-requests/types.js +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Features phase: clone EVERY repository linked to the product
|
|
3
|
+
* (product_repositories), ask Claude to catalogue the user-facing features
|
|
4
|
+
* the product delivers, and persist them via the product_features table.
|
|
5
|
+
*
|
|
6
|
+
* Multi-repo: unlike recipes (single primary repo), features are discovered
|
|
7
|
+
* across the whole product. Repo resolution + cloning is shared with the
|
|
8
|
+
* diagram phases (cloneDiagramRepos): each repo is cloned into its own
|
|
9
|
+
* subdirectory of a per-product parent dir, and the agent runs in the
|
|
10
|
+
* parent so it can explore all of them in one pass.
|
|
11
|
+
*
|
|
12
|
+
* Production-grade behaviours layered on top of the basic agent loop
|
|
13
|
+
* (mirrors the recipes phase):
|
|
14
|
+
*
|
|
15
|
+
* - Heartbeat: `last_heartbeat_at` on the feature_scans row is refreshed
|
|
16
|
+
* on every assistant message so the reader can detect stalled / crashed
|
|
17
|
+
* runs (see desktop-app/.../services/db/feature-scans.ts for the lazy
|
|
18
|
+
* reaper).
|
|
19
|
+
* - Cancellation-safe writes: markRunning / markSuccess / markFailed only
|
|
20
|
+
* touch rows whose status is in {pending, running}. If the user clicked
|
|
21
|
+
* Stop and the row is now 'cancelled', the final write no-ops.
|
|
22
|
+
* - Per-call MCP writes: agent commits each create / update / remove as
|
|
23
|
+
* it goes. There is no "submit at the end" buffer — partial progress
|
|
24
|
+
* survives even if the agent later errors out.
|
|
25
|
+
*/
|
|
26
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
27
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
28
|
+
import { getSupabase } from '../../supabase/client.js';
|
|
29
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
30
|
+
import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
|
|
31
|
+
import { cloneDiagramRepos, safeDirName, } from '../diagram-shared/clone-repos.js';
|
|
32
|
+
import { fetchProductBasics } from '../find-shared/mcp.js';
|
|
33
|
+
import { createPromptGenerator, extractTextFromContent, } from '../pr-shared/agent-utils.js';
|
|
34
|
+
import { createFeaturesMcpServer, createFeaturesMutationCounts, } from './mcp-server.js';
|
|
35
|
+
import { createFeaturesSystemPrompt, createFeaturesUserPrompt, } from './prompts.js';
|
|
36
|
+
const WORKSPACE_KEY = 'features';
|
|
37
|
+
const MAX_TURNS = 200;
|
|
38
|
+
// Heartbeat cadence: at most one DB write per HEARTBEAT_MIN_INTERVAL_MS.
|
|
39
|
+
// Triggered on every assistant message so a stalled agent (no messages
|
|
40
|
+
// flowing) lets the row go stale and the reader can mark it failed.
|
|
41
|
+
const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
|
|
42
|
+
/**
|
|
43
|
+
* Repo-scope note for the agent's user prompt. Unlike the diagram phases'
|
|
44
|
+
* describeRepoScope (which asks for one unified flow), this tells the agent
|
|
45
|
+
* the exact full names it may use in the `repos` field of each feature.
|
|
46
|
+
*/
|
|
47
|
+
export function describeFeatureRepoScope(repos) {
|
|
48
|
+
if (repos.length === 1) {
|
|
49
|
+
return `The working directory is a clone of \`${repos[0].fullName}\`. Use that full name in the \`repos\` field.`;
|
|
50
|
+
}
|
|
51
|
+
const list = repos.map((r) => `- ${r.fullName} (subdirectory: ${safeDirName(r.fullName)})`);
|
|
52
|
+
return [
|
|
53
|
+
`This product spans ${repos.length} repositories, each cloned into its own subdirectory of the working directory:`,
|
|
54
|
+
...list,
|
|
55
|
+
'Explore all of them. A feature implemented across several repositories is ONE feature listing every repo it touches in `repos` — do not duplicate it per repo.',
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
export async function runFeaturesPhase(options) {
|
|
59
|
+
const { productId, scanId, guidance, verbose } = options;
|
|
60
|
+
logInfo(`Starting features scan for product ${productId}`);
|
|
61
|
+
const supabase = getSupabase();
|
|
62
|
+
const claimed = await markRunning(supabase, scanId);
|
|
63
|
+
if (!claimed) {
|
|
64
|
+
return {
|
|
65
|
+
status: 'cancelled',
|
|
66
|
+
message: 'Feature scan row is no longer in a runnable state (likely cancelled before the CLI started)',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
let cleanupDir;
|
|
70
|
+
let succeeded = false;
|
|
71
|
+
try {
|
|
72
|
+
// Every repo linked to the product, in stored order. cloneDiagramRepos
|
|
73
|
+
// falls back to the product's primary repo when the list is empty
|
|
74
|
+
// (older single-repo products with no product_repositories rows).
|
|
75
|
+
const repositoryIds = await listProductRepositoryIds(supabase, productId);
|
|
76
|
+
const cloned = await cloneDiagramRepos({
|
|
77
|
+
productId,
|
|
78
|
+
repositoryIds,
|
|
79
|
+
workspaceKey: WORKSPACE_KEY,
|
|
80
|
+
verbose,
|
|
81
|
+
});
|
|
82
|
+
if (!cloned.ok) {
|
|
83
|
+
await markFailed(supabase, scanId, cloned.message);
|
|
84
|
+
return { status: 'error', message: cloned.message };
|
|
85
|
+
}
|
|
86
|
+
;
|
|
87
|
+
({ cleanupDir } = cloned);
|
|
88
|
+
const [basics, scanMeta, existingFeatures] = await Promise.all([
|
|
89
|
+
fetchProductBasics(productId),
|
|
90
|
+
getScanCreator(supabase, scanId),
|
|
91
|
+
listProductFeatures(supabase, productId),
|
|
92
|
+
]);
|
|
93
|
+
if (!scanMeta) {
|
|
94
|
+
const msg = 'feature_scans row vanished mid-run; aborting';
|
|
95
|
+
await markFailed(supabase, scanId, msg);
|
|
96
|
+
return { status: 'error', message: msg };
|
|
97
|
+
}
|
|
98
|
+
const systemPrompt = createFeaturesSystemPrompt();
|
|
99
|
+
const userPrompt = createFeaturesUserPrompt({
|
|
100
|
+
productName: basics.name,
|
|
101
|
+
productDescription: basics.description,
|
|
102
|
+
guidance,
|
|
103
|
+
existingFeatures,
|
|
104
|
+
repoScopeNote: describeFeatureRepoScope(cloned.repos),
|
|
105
|
+
});
|
|
106
|
+
const counts = createFeaturesMutationCounts();
|
|
107
|
+
const mcpServer = createFeaturesMcpServer({
|
|
108
|
+
supabase,
|
|
109
|
+
productId,
|
|
110
|
+
createdBy: scanMeta.created_by,
|
|
111
|
+
}, counts, existingFeatures);
|
|
112
|
+
logInfo(`Running Claude agent to identify features across ${cloned.repos.length} repo(s)...`);
|
|
113
|
+
let lastHeartbeatAt = 0;
|
|
114
|
+
for await (const message of query({
|
|
115
|
+
prompt: createPromptGenerator(userPrompt),
|
|
116
|
+
options: {
|
|
117
|
+
systemPrompt: {
|
|
118
|
+
type: 'preset',
|
|
119
|
+
preset: 'claude_code',
|
|
120
|
+
append: systemPrompt,
|
|
121
|
+
},
|
|
122
|
+
model: DEFAULT_MODEL,
|
|
123
|
+
maxTurns: MAX_TURNS,
|
|
124
|
+
permissionMode: 'bypassPermissions',
|
|
125
|
+
cwd: cloned.projectDir,
|
|
126
|
+
mcpServers: {
|
|
127
|
+
features: mcpServer,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
})) {
|
|
131
|
+
if (message.type === 'assistant') {
|
|
132
|
+
extractTextFromContent(message.message?.content ?? [], verbose);
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
if (now - lastHeartbeatAt >= HEARTBEAT_MIN_INTERVAL_MS) {
|
|
135
|
+
lastHeartbeatAt = now;
|
|
136
|
+
await heartbeat(supabase, scanId);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (message.type !== 'result') {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (message.subtype !== 'success') {
|
|
144
|
+
const msg = `Features scan failed: agent ${message.subtype}`;
|
|
145
|
+
const written = await markFailed(supabase, scanId, msg);
|
|
146
|
+
return {
|
|
147
|
+
status: written ? 'error' : 'cancelled',
|
|
148
|
+
message: written
|
|
149
|
+
? msg
|
|
150
|
+
: 'Scan was cancelled while the agent was running',
|
|
151
|
+
counts,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const written = await markSuccess(supabase, scanId);
|
|
155
|
+
if (!written) {
|
|
156
|
+
return {
|
|
157
|
+
status: 'cancelled',
|
|
158
|
+
message: 'Scan was cancelled before the result could be written',
|
|
159
|
+
counts,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
succeeded = true;
|
|
163
|
+
const summary = `created ${counts.created}, updated ${counts.updated}, removed ${counts.removed}`;
|
|
164
|
+
logSuccess(`Features scan complete — ${summary}`);
|
|
165
|
+
return {
|
|
166
|
+
status: 'success',
|
|
167
|
+
message: `Features scan complete (${summary})`,
|
|
168
|
+
counts,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const msg = 'Features scan ended without a result message';
|
|
172
|
+
await markFailed(supabase, scanId, msg);
|
|
173
|
+
return { status: 'error', message: msg, counts };
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
177
|
+
logError(`Features scan failed: ${errorMessage}`);
|
|
178
|
+
await markFailed(supabase, scanId, errorMessage);
|
|
179
|
+
return { status: 'error', message: errorMessage };
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
if (succeeded) {
|
|
183
|
+
cleanupIssueRepo(cleanupDir);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// DB helpers — exported for unit tests
|
|
189
|
+
// ============================================================================
|
|
190
|
+
export async function listProductRepositoryIds(supabase, productId) {
|
|
191
|
+
const { data, error } = await supabase
|
|
192
|
+
.from('product_repositories')
|
|
193
|
+
.select('repository_id, position')
|
|
194
|
+
.eq('product_id', productId)
|
|
195
|
+
.order('position', { ascending: true });
|
|
196
|
+
if (error || !data) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
return data.map((r) => r.repository_id);
|
|
200
|
+
}
|
|
201
|
+
export async function getScanCreator(supabase, scanId) {
|
|
202
|
+
const { data, error } = await supabase
|
|
203
|
+
.from('feature_scans')
|
|
204
|
+
.select('created_by')
|
|
205
|
+
.eq('id', scanId)
|
|
206
|
+
.maybeSingle();
|
|
207
|
+
if (error || !data) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
212
|
+
export async function listProductFeatures(supabase, productId) {
|
|
213
|
+
const { data, error } = await supabase
|
|
214
|
+
.from('product_features')
|
|
215
|
+
.select('id, name, description, status, source')
|
|
216
|
+
.eq('product_id', productId)
|
|
217
|
+
.order('created_at', { ascending: true });
|
|
218
|
+
if (error || !data) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
return data;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Claim the row by flipping `pending` → `running`. Returns true on success
|
|
225
|
+
* (we won the claim) and false when the row has already moved on (e.g. user
|
|
226
|
+
* cancelled before the CLI started). Bounded by the status filter so we
|
|
227
|
+
* can't accidentally resurrect a 'cancelled' row.
|
|
228
|
+
*/
|
|
229
|
+
export async function markRunning(supabase, scanId) {
|
|
230
|
+
const { data, error } = await supabase
|
|
231
|
+
.from('feature_scans')
|
|
232
|
+
.update({
|
|
233
|
+
status: 'running',
|
|
234
|
+
error: null,
|
|
235
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
236
|
+
})
|
|
237
|
+
.eq('id', scanId)
|
|
238
|
+
.in('status', ['pending', 'running'])
|
|
239
|
+
.select('id')
|
|
240
|
+
.maybeSingle();
|
|
241
|
+
if (error) {
|
|
242
|
+
logWarning(`Could not mark scan as running: ${error.message}`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return data !== null;
|
|
246
|
+
}
|
|
247
|
+
export async function heartbeat(supabase, scanId) {
|
|
248
|
+
const { error } = await supabase
|
|
249
|
+
.from('feature_scans')
|
|
250
|
+
.update({ last_heartbeat_at: new Date().toISOString() })
|
|
251
|
+
.eq('id', scanId)
|
|
252
|
+
.eq('status', 'running');
|
|
253
|
+
if (error) {
|
|
254
|
+
logWarning(`Heartbeat failed: ${error.message}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export async function markFailed(supabase, scanId, errorMessage) {
|
|
258
|
+
const { data, error } = await supabase
|
|
259
|
+
.from('feature_scans')
|
|
260
|
+
.update({
|
|
261
|
+
status: 'failed',
|
|
262
|
+
error: errorMessage,
|
|
263
|
+
completed_at: new Date().toISOString(),
|
|
264
|
+
})
|
|
265
|
+
.eq('id', scanId)
|
|
266
|
+
.in('status', ['pending', 'running'])
|
|
267
|
+
.select('id')
|
|
268
|
+
.maybeSingle();
|
|
269
|
+
if (error) {
|
|
270
|
+
logWarning(`Could not mark scan as failed: ${error.message}`);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return data !== null;
|
|
274
|
+
}
|
|
275
|
+
export async function markSuccess(supabase, scanId) {
|
|
276
|
+
const { data, error } = await supabase
|
|
277
|
+
.from('feature_scans')
|
|
278
|
+
.update({
|
|
279
|
+
status: 'success',
|
|
280
|
+
error: null,
|
|
281
|
+
completed_at: new Date().toISOString(),
|
|
282
|
+
})
|
|
283
|
+
.eq('id', scanId)
|
|
284
|
+
.in('status', ['pending', 'running'])
|
|
285
|
+
.select('id')
|
|
286
|
+
.maybeSingle();
|
|
287
|
+
if (error) {
|
|
288
|
+
logWarning(`Could not mark scan as success: ${error.message}`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return data !== null;
|
|
292
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing the features toolkit. Three tools:
|
|
3
|
+
*
|
|
4
|
+
* - create_feature INSERT a product_features row (source = 'agent')
|
|
5
|
+
* - update_feature UPDATE an existing row (enrichment, latest-wins on
|
|
6
|
+
* the fields passed)
|
|
7
|
+
* - remove_feature DELETE a previously agent-discovered row the agent
|
|
8
|
+
* confirmed is no longer present (manual rows are
|
|
9
|
+
* never deletable from a scan)
|
|
10
|
+
*
|
|
11
|
+
* All writes are scoped to the active feature_scan's product_id. The
|
|
12
|
+
* handlers persist directly via the Supabase client passed in by the
|
|
13
|
+
* orchestrator — there is no captured-extraction buffer to flush at the
|
|
14
|
+
* end. Each tool call is its own committed effect.
|
|
15
|
+
*/
|
|
16
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { type FeatureSummary } from './types.js';
|
|
19
|
+
export interface FeaturesToolContext {
|
|
20
|
+
supabase: SupabaseClient;
|
|
21
|
+
productId: string;
|
|
22
|
+
createdBy: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Records how many of each kind of mutation the agent made — exported so the
|
|
26
|
+
* orchestrator can print a summary line at the end of the scan.
|
|
27
|
+
*/
|
|
28
|
+
export interface FeaturesMutationCounts {
|
|
29
|
+
created: number;
|
|
30
|
+
updated: number;
|
|
31
|
+
removed: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function createFeaturesMutationCounts(): FeaturesMutationCounts;
|
|
34
|
+
export declare function createCreateFeatureTool(ctx: FeaturesToolContext, counts: FeaturesMutationCounts, knownFeatures: Map<string, FeatureSummary>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
35
|
+
name: z.ZodString;
|
|
36
|
+
description: z.ZodString;
|
|
37
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
38
|
+
shipped: "shipped";
|
|
39
|
+
deprecated: "deprecated";
|
|
40
|
+
planned: "planned";
|
|
41
|
+
in_development: "in_development";
|
|
42
|
+
}>>;
|
|
43
|
+
repos: z.ZodArray<z.ZodString>;
|
|
44
|
+
evidence: z.ZodString;
|
|
45
|
+
}>;
|
|
46
|
+
export declare function createUpdateFeatureTool(ctx: FeaturesToolContext, counts: FeaturesMutationCounts, knownFeatures: Map<string, FeatureSummary>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
47
|
+
feature_id: z.ZodString;
|
|
48
|
+
description: z.ZodOptional<z.ZodString>;
|
|
49
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
50
|
+
shipped: "shipped";
|
|
51
|
+
deprecated: "deprecated";
|
|
52
|
+
planned: "planned";
|
|
53
|
+
in_development: "in_development";
|
|
54
|
+
}>>;
|
|
55
|
+
repos: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
|
+
evidence: z.ZodOptional<z.ZodString>;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function createRemoveFeatureTool(ctx: FeaturesToolContext, counts: FeaturesMutationCounts, knownFeatures: Map<string, FeatureSummary>): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
59
|
+
feature_id: z.ZodString;
|
|
60
|
+
}>;
|
|
61
|
+
export declare function createFeaturesMcpServer(ctx: FeaturesToolContext, counts: FeaturesMutationCounts, existingFeatures: FeatureSummary[]): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing the features toolkit. Three tools:
|
|
3
|
+
*
|
|
4
|
+
* - create_feature INSERT a product_features row (source = 'agent')
|
|
5
|
+
* - update_feature UPDATE an existing row (enrichment, latest-wins on
|
|
6
|
+
* the fields passed)
|
|
7
|
+
* - remove_feature DELETE a previously agent-discovered row the agent
|
|
8
|
+
* confirmed is no longer present (manual rows are
|
|
9
|
+
* never deletable from a scan)
|
|
10
|
+
*
|
|
11
|
+
* All writes are scoped to the active feature_scan's product_id. The
|
|
12
|
+
* handlers persist directly via the Supabase client passed in by the
|
|
13
|
+
* orchestrator — there is no captured-extraction buffer to flush at the
|
|
14
|
+
* end. Each tool call is its own committed effect.
|
|
15
|
+
*/
|
|
16
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { FEATURE_DESCRIPTION_MAX, FEATURE_EVIDENCE_MAX, FEATURE_NAME_MAX, FEATURE_REPO_NAME_MAX, FEATURE_REPOS_MAX, } from './types.js';
|
|
19
|
+
export function createFeaturesMutationCounts() {
|
|
20
|
+
return { created: 0, updated: 0, removed: 0 };
|
|
21
|
+
}
|
|
22
|
+
const statusSchema = z
|
|
23
|
+
.enum(['planned', 'in_development', 'shipped', 'deprecated'])
|
|
24
|
+
.describe('Lifecycle status as evidenced by the code. Default "shipped"; use "in_development" for half-wired / flag-gated code.');
|
|
25
|
+
const reposSchema = z
|
|
26
|
+
.array(z.string().min(1).max(FEATURE_REPO_NAME_MAX))
|
|
27
|
+
.max(FEATURE_REPOS_MAX)
|
|
28
|
+
.describe('Repository full names ("owner/repo") where the feature is implemented. Only names from the repository list you were given.');
|
|
29
|
+
function normalizeRepos(repos) {
|
|
30
|
+
const cleaned = repos.map((r) => r.trim()).filter((r) => r.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 createCreateFeatureTool(ctx, counts, knownFeatures) {
|
|
45
|
+
return tool('create_feature', 'Register a feature that is not in the existing list for this product. Pass a short user-facing name, a 1-3 sentence description, the repos it lives in, and evidence (file paths / entry points).', {
|
|
46
|
+
name: z.string().min(1).max(FEATURE_NAME_MAX),
|
|
47
|
+
description: z.string().min(1).max(FEATURE_DESCRIPTION_MAX),
|
|
48
|
+
status: statusSchema.optional(),
|
|
49
|
+
repos: reposSchema,
|
|
50
|
+
evidence: z
|
|
51
|
+
.string()
|
|
52
|
+
.min(1)
|
|
53
|
+
.max(FEATURE_EVIDENCE_MAX)
|
|
54
|
+
.describe('Plain-text paragraph saying where in the code this feature shows up (file paths, routes, entry points).'),
|
|
55
|
+
}, async (args) => {
|
|
56
|
+
const name = args.name.trim();
|
|
57
|
+
const duplicate = [...knownFeatures.values()].find((f) => f.name.toLowerCase() === name.toLowerCase());
|
|
58
|
+
if (duplicate) {
|
|
59
|
+
return textError(`A feature named "${duplicate.name}" already exists (id=${duplicate.id}). Use update_feature instead.`);
|
|
60
|
+
}
|
|
61
|
+
const { data, error } = await ctx.supabase
|
|
62
|
+
.from('product_features')
|
|
63
|
+
.insert({
|
|
64
|
+
product_id: ctx.productId,
|
|
65
|
+
name,
|
|
66
|
+
description: args.description.trim(),
|
|
67
|
+
status: args.status ?? 'shipped',
|
|
68
|
+
source: 'agent',
|
|
69
|
+
repos: normalizeRepos(args.repos),
|
|
70
|
+
evidence: args.evidence.trim(),
|
|
71
|
+
created_by: ctx.createdBy,
|
|
72
|
+
})
|
|
73
|
+
.select('id')
|
|
74
|
+
.single();
|
|
75
|
+
if (error || !data) {
|
|
76
|
+
return textError(`Failed to create feature: ${error?.message ?? 'unknown error'}`);
|
|
77
|
+
}
|
|
78
|
+
knownFeatures.set(data.id, {
|
|
79
|
+
id: data.id,
|
|
80
|
+
name,
|
|
81
|
+
description: args.description.trim(),
|
|
82
|
+
status: args.status ?? 'shipped',
|
|
83
|
+
source: 'agent',
|
|
84
|
+
});
|
|
85
|
+
counts.created += 1;
|
|
86
|
+
return textOk(`Created feature ${data.id} ("${name}").`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export function createUpdateFeatureTool(ctx, counts, knownFeatures) {
|
|
90
|
+
return tool('update_feature', "Enrich an existing feature you corroborated in the code: evidence, repos, corrected status, or a better description. Omit fields you do not want to change. For source=manual entries only add evidence/repos/status or fill a missing description — never rewrite the user's intent.", {
|
|
91
|
+
feature_id: z.string().uuid().describe('id from the existing list'),
|
|
92
|
+
description: z.string().min(1).max(FEATURE_DESCRIPTION_MAX).optional(),
|
|
93
|
+
status: statusSchema.optional(),
|
|
94
|
+
repos: reposSchema.optional(),
|
|
95
|
+
evidence: z.string().min(1).max(FEATURE_EVIDENCE_MAX).optional(),
|
|
96
|
+
}, async (args) => {
|
|
97
|
+
const known = knownFeatures.get(args.feature_id);
|
|
98
|
+
if (!known) {
|
|
99
|
+
return textError(`feature_id ${args.feature_id} is not in this product's feature list. Use create_feature for new entries.`);
|
|
100
|
+
}
|
|
101
|
+
const patch = {};
|
|
102
|
+
if (args.description !== undefined) {
|
|
103
|
+
patch.description = args.description.trim();
|
|
104
|
+
}
|
|
105
|
+
if (args.status !== undefined) {
|
|
106
|
+
patch.status = args.status;
|
|
107
|
+
}
|
|
108
|
+
if (args.repos !== undefined) {
|
|
109
|
+
patch.repos = normalizeRepos(args.repos);
|
|
110
|
+
}
|
|
111
|
+
if (args.evidence !== undefined) {
|
|
112
|
+
patch.evidence = args.evidence.trim();
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(patch).length === 0) {
|
|
115
|
+
return textError('Nothing to update — pass at least one field.');
|
|
116
|
+
}
|
|
117
|
+
const { error } = await ctx.supabase
|
|
118
|
+
.from('product_features')
|
|
119
|
+
.update(patch)
|
|
120
|
+
.eq('id', args.feature_id)
|
|
121
|
+
.eq('product_id', ctx.productId);
|
|
122
|
+
if (error) {
|
|
123
|
+
return textError(`Failed to update feature: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
counts.updated += 1;
|
|
126
|
+
return textOk(`Updated feature ${args.feature_id} ("${known.name}").`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function createRemoveFeatureTool(ctx, counts, knownFeatures) {
|
|
130
|
+
return tool('remove_feature', 'Delete a previously agent-discovered feature (source=agent) that you have confirmed is NO LONGER present in the code. Manually defined features cannot be removed by a scan.', {
|
|
131
|
+
feature_id: z.string().uuid(),
|
|
132
|
+
}, async (args) => {
|
|
133
|
+
const known = knownFeatures.get(args.feature_id);
|
|
134
|
+
if (!known) {
|
|
135
|
+
return textError(`feature_id ${args.feature_id} is not in this product's feature list — nothing to remove.`);
|
|
136
|
+
}
|
|
137
|
+
if (known.source !== 'agent') {
|
|
138
|
+
return textError(`Feature ${args.feature_id} ("${known.name}") was defined manually and cannot be removed by a scan. If it looks wrong, leave it for the user.`);
|
|
139
|
+
}
|
|
140
|
+
const { error } = await ctx.supabase
|
|
141
|
+
.from('product_features')
|
|
142
|
+
.delete()
|
|
143
|
+
.eq('id', args.feature_id)
|
|
144
|
+
.eq('product_id', ctx.productId)
|
|
145
|
+
.eq('source', 'agent');
|
|
146
|
+
if (error) {
|
|
147
|
+
return textError(`Failed to remove feature: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
knownFeatures.delete(args.feature_id);
|
|
150
|
+
counts.removed += 1;
|
|
151
|
+
return textOk(`Removed feature ${args.feature_id} ("${known.name}").`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export function createFeaturesMcpServer(ctx, counts, existingFeatures) {
|
|
155
|
+
const knownFeatures = new Map(existingFeatures.map((f) => [f.id, f]));
|
|
156
|
+
return createSdkMcpServer({
|
|
157
|
+
name: 'features',
|
|
158
|
+
version: '1.0.0',
|
|
159
|
+
tools: [
|
|
160
|
+
createCreateFeatureTool(ctx, counts, knownFeatures),
|
|
161
|
+
createUpdateFeatureTool(ctx, counts, knownFeatures),
|
|
162
|
+
createRemoveFeatureTool(ctx, counts, knownFeatures),
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the features phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned repositories of a product (one or several
|
|
5
|
+
* — multi-repo products are cloned side by side as subdirectories of the
|
|
6
|
+
* working directory) and catalogue the user-facing FEATURES the product
|
|
7
|
+
* delivers. A feature is the WHAT from the user's perspective ("Export
|
|
8
|
+
* board as PDF", "Slack notifications", "SSO login"), not the HOW (that is
|
|
9
|
+
* what recipes capture) and not a tech-stack inventory.
|
|
10
|
+
*
|
|
11
|
+
* The agent is fed the product's existing features (manual + previously
|
|
12
|
+
* discovered) and must, for each feature it identifies, pick one of:
|
|
13
|
+
* - update_feature — an existing entry matches; enrich it with evidence /
|
|
14
|
+
* repos / corrected status
|
|
15
|
+
* - create_feature — nothing matches; new entry (source = 'agent')
|
|
16
|
+
* - remove_feature — a previously agent-discovered entry can no longer be
|
|
17
|
+
* corroborated in the code (manual entries are never
|
|
18
|
+
* removed)
|
|
19
|
+
*
|
|
20
|
+
* Submission is via MCP tool calls only — no fenced JSON.
|
|
21
|
+
*/
|
|
22
|
+
import type { FeatureSummary } from './types.js';
|
|
23
|
+
export interface FeaturesPromptContext {
|
|
24
|
+
productName: string;
|
|
25
|
+
productDescription?: string;
|
|
26
|
+
guidance?: string;
|
|
27
|
+
existingFeatures: FeatureSummary[];
|
|
28
|
+
/** Multi-repo note from describeRepoScope(); empty for single-repo. */
|
|
29
|
+
repoScopeNote: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function createFeaturesSystemPrompt(): string;
|
|
32
|
+
export declare function createFeaturesUserPrompt(ctx: FeaturesPromptContext): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for the features phase.
|
|
3
|
+
*
|
|
4
|
+
* Agent's job: explore the cloned repositories of a product (one or several
|
|
5
|
+
* — multi-repo products are cloned side by side as subdirectories of the
|
|
6
|
+
* working directory) and catalogue the user-facing FEATURES the product
|
|
7
|
+
* delivers. A feature is the WHAT from the user's perspective ("Export
|
|
8
|
+
* board as PDF", "Slack notifications", "SSO login"), not the HOW (that is
|
|
9
|
+
* what recipes capture) and not a tech-stack inventory.
|
|
10
|
+
*
|
|
11
|
+
* The agent is fed the product's existing features (manual + previously
|
|
12
|
+
* discovered) and must, for each feature it identifies, pick one of:
|
|
13
|
+
* - update_feature — an existing entry matches; enrich it with evidence /
|
|
14
|
+
* repos / corrected status
|
|
15
|
+
* - create_feature — nothing matches; new entry (source = 'agent')
|
|
16
|
+
* - remove_feature — a previously agent-discovered entry can no longer be
|
|
17
|
+
* corroborated in the code (manual entries are never
|
|
18
|
+
* removed)
|
|
19
|
+
*
|
|
20
|
+
* Submission is via MCP tool calls only — no fenced JSON.
|
|
21
|
+
*/
|
|
22
|
+
export function createFeaturesSystemPrompt() {
|
|
23
|
+
return `You are a senior product manager with a staff-engineer's eye, cataloguing the FEATURES a product delivers by reading its source code.
|
|
24
|
+
|
|
25
|
+
The current working directory contains a fresh clone of the product's repository (or, for multi-repo products, one subdirectory per repository). Use Glob/Grep/Read (and Bash for git log when helpful) to explore before writing anything.
|
|
26
|
+
|
|
27
|
+
## What is a "feature"?
|
|
28
|
+
|
|
29
|
+
A feature is ONE user-facing capability, named from the user's point of view. It answers "what can a user do with this product?", not "how is it built?".
|
|
30
|
+
|
|
31
|
+
Good feature names: "Export board as PDF", "Slack notifications", "Single sign-on (SAML)", "Real-time collaborative editing", "Usage-based billing".
|
|
32
|
+
Bad feature names: "React frontend", "Postgres database", "REST API", "Utils module" (implementation details, not user value).
|
|
33
|
+
|
|
34
|
+
Keep names short (a few words, Title-style), and the description 1–3 sentences in plain product language: what the user gets, plus any notable scope/limits you can see in the code.
|
|
35
|
+
|
|
36
|
+
## Output protocol
|
|
37
|
+
|
|
38
|
+
The MCP server exposes these tools — use them, do NOT paste content as fenced code:
|
|
39
|
+
|
|
40
|
+
1. \`create_feature({ name, description, status?, repos, evidence })\` — register a feature that is not in the existing list. \`status\` defaults to "shipped"; use "in_development" when the code is clearly behind an unfinished flag or half-wired.
|
|
41
|
+
|
|
42
|
+
2. \`update_feature({ feature_id, description?, status?, repos?, evidence? })\` — enrich an existing entry you corroborated in the code. Omit fields you don't want to change.
|
|
43
|
+
|
|
44
|
+
3. \`remove_feature({ feature_id })\` — drop a previously agent-discovered entry you could NOT corroborate anywhere in the code. Only entries marked source=agent can be removed; manually defined features are never deleted by a scan.
|
|
45
|
+
|
|
46
|
+
Call exactly one of create / update for each feature you identify, then remove for any stale agent entries. When you are done, end your turn — no summary message, no JSON dump.
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- \`repos\` is the list of repository full names ("owner/repo") where the feature is implemented — only use names from the repository list you were given.
|
|
51
|
+
- \`evidence\` is one short plain-text paragraph pointing at where the feature lives: key file paths, routes, entry points. For multi-repo products prefix paths with the repo subdirectory.
|
|
52
|
+
- Aim for the level of granularity a changelog or pricing page would use. Don't list every API endpoint as its own feature, and don't collapse the whole product into one feature. Most products have 5–30 features.
|
|
53
|
+
- Don't invent features. If you can't point at concrete code, don't record it.
|
|
54
|
+
- Existing entries: match by meaning, not exact name. If an existing feature (manual or agent) covers what you found, \`update_feature\` it instead of creating a near-duplicate.
|
|
55
|
+
- Manual entries (source=manual) are the user's own definitions: never rewrite their intent. You may add \`evidence\` / \`repos\`, correct an obviously wrong \`status\`, or fill in a missing description — nothing more.`;
|
|
56
|
+
}
|
|
57
|
+
export function createFeaturesUserPrompt(ctx) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push(`# Product: ${ctx.productName}`);
|
|
60
|
+
if (ctx.productDescription) {
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('## Description');
|
|
63
|
+
lines.push(ctx.productDescription);
|
|
64
|
+
}
|
|
65
|
+
if (ctx.repoScopeNote) {
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('## Repositories');
|
|
68
|
+
lines.push(ctx.repoScopeNote);
|
|
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 features for this product');
|
|
77
|
+
if (ctx.existingFeatures.length === 0) {
|
|
78
|
+
lines.push('(none — every feature you identify will be a `create_feature`.)');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push('Prefer `update_feature` over `create_feature` when one of these matches what you found. Entries marked source=manual were written by a human — enrich, never rewrite.');
|
|
82
|
+
lines.push('');
|
|
83
|
+
for (const f of ctx.existingFeatures) {
|
|
84
|
+
const desc = f.description ? ` — ${f.description}` : '';
|
|
85
|
+
lines.push(`- id=${f.id} "${f.name}" [status=${f.status}, source=${f.source}]${desc}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push('## Task');
|
|
90
|
+
lines.push('Explore the cloned repositories, identify every user-facing feature the product delivers, and for each call exactly one of `create_feature` / `update_feature`. Then call `remove_feature` for any previously agent-discovered entry you could not corroborate. End your turn when done.');
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the features 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 type FeatureStatus = 'planned' | 'in_development' | 'shipped' | 'deprecated';
|
|
8
|
+
export type FeatureSource = 'manual' | 'agent';
|
|
9
|
+
export interface FeatureSummary {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
status: FeatureStatus;
|
|
14
|
+
source: FeatureSource;
|
|
15
|
+
}
|
|
16
|
+
export interface CreateFeatureArgs {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
status?: FeatureStatus;
|
|
20
|
+
repos: string[];
|
|
21
|
+
evidence: string;
|
|
22
|
+
}
|
|
23
|
+
export interface UpdateFeatureArgs {
|
|
24
|
+
featureId: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
status?: FeatureStatus;
|
|
27
|
+
repos?: string[];
|
|
28
|
+
evidence?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const FEATURE_NAME_MAX = 200;
|
|
31
|
+
export declare const FEATURE_DESCRIPTION_MAX = 10000;
|
|
32
|
+
export declare const FEATURE_EVIDENCE_MAX = 4000;
|
|
33
|
+
export declare const FEATURE_REPOS_MAX = 20;
|
|
34
|
+
export declare const FEATURE_REPO_NAME_MAX = 200;
|