edsger 0.73.0 → 0.75.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.
Files changed (47) hide show
  1. package/dist/api/adr.d.ts +48 -0
  2. package/dist/api/adr.js +139 -0
  3. package/dist/commands/adr/index.d.ts +13 -0
  4. package/dist/commands/adr/index.js +31 -0
  5. package/dist/commands/features/index.d.ts +15 -0
  6. package/dist/commands/features/index.js +34 -0
  7. package/dist/commands/pr-resolve/index.d.ts +3 -1
  8. package/dist/commands/pr-resolve/index.js +12 -7
  9. package/dist/commands/pr-review/index.d.ts +3 -1
  10. package/dist/commands/pr-review/index.js +10 -6
  11. package/dist/commands/sync-github-pull-requests/index.d.ts +11 -0
  12. package/dist/commands/sync-github-pull-requests/index.js +42 -0
  13. package/dist/index.js +66 -4
  14. package/dist/phases/adr-generation/agent.d.ts +6 -0
  15. package/dist/phases/adr-generation/agent.js +69 -0
  16. package/dist/phases/adr-generation/index.d.ts +15 -0
  17. package/dist/phases/adr-generation/index.js +66 -0
  18. package/dist/phases/adr-generation/parse.d.ts +12 -0
  19. package/dist/phases/adr-generation/parse.js +123 -0
  20. package/dist/phases/adr-generation/prompts.d.ts +8 -0
  21. package/dist/phases/adr-generation/prompts.js +35 -0
  22. package/dist/phases/data-flow/mcp-server.d.ts +1 -1
  23. package/dist/phases/features/index.d.ts +65 -0
  24. package/dist/phases/features/index.js +292 -0
  25. package/dist/phases/features/mcp-server.d.ts +61 -0
  26. package/dist/phases/features/mcp-server.js +165 -0
  27. package/dist/phases/features/prompts.d.ts +32 -0
  28. package/dist/phases/features/prompts.js +92 -0
  29. package/dist/phases/features/types.d.ts +34 -0
  30. package/dist/phases/features/types.js +15 -0
  31. package/dist/phases/pr-resolve/index.d.ts +3 -1
  32. package/dist/phases/pr-resolve/index.js +12 -12
  33. package/dist/phases/pr-review/index.d.ts +3 -1
  34. package/dist/phases/pr-review/index.js +13 -16
  35. package/dist/phases/pr-shared/status.d.ts +18 -0
  36. package/dist/phases/pr-shared/status.js +37 -0
  37. package/dist/phases/quality-benchmark/parsers.js +79 -0
  38. package/dist/phases/quality-benchmark/rubric.md +125 -17
  39. package/dist/phases/quality-benchmark/tool-catalog.js +39 -0
  40. package/dist/phases/sync-github-pull-requests/index.d.ts +23 -0
  41. package/dist/phases/sync-github-pull-requests/index.js +210 -0
  42. package/dist/phases/sync-github-pull-requests/state.d.ts +24 -0
  43. package/dist/phases/sync-github-pull-requests/state.js +16 -0
  44. package/dist/phases/sync-github-pull-requests/types.d.ts +22 -0
  45. package/dist/phases/sync-github-pull-requests/types.js +1 -0
  46. package/dist/skills/phase/adr-generation/SKILL.md +51 -0
  47. package/package.json +1 -1
