edsger 0.75.1 → 0.77.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 (52) hide show
  1. package/dist/commands/api-docs/index.d.ts +18 -0
  2. package/dist/commands/api-docs/index.js +41 -0
  3. package/dist/commands/find-architecture/index.d.ts +3 -1
  4. package/dist/commands/find-architecture/index.js +14 -5
  5. package/dist/commands/find-bugs/index.d.ts +3 -1
  6. package/dist/commands/find-bugs/index.js +14 -5
  7. package/dist/commands/find-smells/index.d.ts +3 -1
  8. package/dist/commands/find-smells/index.js +14 -5
  9. package/dist/commands/quality-benchmark/index.d.ts +5 -0
  10. package/dist/commands/quality-benchmark/index.js +28 -0
  11. package/dist/index.js +38 -6
  12. package/dist/phases/api-docs/index.d.ts +47 -0
  13. package/dist/phases/api-docs/index.js +254 -0
  14. package/dist/phases/api-docs/mcp-server.d.ts +25 -0
  15. package/dist/phases/api-docs/mcp-server.js +82 -0
  16. package/dist/phases/api-docs/prompts.d.ts +16 -0
  17. package/dist/phases/api-docs/prompts.js +65 -0
  18. package/dist/phases/api-docs/types.d.ts +22 -0
  19. package/dist/phases/api-docs/types.js +10 -0
  20. package/dist/phases/find-architecture/index.d.ts +4 -1
  21. package/dist/phases/find-architecture/index.js +46 -26
  22. package/dist/phases/find-architecture/prompts.d.ts +2 -1
  23. package/dist/phases/find-architecture/prompts.js +3 -2
  24. package/dist/phases/find-bugs/index.d.ts +4 -1
  25. package/dist/phases/find-bugs/index.js +32 -19
  26. package/dist/phases/find-shared/baseline.d.ts +45 -0
  27. package/dist/phases/find-shared/baseline.js +56 -0
  28. package/dist/phases/find-shared/custom-rules.d.ts +39 -0
  29. package/dist/phases/find-shared/custom-rules.js +75 -0
  30. package/dist/phases/find-shared/detect-context.d.ts +40 -0
  31. package/dist/phases/find-shared/detect-context.js +247 -0
  32. package/dist/phases/find-shared/mcp.d.ts +24 -3
  33. package/dist/phases/find-shared/mcp.js +41 -4
  34. package/dist/phases/find-shared/rule-config.d.ts +37 -0
  35. package/dist/phases/find-shared/rule-config.js +67 -0
  36. package/dist/phases/find-shared/rule-packs.d.ts +65 -0
  37. package/dist/phases/find-shared/rule-packs.js +124 -0
  38. package/dist/phases/find-shared/scoped-read.d.ts +12 -0
  39. package/dist/phases/find-shared/scoped-read.js +33 -0
  40. package/dist/phases/find-smells/index.d.ts +4 -1
  41. package/dist/phases/find-smells/index.js +43 -23
  42. package/dist/phases/find-smells/prompts.d.ts +2 -1
  43. package/dist/phases/find-smells/prompts.js +4 -3
  44. package/dist/phases/quality-benchmark/gate.d.ts +50 -0
  45. package/dist/phases/quality-benchmark/gate.js +91 -0
  46. package/dist/phases/quality-benchmark/index.js +15 -1
  47. package/dist/phases/quality-benchmark/parsers.d.ts +23 -0
  48. package/dist/phases/quality-benchmark/parsers.js +210 -0
  49. package/dist/phases/quality-benchmark/rubric.md +37 -0
  50. package/dist/phases/quality-benchmark/tool-catalog.js +58 -1
  51. package/dist/phases/quality-benchmark/types.d.ts +8 -1
  52. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ /**
2
+ * CLI command: edsger api-docs <productId> --run-id <id>
3
+ * edsger api-docs --repo-id <id> --run-id <id>
4
+ *
5
+ * Clones the repository, asks Claude to determine its API surface, and persists
6
+ * a generated API reference (Markdown + optional OpenAPI document) onto the
7
+ * pending api_doc_runs row. The desktop UI creates the pending row first then
8
+ * invokes the CLI with --run-id; the CLI flips status running →
9
+ * success/failed and writes the artifact via the api-docs MCP toolkit.
10
+ */
11
+ export interface ApiDocsCliOptions {
12
+ runId: string;
13
+ /** Repo-only mode: generate for a single repositories row, no product. */
14
+ repoId?: string;
15
+ guidance?: string;
16
+ verbose?: boolean;
17
+ }
18
+ export declare function runApiDocs(productId: string | undefined, options: ApiDocsCliOptions): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * CLI command: edsger api-docs <productId> --run-id <id>
3
+ * edsger api-docs --repo-id <id> --run-id <id>
4
+ *
5
+ * Clones the repository, asks Claude to determine its API surface, and persists
6
+ * a generated API reference (Markdown + optional OpenAPI document) onto the
7
+ * pending api_doc_runs row. The desktop UI creates the pending row first then
8
+ * invokes the CLI with --run-id; the CLI flips status running →
9
+ * success/failed and writes the artifact via the api-docs MCP toolkit.
10
+ */
11
+ import { runApiDocsPhase } from '../../phases/api-docs/index.js';
12
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
13
+ export async function runApiDocs(productId, options) {
14
+ const { runId, repoId, guidance, verbose } = options;
15
+ if (!productId && !repoId) {
16
+ throw new Error('Either a product ID or --repo-id is required for api-docs');
17
+ }
18
+ if (!runId) {
19
+ throw new Error('--run-id is required (the pending api_doc_runs row id)');
20
+ }
21
+ if (productId) {
22
+ logInfo(`Starting API docs generation for product ${productId}`);
23
+ }
24
+ else {
25
+ logInfo(`Starting API docs generation for repository ${repoId}`);
26
+ }
27
+ const result = await runApiDocsPhase({
28
+ productId,
29
+ repoId,
30
+ runId,
31
+ guidance,
32
+ verbose,
33
+ });
34
+ if (result.status === 'success') {
35
+ logSuccess(result.message);
36
+ }
37
+ else {
38
+ logError(result.message);
39
+ process.exit(1);
40
+ }
41
+ }
@@ -5,9 +5,11 @@
5
5
  * and files each new finding as an issue.
