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.
- package/dist/api/adr.d.ts +48 -0
- package/dist/api/adr.js +139 -0
- package/dist/commands/adr/index.d.ts +13 -0
- package/dist/commands/adr/index.js +31 -0
- 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 +66 -4
- package/dist/phases/adr-generation/agent.d.ts +6 -0
- package/dist/phases/adr-generation/agent.js +69 -0
- package/dist/phases/adr-generation/index.d.ts +15 -0
- package/dist/phases/adr-generation/index.js +66 -0
- package/dist/phases/adr-generation/parse.d.ts +12 -0
- package/dist/phases/adr-generation/parse.js +123 -0
- package/dist/phases/adr-generation/prompts.d.ts +8 -0
- package/dist/phases/adr-generation/prompts.js +35 -0
- package/dist/phases/data-flow/mcp-server.d.ts +1 -1
- 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/quality-benchmark/parsers.js +79 -0
- package/dist/phases/quality-benchmark/rubric.md +125 -17
- package/dist/phases/quality-benchmark/tool-catalog.js +39 -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/dist/skills/phase/adr-generation/SKILL.md +51 -0
- 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
|
+
}
|