@@ -0,0 +1,15 @@
1
+ export interface AdrGenerationOptions {
2
+ adrId: string;
3
+ verbose?: boolean;
4
+ }
5
+ export interface AdrGenerationResult {
6
+ status: 'success' | 'error';
7
+ optionCount: number;
8
+ error?: string;
9
+ }
10
+ /**
11
+ * Generate decision options for a draft ADR: gather context, run the agent,
12
+ * persist the resulting options, and flip the ADR to `proposed`/`ready` for the
13
+ * human to choose. The draft row is created by the desktop app beforehand.
14
+ */
15
+ export declare function generateAdr(options: AdrGenerationOptions): Promise<AdrGenerationResult>;
@@ -0,0 +1,66 @@
1
+ import { getAdr, loadAdrContext, markGenerationFailed, markGenerationReady, saveAdrOptions, } from '../../api/adr.js';
2
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
3
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
4
+ import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
5
+ import { executeAdrQuery } from './agent.js';
6
+ import { buildAdrUserPrompt, createAdrSystemPrompt } from './prompts.js';
7
+ /** Best-effort clone of the relevant repo so the agent can read the codebase. */
8
+ async function tryCloneRepo(adr, verbose) {
9
+ try {
10
+ const gh = adr.product_id
11
+ ? await getGitHubConfigByProduct(adr.product_id, verbose)
12
+ : await getGitHubConfigByRepository(adr.repository_id, verbose);
13
+ if (gh.configured && gh.token && gh.owner && gh.repo) {
14
+ const workspaceRoot = ensureWorkspaceDir();
15
+ const { repoPath } = cloneIssueRepo(workspaceRoot, `adr-${adr.id}`, gh.owner, gh.repo, gh.token);
16
+ logInfo(`Repository cloned to: ${repoPath}`);
17
+ return repoPath;
18
+ }
19
+ }
20
+ catch (error) {
21
+ logInfo(`Could not clone repo (continuing without codebase): ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ return undefined;
24
+ }
25
+ /**
26
+ * Generate decision options for a draft ADR: gather context, run the agent,
27
+ * persist the resulting options, and flip the ADR to `proposed`/`ready` for the
28
+ * human to choose. The draft row is created by the desktop app beforehand.
29
+ */
30
+ export async function generateAdr(options) {
31
+ const { adrId, verbose } = options;
32
+ let repoCwd;
33
+ try {
34
+ const adr = await getAdr(adrId);
35
+ logInfo(`Generating decision options for: ${adr.title}`);
36
+ const context = await loadAdrContext(adr);
37
+ repoCwd = await tryCloneRepo(adr, verbose);
38
+ const systemPrompt = await createAdrSystemPrompt(Boolean(repoCwd));
39
+ const userPrompt = buildAdrUserPrompt(adr, context);
40
+ const generated = await executeAdrQuery(userPrompt, systemPrompt, verbose, repoCwd);
41
+ if (!generated || generated.length === 0) {
42
+ const message = 'The agent did not return any decision options.';
43
+ await markGenerationFailed(adrId, message);
44
+ return { status: 'error', optionCount: 0, error: message };
45
+ }
46
+ await saveAdrOptions(adrId, generated);
47
+ await markGenerationReady(adrId);
48
+ logSuccess(`Generated ${generated.length} decision options.`);
49
+ for (const o of generated) {
50
+ logInfo(` ${o.is_recommended ? '★' : '•'} ${o.title}`);
51
+ }
52
+ logInfo('\nReview and choose one in the Desktop app → ADR tab');
53
+ return { status: 'success', optionCount: generated.length };
54
+ }
55
+ catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ logError(`ADR generation failed: ${message}`);
58
+ await markGenerationFailed(adrId, message);
59
+ return { status: 'error', optionCount: 0, error: message };
60
+ }
61
+ finally {
62
+ if (repoCwd) {
63
+ cleanupIssueRepo(repoCwd);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,12 @@
1
+ import { type AdrOptionInput } from '../../api/adr.js';
2
+ /**
3
+ * Pure parsing/normalization helpers for the ADR agent response. Kept separate
4
+ * from agent.ts (which owns the SDK query loop) so they can be unit-tested
5
+ * without spinning up the model.
6
+ */
7
+ /** Index of the matching closer for the opener at `start`, respecting strings. */
8
+ export declare function findMatchingClose(text: string, start: number): number;
9
+ export declare function extractJSON(text: string): any | null;
10
+ export declare function asStringArray(value: unknown): string[];
11
+ /** Coerce the parsed JSON into validated option inputs. */
12
+ export declare function normalizeOptions(parsed: unknown): AdrOptionInput[];
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure parsing/normalization helpers for the ADR agent response. Kept separate
3
+ * from agent.ts (which owns the SDK query loop) so they can be unit-tested
4
+ * without spinning up the model.
5
+ */
6
+ /** Index of the matching closer for the opener at `start`, respecting strings. */
7
+ export function findMatchingClose(text, start) {
8
+ const opener = text[start];
9
+ const closer = opener === '{' ? '}' : ']';
10
+ let depth = 0;
11
+ let inString = false;
12
+ let escape = false;
13
+ for (let i = start; i < text.length; i++) {
14
+ const ch = text[i];
15
+ if (escape) {
16
+ escape = false;
17
+ continue;
18
+ }
19
+ if (ch === '\\' && inString) {
20
+ escape = true;
21
+ continue;
22
+ }
23
+ if (ch === '"') {
24
+ inString = !inString;
25
+ continue;
26
+ }
27
+ if (inString) {
28
+ continue;
29
+ }
30
+ if (ch === opener) {
31
+ depth++;
32
+ }
33
+ else if (ch === closer) {
34
+ depth--;
35
+ if (depth === 0) {
36
+ return i;
37
+ }
38
+ }
39
+ }
40
+ return -1;
41
+ }
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ export function extractJSON(text) {
44
+ // Largest ```json block first.
45
+ const jsonBlocks = [...text.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)];
46
+ if (jsonBlocks.length > 0) {
47
+ const largest = jsonBlocks.reduce((a, b) => a[1].length >= b[1].length ? a : b);
48
+ try {
49
+ return JSON.parse(largest[1]);
50
+ }
51
+ catch {
52
+ /* fall through */
53
+ }
54
+ }
55
+ // Generic code block.
56
+ const codeBlock = text.match(/```\s*\n([\s\S]*?)\n\s*```/);
57
+ if (codeBlock) {
58
+ try {
59
+ return JSON.parse(codeBlock[1]);
60
+ }
61
+ catch {
62
+ /* fall through */
63
+ }
64
+ }
65
+ // Balanced { ... } scan.
66
+ for (let i = 0; i < text.length; i++) {
67
+ if (text[i] === '{') {
68
+ const end = findMatchingClose(text, i);
69
+ if (end !== -1) {
70
+ try {
71
+ return JSON.parse(text.slice(i, end + 1));
72
+ }
73
+ catch {
74
+ i = end;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ try {
80
+ return JSON.parse(text);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export function asStringArray(value) {
87
+ if (!Array.isArray(value)) {
88
+ return [];
89
+ }
90
+ return value
91
+ .map((v) => (typeof v === 'string' ? v : String(v)))
92
+ .map((s) => s.trim())
93
+ .filter(Boolean);
94
+ }
95
+ /** Coerce the parsed JSON into validated option inputs. */
96
+ export function normalizeOptions(parsed) {
97
+ const root = parsed;
98
+ const rawOptions = Array.isArray(root?.options)
99
+ ? root?.options
100
+ : Array.isArray(parsed)
101
+ ? parsed
102
+ : [];
103
+ const options = [];
104
+ for (const raw of rawOptions ?? []) {
105
+ const o = raw;
106
+ const title = typeof o.title === 'string' ? o.title.trim() : '';
107
+ if (!title) {
108
+ continue;
109
+ }
110
+ options.push({
111
+ title,
112
+ summary: typeof o.summary === 'string' ? o.summary.trim() : undefined,
113
+ pros: asStringArray(o.pros),
114
+ cons: asStringArray(o.cons),
115
+ is_recommended: o.is_recommended === true,
116
+ });
117
+ }
118
+ // Guarantee exactly one recommended option when any exist.
119
+ if (options.length > 0 && !options.some((o) => o.is_recommended)) {
120
+ options[0].is_recommended = true;
121
+ }
122
+ return options;
123
+ }
@@ -0,0 +1,8 @@
1
+ import { type AdrContext, type AdrRow } from '../../api/adr.js';
2
+ /**
3
+ * Build the ADR agent's system prompt from the bundled (and user-overridable)
4
+ * `phase/adr-generation` SKILL.md. Conditional blocks are toggled by whether a
5
+ * codebase is available in the working directory.
6
+ */
7
+ export declare function createAdrSystemPrompt(hasCodebase: boolean, projectDir?: string): Promise<string>;
8
+ export declare function buildAdrUserPrompt(adr: AdrRow, context: AdrContext): string;
@@ -0,0 +1,35 @@
1
+ import { processConditionals, resolveSkill, } from '../../services/skill-resolver.js';
2
+ /**
3
+ * Build the ADR agent's system prompt from the bundled (and user-overridable)
4
+ * `phase/adr-generation` SKILL.md. Conditional blocks are toggled by whether a
5
+ * codebase is available in the working directory.
6
+ */
7
+ export async function createAdrSystemPrompt(hasCodebase, projectDir) {
8
+ const skill = await resolveSkill('phase/adr-generation', { projectDir });
9
+ if (!skill) {
10
+ throw new Error('Failed to load skill: phase/adr-generation');
11
+ }
12
+ return processConditionals(skill.prompt, { hasCodebase });
13
+ }
14
+ export function buildAdrUserPrompt(adr, context) {
15
+ const lines = [];
16
+ lines.push(`# Decision to make\n${adr.title}`);
17
+ if (adr.generation_prompt && adr.generation_prompt !== adr.title) {
18
+ lines.push(`\n## Direction from the human\n${adr.generation_prompt}`);
19
+ }
20
+ if (adr.context) {
21
+ lines.push(`\n## Additional context\n${adr.context}`);
22
+ }
23
+ lines.push(`\n## ${context.scope === 'product' ? 'Product' : 'Repository'}\n${context.name}${context.description ? `\n${context.description}` : ''}`);
24
+ if (context.repos.length > 0) {
25
+ lines.push(`\n## Linked repositories\n${context.repos.join('\n')}`);
26
+ }
27
+ if (context.issues.length > 0) {
28
+ lines.push(`\n## Recent issues (for situational awareness)\n${context.issues
29
+ .slice(0, 25)
30
+ .map((i) => `- ${i}`)
31
+ .join('\n')}`);
32
+ }
33
+ lines.push(`\nProduce the options JSON per the output contract. Investigate before answering.`);
34
+ return lines.join('\n');
35
+ }
@@ -25,8 +25,8 @@ export declare function createSubmitDataFlowTool(state: DataFlowCaptureState): i
25
25
  slug: z.ZodString;
26
26
  name: z.ZodString;
27
27
  kind: z.ZodEnum<{
28
- model: "model";
29
28
  source: "source";
29
+ model: "model";
30
30
  transform: "transform";
31
31
  dataset: "dataset";
32
32
  sink: "sink";
@@ -0,0 +1,65 @@
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 type { SupabaseClient } from '@supabase/supabase-js';
27
+ import { type ClonedRepo } from '../diagram-shared/clone-repos.js';
28
+ import type { FeatureSummary } from './types.js';
29
+ export interface FeaturesPhaseOptions {
30
+ productId: string;
31
+ scanId: string;
32
+ guidance?: string;
33
+ verbose?: boolean;
34
+ }
35
+ export interface FeaturesPhaseResult {
36
+ status: 'success' | 'error' | 'cancelled';
37
+ message: string;
38
+ counts?: {
39
+ created: number;
40
+ updated: number;
41
+ removed: number;
42
+ };
43
+ }
44
+ /**
45
+ * Repo-scope note for the agent's user prompt. Unlike the diagram phases'
46
+ * describeRepoScope (which asks for one unified flow), this tells the agent
47
+ * the exact full names it may use in the `repos` field of each feature.
48
+ */
49
+ export declare function describeFeatureRepoScope(repos: ClonedRepo[]): string;
50
+ export declare function runFeaturesPhase(options: FeaturesPhaseOptions): Promise<FeaturesPhaseResult>;
51
+ export declare function listProductRepositoryIds(supabase: SupabaseClient, productId: string): Promise<string[]>;
52
+ export declare function getScanCreator(supabase: SupabaseClient, scanId: string): Promise<{
53
+ created_by: string;
54
+ } | null>;
55
+ export declare function listProductFeatures(supabase: SupabaseClient, productId: string): Promise<FeatureSummary[]>;
56
+ /**
57
+ * Claim the row by flipping `pending` → `running`. Returns true on success
58
+ * (we won the claim) and false when the row has already moved on (e.g. user
59
+ * cancelled before the CLI started). Bounded by the status filter so we
60
+ * can't accidentally resurrect a 'cancelled' row.
61
+ */
62
+ export declare function markRunning(supabase: SupabaseClient, scanId: string): Promise<boolean>;
63
+ export declare function heartbeat(supabase: SupabaseClient, scanId: string): Promise<void>;
64
+ export declare function markFailed(supabase: SupabaseClient, scanId: string, errorMessage: string): Promise<boolean>;
65
+ export declare function markSuccess(supabase: SupabaseClient, scanId: string): Promise<boolean>;
@@ -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
+ }