6
6
  */
7
7
  export interface FindArchitectureCliOptions {
8
+ /** Repo-only mode: scan a single `repositories` row with no product. */
9
+ repoId?: string;
8
10
  full?: boolean;
9
11
  branch?: string;
10
12
  maxFiles?: number;
11
13
  verbose?: boolean;
12
14
  }
13
- export declare function runFindArchitecture(productId: string, options: FindArchitectureCliOptions): Promise<void>;
15
+ export declare function runFindArchitecture(productId: string | undefined, options: FindArchitectureCliOptions): Promise<void>;
@@ -4,22 +4,31 @@
4
4
  * coupling, cycles, missing/duplicated abstractions, responsibility creep)
5
5
  * and files each new finding as an issue.
6
6
  */
7
- import { getGitHubConfigByProduct } from '../../api/github.js';
7
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
8
8
  import { scanForArchitecture } from '../../phases/find-architecture/index.js';
9
9
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
10
10
  export async function runFindArchitecture(productId, options) {
11
- const { full, branch, maxFiles, verbose } = options;
12
- logInfo(`Starting architecture scan for product ${productId}`);
13
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
11
+ const { repoId, full, branch, maxFiles, verbose } = options;
12
+ if (!productId && !repoId) {
13
+ throw new Error('Provide a productId or --repo-id for find-architecture');
14
+ }
15
+ const scopeLabel = productId
16
+ ? `product ${productId}`
17
+ : `repository ${repoId}`;
18
+ logInfo(`Starting architecture scan for ${scopeLabel}`);
19
+ const githubConfig = productId
20
+ ? await getGitHubConfigByProduct(productId, verbose)
21
+ : await getGitHubConfigByRepository(repoId, verbose);
14
22
  if (!githubConfig.configured ||
15
23
  !githubConfig.token ||
16
24
  !githubConfig.owner ||
17
25
  !githubConfig.repo) {
18
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
26
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
19
27
  process.exit(1);
20
28
  }
21
29
  const result = await scanForArchitecture({
22
30
  productId,
31
+ repoId,
23
32
  githubToken: githubConfig.token,
24
33
  owner: githubConfig.owner,
25
34
  repo: githubConfig.repo,
@@ -3,9 +3,11 @@
3
3
  * Audits the product's repository for bugs and files each new finding as an issue.
4
4
  */
5
5
  export interface FindBugsCliOptions {
6
+ /** Repo-only mode: scan a single `repositories` row with no product. */
7
+ repoId?: string;
6
8
  full?: boolean;
7
9
  branch?: string;
8
10
  maxFiles?: number;
9
11
  verbose?: boolean;
10
12
  }
11
- export declare function runFindBugs(productId: string, options: FindBugsCliOptions): Promise<void>;
13
+ export declare function runFindBugs(productId: string | undefined, options: FindBugsCliOptions): Promise<void>;
@@ -2,22 +2,31 @@
2
2
  * CLI command: edsger find-bugs <productId>
3
3
  * Audits the product's repository for bugs and files each new finding as an issue.
4
4
  */
5
- import { getGitHubConfigByProduct } from '../../api/github.js';
5
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
6
6
  import { scanForBugs } from '../../phases/find-bugs/index.js';
7
7
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
8
8
  export async function runFindBugs(productId, options) {
9
- const { full, branch, maxFiles, verbose } = options;
10
- logInfo(`Starting bug scan for product ${productId}`);
11
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
9
+ const { repoId, full, branch, maxFiles, verbose } = options;
10
+ if (!productId && !repoId) {
11
+ throw new Error('Provide a productId or --repo-id for find-bugs');
12
+ }
13
+ const scopeLabel = productId
14
+ ? `product ${productId}`
15
+ : `repository ${repoId}`;
16
+ logInfo(`Starting bug scan for ${scopeLabel}`);
17
+ const githubConfig = productId
18
+ ? await getGitHubConfigByProduct(productId, verbose)
19
+ : await getGitHubConfigByRepository(repoId, verbose);
12
20
  if (!githubConfig.configured ||
13
21
  !githubConfig.token ||
14
22
  !githubConfig.owner ||
15
23
  !githubConfig.repo) {
16
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
24
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
17
25
  process.exit(1);
18
26
  }
19
27
  const result = await scanForBugs({
20
28
  productId,
29
+ repoId,
21
30
  githubToken: githubConfig.token,
22
31
  owner: githubConfig.owner,
23
32
  repo: githubConfig.repo,
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { type SmellCategory } from '../../phases/find-smells/types.js';
7
7
  export interface FindSmellsCliOptions {
8
+ /** Repo-only mode: scan a single `repositories` row with no product. */
9
+ repoId?: string;
8
10
  full?: boolean;
9
11
  branch?: string;
10
12
  maxFiles?: number;
@@ -18,4 +20,4 @@ export interface FindSmellsCliOptions {
18
20
  * matches user intuition for `--categories=`.
19
21
  */
20
22
  export declare function parseCategoriesOption(raw: string): SmellCategory[] | undefined;
21
- export declare function runFindSmells(productId: string, options: FindSmellsCliOptions): Promise<void>;
23
+ export declare function runFindSmells(productId: string | undefined, options: FindSmellsCliOptions): Promise<void>;
@@ -3,7 +3,7 @@
3
3
  * Audits the product's repository for code smells (refactor candidates, perf
4
4
  * cliffs, dead code, etc.) and files each new finding as an issue.
5
5
  */
6
- import { getGitHubConfigByProduct } from '../../api/github.js';
6
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
7
7
  import { scanForSmells } from '../../phases/find-smells/index.js';
8
8
  import { isSmellCategory, SMELL_CATEGORIES, } from '../../phases/find-smells/types.js';
9
9
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
@@ -31,18 +31,27 @@ export function parseCategoriesOption(raw) {
31
31
  return Array.from(new Set(tokens));
32
32
  }
33
33
  export async function runFindSmells(productId, options) {
34
- const { full, branch, maxFiles, categories, verbose } = options;
35
- logInfo(`Starting smell scan for product ${productId}`);
36
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
34
+ const { repoId, full, branch, maxFiles, categories, verbose } = options;
35
+ if (!productId && !repoId) {
36
+ throw new Error('Provide a productId or --repo-id for find-smells');
37
+ }
38
+ const scopeLabel = productId
39
+ ? `product ${productId}`
40
+ : `repository ${repoId}`;
41
+ logInfo(`Starting smell scan for ${scopeLabel}`);
42
+ const githubConfig = productId
43
+ ? await getGitHubConfigByProduct(productId, verbose)
44
+ : await getGitHubConfigByRepository(repoId, verbose);
37
45
  if (!githubConfig.configured ||
38
46
  !githubConfig.token ||
39
47
  !githubConfig.owner ||
40
48
  !githubConfig.repo) {
41
- logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
49
+ logError(`GitHub not configured for ${scopeLabel}: ${githubConfig.message || 'No installation found'}`);
42
50
  process.exit(1);
43
51
  }
44
52
  const result = await scanForSmells({
45
53
  productId,
54
+ repoId,
46
55
  githubToken: githubConfig.token,
47
56
  owner: githubConfig.owner,
48
57
  repo: githubConfig.repo,
@@ -36,5 +36,10 @@ export interface QualityBenchmarkCliOptions {
36
36
  save?: boolean;
37
37
  /** Overwrite any existing report row for this (product, commit, rubric). */
38
38
  force?: boolean;
39
+ /**
40
+ * Evaluate the scope's quality gate against the report and exit non-zero if
41
+ * it fails — for use in CI ("fail the build"). No-op when no gate is set.
42
+ */
43
+ gate?: boolean;
39
44
  }
40
45
  export declare function runQualityBenchmarkCli(productId: string, options: QualityBenchmarkCliOptions): Promise<void>;
@@ -26,6 +26,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
26
26
  import { dirname, resolve } from 'node:path';
27
27
  import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
28
28
  import { fetchProductBasics } from '../../phases/find-shared/mcp.js';
29
+ import { evaluateGate, getQualityGate, } from '../../phases/quality-benchmark/gate.js';
29
30
  import { runQualityBenchmark, } from '../../phases/quality-benchmark/index.js';
30
31
  import { prepareQualityWorkspace } from '../../phases/quality-benchmark/workspace.js';
31
32
  import { saveQualityReport } from '../../services/quality-reports.js';
@@ -207,6 +208,33 @@ export async function runQualityBenchmarkCli(productId, options) {
207
208
  if (outcome.report.executive_summary) {
208
209
  logInfo(outcome.report.executive_summary);
209
210
  }
211
+ // Quality gate (CI enforcement). Evaluated against the report we just
212
+ // produced; a failing gate exits non-zero so a pipeline step can block.
213
+ if (options.gate) {
214
+ const scope = repoOnly
215
+ ? { repoId: options.repoId }
216
+ : { productId };
217
+ const gate = await getQualityGate(scope);
218
+ if (!gate) {
219
+ logInfo('Quality gate: none configured for this scope — skipping.');
220
+ }
221
+ else if (!gate.enabled) {
222
+ logInfo('Quality gate: disabled for this scope — skipping.');
223
+ }
224
+ else {
225
+ const result = evaluateGate(outcome.report, gate);
226
+ if (result.passed) {
227
+ logSuccess('Quality gate: PASS');
228
+ }
229
+ else {
230
+ logError('Quality gate: FAIL');
231
+ for (const v of result.violations) {
232
+ logError(` • ${v.label}: required ${v.required}, got ${v.actual}`);
233
+ }
234
+ process.exit(1);
235
+ }
236
+ }
237
+ }
210
238
  }
211
239
  /** Resolve a repository id to its "owner/repo" full name (RLS-scoped). */
212
240
  async function resolveRepositoryFullName(repositoryId) {
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { runLogin, runLogout, runStatus } from './auth/login.js';
9
9
  import { runAdr } from './commands/adr/index.js';
10
10
  import { runAgentWorkflow } from './commands/agent-workflow/index.js';
11
11
  import { runAnalyzeLogs } from './commands/analyze-logs/index.js';
12
+ import { runApiDocs } from './commands/api-docs/index.js';
12
13
  import { runAppStoreGeneration } from './commands/app-store/index.js';
13
14
  import { runArchitectureDiagram } from './commands/architecture-diagram/index.js';
14
15
  import { runBuild } from './commands/build/index.js';
@@ -802,8 +803,9 @@ program
802
803
  // Subcommand: edsger find-bugs <productId>
803
804
  // ============================================================
804
805
  program
805
- .command('find-bugs <productId>')
806
- .description("AI-audit a product's repository for bugs and file each new finding as an issue")
806
+ .command('find-bugs [productId]')
807
+ .description("AI-audit a product's (or a standalone repository's) repo for bugs and file each new finding as an issue")
808
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
807
809
  .option('--full', 'Force a full scan even if previous-scan state exists')
808
810
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
809
811
  .option('--max-files <n>', 'Upper bound on files the auditor may Read (default 200)', (value) => {
@@ -836,6 +838,7 @@ program
836
838
  .option('--output <path>', 'Where to write the JSON report (default: ./quality-report-<commit>.json)')
837
839
  .option('--no-save', 'Skip the quality_reports/save MCP call (local-only run)')
838
840
  .option('--force', 'Overwrite any existing report row for this (product, commit, rubric)')
841
+ .option('--gate', "Evaluate the scope's quality gate against the report and exit non-zero if it fails (for CI). No-op when no gate is configured")
839
842
  .option('-v, --verbose', 'Print every progress event')
840
843
  .action(async (productId, opts) => {
841
844
  try {
@@ -1030,8 +1033,9 @@ program
1030
1033
  // Subcommand: edsger find-smells <productId>
1031
1034
  // ============================================================
1032
1035
  program
1033
- .command('find-smells <productId>')
1034
- .description("AI-audit a product's repository for code smells (refactor candidates, perf cliffs, dead code, type-safety gaps) and file each new finding as an issue")
1036
+ .command('find-smells [productId]')
1037
+ .description("AI-audit a product's (or a standalone repository's) repo for code smells (refactor candidates, perf cliffs, dead code, type-safety gaps) and file each new finding as an issue")
1038
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1035
1039
  .option('--full', 'Force a full scan even if previous-scan state exists')
1036
1040
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
1037
1041
  .option('--max-files <n>', `Upper bound on files the auditor may Read (default ${FIND_SMELLS_DEFAULT_MAX_FILES})`, (value) => {
@@ -1055,8 +1059,9 @@ program
1055
1059
  // Subcommand: edsger find-architecture <productId>
1056
1060
  // ============================================================
1057
1061
  program
1058
- .command('find-architecture <productId>')
1059
- .description("AI-audit a product's repository for architectural concerns (layering, coupling, cycles, missing/duplicated abstractions, responsibility creep) and file each new finding as an issue")
1062
+ .command('find-architecture [productId]')
1063
+ .description("AI-audit a product's (or a standalone repository's) repo for architectural concerns (layering, coupling, cycles, missing/duplicated abstractions, responsibility creep) and file each new finding as an issue")
1064
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1060
1065
  .option('--full', 'Force a full scan even if previous-scan state exists')
1061
1066
  .option('--branch <name>', 'Branch to scan (defaults to repo default branch)')
1062
1067
  .option('--max-files <n>', `Upper bound on files the auditor may Read (default ${FIND_ARCHITECTURE_DEFAULT_MAX_FILES})`, (value) => {
@@ -1139,6 +1144,33 @@ program
1139
1144
  }
1140
1145
  });
1141
1146
  // ============================================================
1147
+ // Subcommand: edsger api-docs <productId>
1148
+ // ============================================================
1149
+ program
1150
+ .command('api-docs [productId]')
1151
+ .description("Generate an API reference (Markdown + an optional OpenAPI 3.x document) for a product's (or standalone repository's) codebase by determining the API surface it exposes from its source. Writes against the pending api_doc_runs row identified by --run-id.")
1152
+ .requiredOption('--run-id <id>', 'Pending api_doc_runs row id to drive (created by the desktop UI before invocation)')
1153
+ .option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
1154
+ .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
1155
+ .option('-v, --verbose', 'Verbose output')
1156
+ .action(async (productId, opts) => {
1157
+ try {
1158
+ if (!productId && !opts.repoId) {
1159
+ throw new Error('Provide a productId or --repo-id (repo-only mode) for api-docs');
1160
+ }
1161
+ await runApiDocs(productId, {
1162
+ runId: opts.runId,
1163
+ repoId: opts.repoId,
1164
+ guidance: opts.guidance,
1165
+ verbose: opts.verbose,
1166
+ });
1167
+ }
1168
+ catch (error) {
1169
+ logError(error instanceof Error ? error.message : String(error));
1170
+ process.exit(1);
1171
+ }
1172
+ });
1173
+ // ============================================================
1142
1174
  // Subcommand: edsger pr-resolve <productId>
1143
1175
  // ============================================================
1144
1176
  program
@@ -0,0 +1,47 @@
1
+ /**
2
+ * API docs phase: clone the repository, ask Claude to work out the API surface
3
+ * it exposes, and persist a generated API reference (Markdown + an optional
4
+ * OpenAPI document) onto the pending api_doc_runs row.
5
+ *
6
+ * Production-grade behaviours (mirrors the recipes phase):
7
+ *
8
+ * - Heartbeat: `last_heartbeat_at` on the api_doc_runs row is refreshed on
9
+ * every assistant message so the reader can detect stalled / crashed runs
10
+ * (see desktop-app/.../services/db/api-doc-runs.ts for the lazy reaper).
11
+ * - Cancellation-safe writes: markRunning / persistAndSucceed / markFailed
12
+ * only touch rows whose status is in {pending, running}. If the user
13
+ * clicked Stop and the row is now 'cancelled', the final write no-ops.
14
+ * - Capture-then-persist: the agent submits the reference via a single
15
+ * `submit_api_docs` MCP tool; the artifact is written in one atomic update
16
+ * alongside the success flip, so a run never leaves a half-written doc.
17
+ */
18
+ import type { SupabaseClient } from '@supabase/supabase-js';
19
+ import type { ApiDocsArtifact } from './types.js';
20
+ export interface ApiDocsPhaseOptions {
21
+ /** Product-scoped run. Mutually exclusive with `repoId`. */
22
+ productId?: string;
23
+ /** Repo-only run: a single repositories row, no product context. */
24
+ repoId?: string;
25
+ runId: string;
26
+ guidance?: string;
27
+ verbose?: boolean;
28
+ }
29
+ export interface ApiDocsPhaseResult {
30
+ status: 'success' | 'error' | 'cancelled';
31
+ message: string;
32
+ }
33
+ export declare function runApiDocsPhase(options: ApiDocsPhaseOptions): Promise<ApiDocsPhaseResult>;
34
+ /**
35
+ * Claim the row by flipping `pending` → `running`. Returns true on success and
36
+ * false when the row has already moved on (e.g. user cancelled before the CLI
37
+ * started). Bounded by the status filter so we can't resurrect a 'cancelled'
38
+ * row.
39
+ */
40
+ export declare function markRunning(supabase: SupabaseClient, runId: string): Promise<boolean>;
41
+ export declare function heartbeat(supabase: SupabaseClient, runId: string): Promise<void>;
42
+ export declare function markFailed(supabase: SupabaseClient, runId: string, errorMessage: string): Promise<boolean>;
43
+ /**
44
+ * Write the captured artifact AND flip the row to success in one update,
45
+ * bounded by the {pending, running} status filter so a cancelled run no-ops.
46
+ */
47
+ export declare function persistAndSucceed(supabase: SupabaseClient, runId: string, artifact: ApiDocsArtifact): Promise<boolean>;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * API docs phase: clone the repository, ask Claude to work out the API surface
3
+ * it exposes, and persist a generated API reference (Markdown + an optional
4
+ * OpenAPI document) onto the pending api_doc_runs row.
5
+ *
6
+ * Production-grade behaviours (mirrors the recipes phase):
7
+ *
8
+ * - Heartbeat: `last_heartbeat_at` on the api_doc_runs row is refreshed on
9
+ * every assistant message so the reader can detect stalled / crashed runs
10
+ * (see desktop-app/.../services/db/api-doc-runs.ts for the lazy reaper).
11
+ * - Cancellation-safe writes: markRunning / persistAndSucceed / markFailed
12
+ * only touch rows whose status is in {pending, running}. If the user
13
+ * clicked Stop and the row is now 'cancelled', the final write no-ops.
14
+ * - Capture-then-persist: the agent submits the reference via a single
15
+ * `submit_api_docs` MCP tool; the artifact is written in one atomic update
16
+ * alongside the success flip, so a run never leaves a half-written doc.
17
+ */
18
+ import { query } from '@anthropic-ai/claude-agent-sdk';
19
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
20
+ import { DEFAULT_MODEL } from '../../constants.js';
21
+ import { getSupabase } from '../../supabase/client.js';
22
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
23
+ import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
24
+ import { fetchProductBasics } from '../find-shared/mcp.js';
25
+ import { createPromptGenerator, extractTextFromContent, } from '../pr-shared/agent-utils.js';
26
+ import { createApiDocsCaptureState, createApiDocsMcpServer, } from './mcp-server.js';
27
+ import { createApiDocsSystemPrompt, createApiDocsUserPrompt, } from './prompts.js';
28
+ const WORKSPACE_KEY = 'api-docs';
29
+ const MAX_TURNS = 200;
30
+ // Heartbeat cadence: at most one DB write per HEARTBEAT_MIN_INTERVAL_MS.
31
+ const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
32
+ export async function runApiDocsPhase(options) {
33
+ const { productId, repoId, runId, guidance, verbose } = options;
34
+ const repoOnly = !productId && Boolean(repoId);
35
+ if (productId) {
36
+ logInfo(`Starting API docs generation for product ${productId}`);
37
+ }
38
+ else {
39
+ logInfo(`Starting API docs generation for repository ${repoId}`);
40
+ }
41
+ const supabase = getSupabase();
42
+ const claimed = await markRunning(supabase, runId);
43
+ if (!claimed) {
44
+ return {
45
+ status: 'cancelled',
46
+ message: 'API docs run row is no longer in a runnable state (likely cancelled before the CLI started)',
47
+ };
48
+ }
49
+ // Resolve repo basics (name/description) for the prompt.
50
+ let repoBasics = {
51
+ fullName: null,
52
+ description: null,
53
+ };
54
+ if (repoOnly) {
55
+ const basics = await getRepositoryBasics(repoId);
56
+ repoBasics = { fullName: basics.fullName, description: basics.description };
57
+ }
58
+ const githubConfig = repoOnly
59
+ ? await getGitHubConfigByRepository(repoId, verbose)
60
+ : await getGitHubConfigByProduct(productId, verbose);
61
+ if (!githubConfig.configured ||
62
+ !githubConfig.token ||
63
+ !githubConfig.owner ||
64
+ !githubConfig.repo) {
65
+ const msg = githubConfig.message ||
66
+ (repoOnly
67
+ ? 'GitHub repository not configured. Connect the repo first.'
68
+ : 'GitHub repository not configured for this product. Connect a repo first.');
69
+ await markFailed(supabase, runId, msg);
70
+ return { status: 'error', message: msg };
71
+ }
72
+ let repoPath;
73
+ let succeeded = false;
74
+ try {
75
+ const workspaceRoot = ensureWorkspaceDir();
76
+ const repoKey = repoOnly
77
+ ? `${WORKSPACE_KEY}-repo-${repoId}`
78
+ : `${WORKSPACE_KEY}-${productId}`;
79
+ ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
80
+ const basics = repoOnly
81
+ ? {
82
+ name: repoBasics.fullName ?? `${githubConfig.owner}/${githubConfig.repo}`,
83
+ description: repoBasics.description ?? undefined,
84
+ }
85
+ : await fetchProductBasics(productId);
86
+ const systemPrompt = createApiDocsSystemPrompt();
87
+ const userPrompt = createApiDocsUserPrompt({
88
+ repoName: basics.name,
89
+ repoDescription: basics.description,
90
+ guidance,
91
+ });
92
+ const capture = createApiDocsCaptureState();
93
+ const mcpServer = createApiDocsMcpServer(capture);
94
+ logInfo('Running Claude agent to generate API docs...');
95
+ let lastHeartbeatAt = 0;
96
+ for await (const message of query({
97
+ prompt: createPromptGenerator(userPrompt),
98
+ options: {
99
+ systemPrompt: {
100
+ type: 'preset',
101
+ preset: 'claude_code',
102
+ append: systemPrompt,
103
+ },
104
+ model: DEFAULT_MODEL,
105
+ maxTurns: MAX_TURNS,
106
+ permissionMode: 'bypassPermissions',
107
+ cwd: repoPath,
108
+ mcpServers: {
109
+ 'api-docs': mcpServer,
110
+ },
111
+ },
112
+ })) {
113
+ if (message.type === 'assistant') {
114
+ extractTextFromContent(message.message?.content ?? [], verbose);
115
+ const now = Date.now();
116
+ if (now - lastHeartbeatAt >= HEARTBEAT_MIN_INTERVAL_MS) {
117
+ lastHeartbeatAt = now;
118
+ await heartbeat(supabase, runId);
119
+ }
120
+ continue;
121
+ }
122
+ if (message.type !== 'result') {
123
+ continue;
124
+ }
125
+ if (message.subtype !== 'success') {
126
+ const msg = `API docs generation failed: agent ${message.subtype}`;
127
+ const written = await markFailed(supabase, runId, msg);
128
+ return {
129
+ status: written ? 'error' : 'cancelled',
130
+ message: written
131
+ ? msg
132
+ : 'Run was cancelled while the agent was running',
133
+ };
134
+ }
135
+ if (!capture.captured) {
136
+ const msg = 'Agent finished without calling submit_api_docs — no reference was produced.';
137
+ const written = await markFailed(supabase, runId, msg);
138
+ return {
139
+ status: written ? 'error' : 'cancelled',
140
+ message: written ? msg : 'Run was cancelled before completion',
141
+ };
142
+ }
143
+ const written = await persistAndSucceed(supabase, runId, capture.captured);
144
+ if (!written) {
145
+ return {
146
+ status: 'cancelled',
147
+ message: 'Run was cancelled before the result could be written',
148
+ };
149
+ }
150
+ succeeded = true;
151
+ const ep = capture.captured.endpointCount;
152
+ logSuccess(`API docs generated (${ep} endpoint${ep === 1 ? '' : 's'}${capture.captured.openapi ? ', with OpenAPI spec' : ''}).`);
153
+ return {
154
+ status: 'success',
155
+ message: `API docs generated (${ep} endpoint${ep === 1 ? '' : 's'}).`,
156
+ };
157
+ }
158
+ const msg = 'API docs generation ended without a result message';
159
+ await markFailed(supabase, runId, msg);
160
+ return { status: 'error', message: msg };
161
+ }
162
+ catch (error) {
163
+ const errorMessage = error instanceof Error ? error.message : String(error);
164
+ logError(`API docs generation failed: ${errorMessage}`);
165
+ await markFailed(supabase, runId, errorMessage);
166
+ return { status: 'error', message: errorMessage };
167
+ }
168
+ finally {
169
+ if (succeeded) {
170
+ cleanupIssueRepo(repoPath);
171
+ }
172
+ }
173
+ }
174
+ // ============================================================================
175
+ // DB helpers — exported for unit tests
176
+ // ============================================================================
177
+ /**
178
+ * Claim the row by flipping `pending` → `running`. Returns true on success and
179
+ * false when the row has already moved on (e.g. user cancelled before the CLI
180
+ * started). Bounded by the status filter so we can't resurrect a 'cancelled'
181
+ * row.
182
+ */
183
+ export async function markRunning(supabase, runId) {
184
+ const { data, error } = await supabase
185
+ .from('api_doc_runs')
186
+ .update({
187
+ status: 'running',
188
+ error: null,
189
+ last_heartbeat_at: new Date().toISOString(),
190
+ })
191
+ .eq('id', runId)
192
+ .in('status', ['pending', 'running'])
193
+ .select('id')
194
+ .maybeSingle();
195
+ if (error) {
196
+ logWarning(`Could not mark run as running: ${error.message}`);
197
+ return false;
198
+ }
199
+ return data !== null;
200
+ }
201
+ export async function heartbeat(supabase, runId) {
202
+ const { error } = await supabase
203
+ .from('api_doc_runs')
204
+ .update({ last_heartbeat_at: new Date().toISOString() })
205
+ .eq('id', runId)
206
+ .eq('status', 'running');
207
+ if (error) {
208
+ logWarning(`Heartbeat failed: ${error.message}`);
209
+ }
210
+ }
211
+ export async function markFailed(supabase, runId, errorMessage) {
212
+ const { data, error } = await supabase
213
+ .from('api_doc_runs')
214
+ .update({
215
+ status: 'failed',
216
+ error: errorMessage,
217
+ completed_at: new Date().toISOString(),
218
+ })
219
+ .eq('id', runId)
220
+ .in('status', ['pending', 'running'])
221
+ .select('id')
222
+ .maybeSingle();
223
+ if (error) {
224
+ logWarning(`Could not mark run as failed: ${error.message}`);
225
+ return false;
226
+ }
227
+ return data !== null;
228
+ }
229
+ /**
230
+ * Write the captured artifact AND flip the row to success in one update,
231
+ * bounded by the {pending, running} status filter so a cancelled run no-ops.
232
+ */
233
+ export async function persistAndSucceed(supabase, runId, artifact) {
234
+ const { data, error } = await supabase
235
+ .from('api_doc_runs')
236
+ .update({
237
+ status: 'success',
238
+ error: null,
239
+ openapi: artifact.openapi,
240
+ markdown: artifact.markdown,
241
+ summary: artifact.summary,
242
+ endpoint_count: artifact.endpointCount,
243
+ completed_at: new Date().toISOString(),
244
+ })
245
+ .eq('id', runId)
246
+ .in('status', ['pending', 'running'])
247
+ .select('id')
248
+ .maybeSingle();
249
+ if (error) {
250
+ logWarning(`Could not persist API docs: ${error.message}`);
251
+ return false;
252
+ }
253
+ return data !== null;
254
+ }