edsger 0.73.0 → 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/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 +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger features <productId> --scan-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Clones every repository linked to the product, asks Claude to catalogue
|
|
5
|
+
* the user-facing features it delivers, and persists them via the
|
|
6
|
+
* product_features table. The desktop UI creates a pending feature_scans
|
|
7
|
+
* row first then invokes the CLI with --scan-id; the CLI flips status
|
|
8
|
+
* running → success/failed and writes features via the MCP toolkit.
|
|
9
|
+
*/
|
|
10
|
+
export interface FeaturesCliOptions {
|
|
11
|
+
scanId: string;
|
|
12
|
+
guidance?: string;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function runFeatures(productId: string, options: FeaturesCliOptions): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger features <productId> --scan-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Clones every repository linked to the product, asks Claude to catalogue
|
|
5
|
+
* the user-facing features it delivers, and persists them via the
|
|
6
|
+
* product_features table. The desktop UI creates a pending feature_scans
|
|
7
|
+
* row first then invokes the CLI with --scan-id; the CLI flips status
|
|
8
|
+
* running → success/failed and writes features via the MCP toolkit.
|
|
9
|
+
*/
|
|
10
|
+
import { runFeaturesPhase } from '../../phases/features/index.js';
|
|
11
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
12
|
+
export async function runFeatures(productId, options) {
|
|
13
|
+
const { scanId, guidance, verbose } = options;
|
|
14
|
+
if (!productId) {
|
|
15
|
+
throw new Error('A product ID is required for features');
|
|
16
|
+
}
|
|
17
|
+
if (!scanId) {
|
|
18
|
+
throw new Error('--scan-id is required (the pending feature_scans row id)');
|
|
19
|
+
}
|
|
20
|
+
logInfo(`Starting features scan for product ${productId}`);
|
|
21
|
+
const result = await runFeaturesPhase({
|
|
22
|
+
productId,
|
|
23
|
+
scanId,
|
|
24
|
+
guidance,
|
|
25
|
+
verbose,
|
|
26
|
+
});
|
|
27
|
+
if (result.status === 'success') {
|
|
28
|
+
logSuccess(result.message);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
logError(result.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
export interface PRResolveCliOptions {
|
|
6
6
|
prUrl: string;
|
|
7
7
|
prId?: string;
|
|
8
|
+
/** Repo-only mode: resolve GitHub config from a repositories row. */
|
|
9
|
+
repoId?: string;
|
|
8
10
|
verbose?: boolean;
|
|
9
11
|
learn?: boolean;
|
|
10
12
|
}
|
|
11
|
-
export declare function runPRResolve(productId: string, options: PRResolveCliOptions): Promise<void>;
|
|
13
|
+
export declare function runPRResolve(productId: string | undefined, options: PRResolveCliOptions): Promise<void>;
|
|
@@ -2,31 +2,36 @@
|
|
|
2
2
|
* CLI command: edsger pr-resolve <productId> --pr-url <url>
|
|
3
3
|
* Resolves PR change requests: fixes what's genuinely better, explains what it won't change.
|
|
4
4
|
*/
|
|
5
|
-
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
5
|
+
import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
|
|
6
6
|
import { resolveStandalonePR } from '../../phases/pr-resolve/index.js';
|
|
7
7
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
8
8
|
export async function runPRResolve(productId, options) {
|
|
9
|
-
const { prUrl, prId, verbose } = options;
|
|
10
|
-
|
|
9
|
+
const { prUrl, prId, repoId, verbose } = options;
|
|
10
|
+
const scopeLabel = repoId ? `repository ${repoId}` : `product ${productId}`;
|
|
11
|
+
logInfo(`Starting PR resolve for ${scopeLabel}`);
|
|
11
12
|
logInfo(`PR URL: ${prUrl}`);
|
|
12
|
-
//
|
|
13
|
-
const githubConfig =
|
|
13
|
+
// Resolve GitHub config from the repository (repo-only mode) or the product.
|
|
14
|
+
const githubConfig = repoId
|
|
15
|
+
? await getGitHubConfigByRepository(repoId, verbose)
|
|
16
|
+
: await getGitHubConfigByProduct(productId ?? '', verbose);
|
|
14
17
|
if (!githubConfig.configured ||
|
|
15
18
|
!githubConfig.token ||
|
|
16
19
|
!githubConfig.owner ||
|
|
17
20
|
!githubConfig.repo) {
|
|
18
|
-
logError(`GitHub not configured for
|
|
21
|
+
logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
|
|
19
22
|
process.exit(1);
|
|
20
23
|
}
|
|
21
24
|
const result = await resolveStandalonePR({
|
|
22
25
|
productId,
|
|
26
|
+
repositoryId: repoId,
|
|
23
27
|
pullRequestUrl: prUrl,
|
|
24
28
|
githubToken: githubConfig.token,
|
|
25
29
|
owner: githubConfig.owner,
|
|
26
30
|
repo: githubConfig.repo,
|
|
27
31
|
prId,
|
|
28
32
|
verbose,
|
|
29
|
-
|
|
33
|
+
// Checklist learning is product-scoped; skip it in repo-only mode.
|
|
34
|
+
learn: repoId ? false : options.learn,
|
|
30
35
|
});
|
|
31
36
|
if (result.status === 'success') {
|
|
32
37
|
logSuccess(`PR resolve completed: ${result.message}`);
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export interface PRReviewCliOptions {
|
|
6
6
|
prUrl: string;
|
|
7
7
|
prId?: string;
|
|
8
|
+
/** Repo-only mode: resolve GitHub config from a repositories row. */
|
|
9
|
+
repoId?: string;
|
|
8
10
|
verbose?: boolean;
|
|
9
11
|
}
|
|
10
|
-
export declare function runPRReview(productId: string, options: PRReviewCliOptions): Promise<void>;
|
|
12
|
+
export declare function runPRReview(productId: string | undefined, options: PRReviewCliOptions): Promise<void>;
|
|
@@ -2,24 +2,28 @@
|
|
|
2
2
|
* CLI command: edsger pr-review <productId> --pr-url <url>
|
|
3
3
|
* Reviews a standalone GitHub PR and posts comments.
|
|
4
4
|
*/
|
|
5
|
-
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
5
|
+
import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
|
|
6
6
|
import { reviewStandalonePR } from '../../phases/pr-review/index.js';
|
|
7
7
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
8
8
|
export async function runPRReview(productId, options) {
|
|
9
|
-
const { prUrl, prId, verbose } = options;
|
|
10
|
-
|
|
9
|
+
const { prUrl, prId, repoId, verbose } = options;
|
|
10
|
+
const scopeLabel = repoId ? `repository ${repoId}` : `product ${productId}`;
|
|
11
|
+
logInfo(`Starting PR review for ${scopeLabel}`);
|
|
11
12
|
logInfo(`PR URL: ${prUrl}`);
|
|
12
|
-
//
|
|
13
|
-
const githubConfig =
|
|
13
|
+
// Resolve GitHub config from the repository (repo-only mode) or the product.
|
|
14
|
+
const githubConfig = repoId
|
|
15
|
+
? await getGitHubConfigByRepository(repoId, verbose)
|
|
16
|
+
: await getGitHubConfigByProduct(productId ?? '', verbose);
|
|
14
17
|
if (!githubConfig.configured ||
|
|
15
18
|
!githubConfig.token ||
|
|
16
19
|
!githubConfig.owner ||
|
|
17
20
|
!githubConfig.repo) {
|
|
18
|
-
logError(`GitHub not configured for
|
|
21
|
+
logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
|
|
19
22
|
process.exit(1);
|
|
20
23
|
}
|
|
21
24
|
const result = await reviewStandalonePR({
|
|
22
25
|
productId,
|
|
26
|
+
repositoryId: repoId,
|
|
23
27
|
pullRequestUrl: prUrl,
|
|
24
28
|
githubToken: githubConfig.token,
|
|
25
29
|
owner: githubConfig.owner,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-github-pull-requests <repoId>`
|
|
3
|
+
*
|
|
4
|
+
* Mirrors a connected GitHub repo's pull requests into the local
|
|
5
|
+
* `pull_requests` table (scoped by repository_id) for the repo detail page.
|
|
6
|
+
* Idempotent — already-synced PRs are refreshed, never duplicated.
|
|
7
|
+
*/
|
|
8
|
+
export interface SyncGithubPullRequestsCliOptions {
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function runSyncGithubPullRequests(repositoryId: string, options?: SyncGithubPullRequestsCliOptions): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-github-pull-requests <repoId>`
|
|
3
|
+
*
|
|
4
|
+
* Mirrors a connected GitHub repo's pull requests into the local
|
|
5
|
+
* `pull_requests` table (scoped by repository_id) for the repo detail page.
|
|
6
|
+
* Idempotent — already-synced PRs are refreshed, never duplicated.
|
|
7
|
+
*/
|
|
8
|
+
import { getGitHubConfigByRepository } from '../../api/github.js';
|
|
9
|
+
import { syncGithubPullRequests } from '../../phases/sync-github-pull-requests/index.js';
|
|
10
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
11
|
+
export async function runSyncGithubPullRequests(repositoryId, options = {}) {
|
|
12
|
+
const { verbose } = options;
|
|
13
|
+
logInfo(`Starting GitHub pull request sync for repository ${repositoryId}`);
|
|
14
|
+
const githubConfig = await getGitHubConfigByRepository(repositoryId, verbose);
|
|
15
|
+
if (!githubConfig.configured ||
|
|
16
|
+
!githubConfig.token ||
|
|
17
|
+
!githubConfig.owner ||
|
|
18
|
+
!githubConfig.repo) {
|
|
19
|
+
logError(`GitHub not configured for repository ${repositoryId}: ${githubConfig.message || 'No installation found'}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const result = await syncGithubPullRequests({
|
|
23
|
+
repositoryId,
|
|
24
|
+
githubToken: githubConfig.token,
|
|
25
|
+
owner: githubConfig.owner,
|
|
26
|
+
repo: githubConfig.repo,
|
|
27
|
+
verbose,
|
|
28
|
+
});
|
|
29
|
+
if (result.status === 'success') {
|
|
30
|
+
logSuccess(`GitHub PR sync completed: ${result.message}`);
|
|
31
|
+
if (result.repository) {
|
|
32
|
+
logInfo(`Repository: ${result.repository}`);
|
|
33
|
+
}
|
|
34
|
+
if (result.fetchedCount !== undefined) {
|
|
35
|
+
logInfo(`Fetched ${result.fetchedCount} · created ${result.createdCount ?? 0} · updated ${result.updatedCount ?? 0}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logError(`GitHub PR sync failed: ${result.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './co
|
|
|
19
19
|
import { runDataFlow } from './commands/data-flow/index.js';
|
|
20
20
|
import { runDiscover } from './commands/discover/index.js';
|
|
21
21
|
import { runErDiagram } from './commands/er-diagram/index.js';
|
|
22
|
+
import { runFeatures } from './commands/features/index.js';
|
|
22
23
|
import { runFinancingDeck } from './commands/financing-deck/index.js';
|
|
23
24
|
import { runFindArchitecture } from './commands/find-architecture/index.js';
|
|
24
25
|
import { runFindBugs } from './commands/find-bugs/index.js';
|
|
@@ -46,6 +47,7 @@ import { runStateDiagram } from './commands/state-diagram/index.js';
|
|
|
46
47
|
import { runSyncAws } from './commands/sync-aws/index.js';
|
|
47
48
|
import { runSyncDatadog } from './commands/sync-datadog/index.js';
|
|
48
49
|
import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
|
|
50
|
+
import { runSyncGithubPullRequests } from './commands/sync-github-pull-requests/index.js';
|
|
49
51
|
import { runSyncOrgRepos } from './commands/sync-org-repos/index.js';
|
|
50
52
|
import { runSyncSentryIssues } from './commands/sync-sentry-issues/index.js';
|
|
51
53
|
import { runSyncTerraform } from './commands/sync-terraform/index.js';
|
|
@@ -763,13 +765,17 @@ program
|
|
|
763
765
|
// Subcommand: edsger pr-review <productId>
|
|
764
766
|
// ============================================================
|
|
765
767
|
program
|
|
766
|
-
.command('pr-review
|
|
767
|
-
.description('AI-review a GitHub PR for a product')
|
|
768
|
+
.command('pr-review [productId]')
|
|
769
|
+
.description('AI-review a GitHub PR for a product (or standalone repository)')
|
|
768
770
|
.requiredOption('--pr-url <url>', 'GitHub PR URL')
|
|
769
771
|
.option('--pr-id <id>', 'Pull request record ID in database')
|
|
772
|
+
.option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
|
|
770
773
|
.option('-v, --verbose', 'Verbose output')
|
|
771
774
|
.action(async (productId, opts) => {
|
|
772
775
|
try {
|
|
776
|
+
if (!productId && !opts.repoId) {
|
|
777
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for pr-review');
|
|
778
|
+
}
|
|
773
779
|
await runPRReview(productId, opts);
|
|
774
780
|
}
|
|
775
781
|
catch (error) {
|
|
@@ -842,6 +848,21 @@ program
|
|
|
842
848
|
}
|
|
843
849
|
});
|
|
844
850
|
// ============================================================
|
|
851
|
+
// Subcommand: edsger sync-github-pull-requests <repoId>
|
|
852
|
+
// ============================================================
|
|
853
|
+
program
|
|
854
|
+
.command('sync-github-pull-requests <repoId>')
|
|
855
|
+
.description("Mirror a connected GitHub repo's pull requests into the local pull_requests list for the repo detail page (idempotent — refreshes, never duplicates)")
|
|
856
|
+
.option('-v, --verbose', 'Verbose output')
|
|
857
|
+
.action(async (repoId, opts) => {
|
|
858
|
+
try {
|
|
859
|
+
await runSyncGithubPullRequests(repoId, opts);
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
exitWithError(error);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
// ============================================================
|
|
845
866
|
// Subcommand: edsger sync-org-repos <teamId>
|
|
846
867
|
// ============================================================
|
|
847
868
|
program
|
|
@@ -1081,17 +1102,42 @@ program
|
|
|
1081
1102
|
}
|
|
1082
1103
|
});
|
|
1083
1104
|
// ============================================================
|
|
1105
|
+
// Subcommand: edsger features <productId>
|
|
1106
|
+
// ============================================================
|
|
1107
|
+
program
|
|
1108
|
+
.command('features <productId>')
|
|
1109
|
+
.description('Scan every repository linked to a product for the user-facing features it delivers and persist them via the product_features table. Manually defined features are enriched, never rewritten. Writes against the pending feature_scans row identified by --scan-id.')
|
|
1110
|
+
.requiredOption('--scan-id <id>', 'Pending feature_scans row id to drive (created by the desktop UI before invocation)')
|
|
1111
|
+
.option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
|
|
1112
|
+
.option('-v, --verbose', 'Verbose output')
|
|
1113
|
+
.action(async (productId, opts) => {
|
|
1114
|
+
try {
|
|
1115
|
+
await runFeatures(productId, {
|
|
1116
|
+
scanId: opts.scanId,
|
|
1117
|
+
guidance: opts.guidance,
|
|
1118
|
+
verbose: opts.verbose,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
exitWithError(error);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
// ============================================================
|
|
1084
1126
|
// Subcommand: edsger pr-resolve <productId>
|
|
1085
1127
|
// ============================================================
|
|
1086
1128
|
program
|
|
1087
|
-
.command('pr-resolve
|
|
1088
|
-
.description('AI-resolve change requests on a GitHub PR')
|
|
1129
|
+
.command('pr-resolve [productId]')
|
|
1130
|
+
.description('AI-resolve change requests on a GitHub PR for a product (or standalone repository)')
|
|
1089
1131
|
.requiredOption('--pr-url <url>', 'GitHub PR URL')
|
|
1090
1132
|
.option('--pr-id <id>', 'Pull request record ID in database')
|
|
1133
|
+
.option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
|
|
1091
1134
|
.option('--no-learn', 'Skip checklist learning after resolve')
|
|
1092
1135
|
.option('-v, --verbose', 'Verbose output')
|
|
1093
1136
|
.action(async (productId, opts) => {
|
|
1094
1137
|
try {
|
|
1138
|
+
if (!productId && !opts.repoId) {
|
|
1139
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for pr-resolve');
|
|
1140
|
+
}
|
|
1095
1141
|
await runPRResolve(productId, opts);
|
|
1096
1142
|
}
|
|
1097
1143
|
catch (error) {
|
|
@@ -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
|
+
}
|