edsger 0.68.0 → 0.70.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 (83) hide show
  1. package/dist/api/github.d.ts +23 -0
  2. package/dist/api/github.js +61 -0
  3. package/dist/commands/architecture-diagram/index.d.ts +8 -0
  4. package/dist/commands/architecture-diagram/index.js +10 -0
  5. package/dist/commands/class-diagram/index.d.ts +7 -0
  6. package/dist/commands/class-diagram/index.js +9 -0
  7. package/dist/commands/data-flow/index.d.ts +8 -6
  8. package/dist/commands/data-flow/index.js +21 -12
  9. package/dist/commands/diagram-shared/index.d.ts +21 -0
  10. package/dist/commands/diagram-shared/index.js +37 -0
  11. package/dist/commands/er-diagram/index.d.ts +19 -0
  12. package/dist/commands/er-diagram/index.js +55 -0
  13. package/dist/commands/flowchart/index.d.ts +8 -0
  14. package/dist/commands/flowchart/index.js +10 -0
  15. package/dist/commands/quality-benchmark/index.js +43 -9
  16. package/dist/commands/recipes/index.d.ts +3 -1
  17. package/dist/commands/recipes/index.js +10 -4
  18. package/dist/commands/screen-flow/index.d.ts +8 -6
  19. package/dist/commands/screen-flow/index.js +21 -12
  20. package/dist/commands/sequence-diagram/index.d.ts +19 -0
  21. package/dist/commands/sequence-diagram/index.js +55 -0
  22. package/dist/commands/state-diagram/index.d.ts +7 -0
  23. package/dist/commands/state-diagram/index.js +9 -0
  24. package/dist/index.js +144 -14
  25. package/dist/phases/architecture-diagram/index.d.ts +15 -0
  26. package/dist/phases/architecture-diagram/index.js +51 -0
  27. package/dist/phases/class-diagram/index.d.ts +14 -0
  28. package/dist/phases/class-diagram/index.js +76 -0
  29. package/dist/phases/data-flow/index.d.ts +6 -3
  30. package/dist/phases/data-flow/index.js +59 -38
  31. package/dist/phases/data-flow/mcp-server.d.ts +1 -1
  32. package/dist/phases/data-flow/mcp-server.js +2 -2
  33. package/dist/phases/data-flow/types.d.ts +1 -1
  34. package/dist/phases/data-flow/types.js +1 -1
  35. package/dist/phases/diagram-shared/clone-repos.d.ts +63 -0
  36. package/dist/phases/diagram-shared/clone-repos.js +153 -0
  37. package/dist/phases/diagram-shared/generate.d.ts +42 -0
  38. package/dist/phases/diagram-shared/generate.js +162 -0
  39. package/dist/phases/diagram-shared/graph.d.ts +62 -0
  40. package/dist/phases/diagram-shared/graph.js +169 -0
  41. package/dist/phases/diagram-shared/mcp.d.ts +35 -0
  42. package/dist/phases/diagram-shared/mcp.js +68 -0
  43. package/dist/phases/diagram-shared/prompts.d.ts +23 -0
  44. package/dist/phases/diagram-shared/prompts.js +35 -0
  45. package/dist/phases/er-diagram/index.d.ts +28 -0
  46. package/dist/phases/er-diagram/index.js +290 -0
  47. package/dist/phases/er-diagram/mcp-server.d.ts +77 -0
  48. package/dist/phases/er-diagram/mcp-server.js +144 -0
  49. package/dist/phases/er-diagram/prompts.d.ts +14 -0
  50. package/dist/phases/er-diagram/prompts.js +36 -0
  51. package/dist/phases/er-diagram/types.d.ts +76 -0
  52. package/dist/phases/er-diagram/types.js +84 -0
  53. package/dist/phases/flow-shared/clone-repos.d.ts +8 -2
  54. package/dist/phases/flow-shared/clone-repos.js +36 -18
  55. package/dist/phases/flowchart/index.d.ts +15 -0
  56. package/dist/phases/flowchart/index.js +50 -0
  57. package/dist/phases/output-contracts.js +178 -2
  58. package/dist/phases/recipes/index.d.ts +8 -1
  59. package/dist/phases/recipes/index.js +74 -17
  60. package/dist/phases/recipes/mcp-server.d.ts +4 -1
  61. package/dist/phases/recipes/mcp-server.js +43 -18
  62. package/dist/phases/screen-flow/index.d.ts +7 -4
  63. package/dist/phases/screen-flow/index.js +66 -45
  64. package/dist/phases/screen-flow/mcp-server.js +2 -2
  65. package/dist/phases/sequence-diagram/index.d.ts +30 -0
  66. package/dist/phases/sequence-diagram/index.js +290 -0
  67. package/dist/phases/sequence-diagram/mcp-server.d.ts +64 -0
  68. package/dist/phases/sequence-diagram/mcp-server.js +134 -0
  69. package/dist/phases/sequence-diagram/prompts.d.ts +14 -0
  70. package/dist/phases/sequence-diagram/prompts.js +36 -0
  71. package/dist/phases/sequence-diagram/types.d.ts +52 -0
  72. package/dist/phases/sequence-diagram/types.js +93 -0
  73. package/dist/phases/state-diagram/index.d.ts +15 -0
  74. package/dist/phases/state-diagram/index.js +53 -0
  75. package/dist/skills/phase/architecture-diagram/SKILL.md +41 -0
  76. package/dist/skills/phase/class-diagram/SKILL.md +44 -0
  77. package/dist/skills/phase/er-diagram/SKILL.md +71 -0
  78. package/dist/skills/phase/flowchart/SKILL.md +38 -0
  79. package/dist/skills/phase/sequence-diagram/SKILL.md +67 -0
  80. package/dist/skills/phase/state-diagram/SKILL.md +38 -0
  81. package/dist/workspace/session-workspace.d.ts +2 -2
  82. package/dist/workspace/session-workspace.js +2 -2
  83. package/package.json +1 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ER Diagram domain types.
3
+ *
4
+ * An ErEntity is a structured description of one persistence entity in a
5
+ * product — a table, a view, or an enum. The CLI extracts these from schema
6
+ * files (migrations, ORM models, type definitions) and the desktop renders
7
+ * them as an entity-relationship diagram.
8
+ *
9
+ * Companion to DataNodeSchema / ScreenSchema: same flow-graph shape (nodes +
10
+ * edges sharing the `diagrams` table storing the JSONB schema), different domain.
11
+ * ER edges describe foreign-key / inheritance relationships between entities,
12
+ * with a cardinality, not data movement or user navigation.
13
+ */
14
+ // ============================================================================
15
+ // Runtime validation for AI-produced extraction
16
+ // ============================================================================
17
+ const ENTITY_KINDS = new Set([
18
+ 'entity',
19
+ 'view',
20
+ 'enum',
21
+ 'junction',
22
+ ]);
23
+ const RELATION_KINDS = new Set([
24
+ 'one-to-one',
25
+ 'one-to-many',
26
+ 'many-to-many',
27
+ 'inherits',
28
+ ]);
29
+ function isRecord(value) {
30
+ return typeof value === 'object' && value !== null;
31
+ }
32
+ function isErEntity(value) {
33
+ if (!isRecord(value)) {
34
+ return false;
35
+ }
36
+ if (typeof value.slug !== 'string' || value.slug.length === 0) {
37
+ return false;
38
+ }
39
+ if (typeof value.name !== 'string' || value.name.length === 0) {
40
+ return false;
41
+ }
42
+ if (typeof value.kind !== 'string' ||
43
+ !ENTITY_KINDS.has(value.kind)) {
44
+ return false;
45
+ }
46
+ return true;
47
+ }
48
+ function isErRelation(value) {
49
+ if (!isRecord(value)) {
50
+ return false;
51
+ }
52
+ if (typeof value.fromSlug !== 'string') {
53
+ return false;
54
+ }
55
+ if (typeof value.toSlug !== 'string') {
56
+ return false;
57
+ }
58
+ if (typeof value.kind !== 'string' ||
59
+ !RELATION_KINDS.has(value.kind)) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+ export function isErDiagramExtraction(value) {
65
+ if (!isRecord(value)) {
66
+ return false;
67
+ }
68
+ if (typeof value.summary !== 'string') {
69
+ return false;
70
+ }
71
+ if (!Array.isArray(value.entities)) {
72
+ return false;
73
+ }
74
+ if (!Array.isArray(value.relations)) {
75
+ return false;
76
+ }
77
+ if (!value.entities.every(isErEntity)) {
78
+ return false;
79
+ }
80
+ if (!value.relations.every(isErRelation)) {
81
+ return false;
82
+ }
83
+ return true;
84
+ }
@@ -35,8 +35,11 @@ export declare function safeDirName(fullName: string): string;
35
35
  /**
36
36
  * Resolve the repositories a flow targets (by id, preserving the stored
37
37
  * order), falling back to the product's primary repo.
38
+ *
39
+ * In repo-only mode there is no product, so no `fallback` is provided: the
40
+ * set is resolved purely from `repositoryIds`.
38
41
  */
39
- export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback: {
42
+ export declare function resolveTargetRepos(productId: string | undefined, repositoryIds: string[], fallback?: {
40
43
  owner: string;
41
44
  repo: string;
42
45
  }): Promise<{
@@ -45,7 +48,10 @@ export declare function resolveTargetRepos(productId: string, repositoryIds: str
45
48
  repo: string;
46
49
  }[]>;
47
50
  export declare function cloneFlowRepos(opts: {
48
- productId: string;
51
+ /** Product-scoped flow. Mutually exclusive with `repoId`. */
52
+ productId?: string;
53
+ /** Repo-only flow: a single repositories row, no product context. */
54
+ repoId?: string;
49
55
  repositoryIds: string[];
50
56
  workspaceKey: string;
51
57
  verbose?: boolean;
@@ -13,7 +13,7 @@
13
13
  * Falls back to the product's primary repo when `repository_ids` is empty
14
14
  * (older flows, or single-repo products).
15
15
  */
16
- import { getGitHubConfigByProduct } from '../../api/github.js';
16
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
17
17
  import { getSupabase } from '../../supabase/client.js';
18
18
  import { logInfo, logWarning } from '../../utils/logger.js';
19
19
  import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
@@ -23,16 +23,22 @@ export function safeDirName(fullName) {
23
23
  /**
24
24
  * Resolve the repositories a flow targets (by id, preserving the stored
25
25
  * order), falling back to the product's primary repo.
26
+ *
27
+ * In repo-only mode there is no product, so no `fallback` is provided: the
28
+ * set is resolved purely from `repositoryIds`.
26
29
  */
27
30
  export async function resolveTargetRepos(productId, repositoryIds, fallback) {
28
31
  if (repositoryIds.length === 0) {
29
- return [
30
- {
31
- fullName: `${fallback.owner}/${fallback.repo}`,
32
- owner: fallback.owner,
33
- repo: fallback.repo,
34
- },
35
- ];
32
+ if (fallback) {
33
+ return [
34
+ {
35
+ fullName: `${fallback.owner}/${fallback.repo}`,
36
+ owner: fallback.owner,
37
+ repo: fallback.repo,
38
+ },
39
+ ];
40
+ }
41
+ return [];
36
42
  }
37
43
  const supabase = getSupabase();
38
44
  const { data } = await supabase
@@ -54,8 +60,8 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
54
60
  resolved.push({ fullName, owner, repo });
55
61
  }
56
62
  // If none resolved (deleted repos / RLS), fall back to the primary repo so
57
- // generation still produces something useful.
58
- if (resolved.length === 0) {
63
+ // generation still produces something useful (product mode only).
64
+ if (resolved.length === 0 && fallback) {
59
65
  return [
60
66
  {
61
67
  fullName: `${fallback.owner}/${fallback.repo}`,
@@ -67,21 +73,33 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
67
73
  return resolved;
68
74
  }
69
75
  export async function cloneFlowRepos(opts) {
70
- const { productId, repositoryIds, workspaceKey, verbose } = opts;
71
- const gh = await getGitHubConfigByProduct(productId, verbose);
76
+ const { productId, repoId, repositoryIds, workspaceKey, verbose } = opts;
77
+ const repoOnly = !productId && Boolean(repoId);
78
+ // Resolve the auth token. Product mode reuses the product's installation /
79
+ // PAT for every repo; repo-only mode resolves it from the first (only) repo.
80
+ const gh = repoOnly
81
+ ? await getGitHubConfigByRepository(repositoryIds[0] ?? repoId, verbose)
82
+ : await getGitHubConfigByProduct(productId, verbose);
72
83
  if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
73
84
  return {
74
85
  ok: false,
75
86
  message: gh.message ||
76
- 'GitHub repository not configured for this product. Connect a repo first.',
87
+ (repoOnly
88
+ ? 'GitHub repository not configured. Connect the repo first.'
89
+ : 'GitHub repository not configured for this product. Connect a repo first.'),
90
+ };
91
+ }
92
+ // In repo-only mode there is no product primary-repo fallback; targets come
93
+ // purely from repositoryIds.
94
+ const targets = await resolveTargetRepos(productId, repositoryIds, repoOnly ? undefined : { owner: gh.owner, repo: gh.repo });
95
+ if (targets.length === 0) {
96
+ return {
97
+ ok: false,
98
+ message: 'No repositories resolved for this flow.',
77
99
  };
78
100
  }
79
- const targets = await resolveTargetRepos(productId, repositoryIds, {
80
- owner: gh.owner,
81
- repo: gh.repo,
82
- });
83
101
  const workspaceRoot = ensureWorkspaceDir();
84
- const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${productId}`);
102
+ const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${repoOnly ? `repo-${repoId}` : productId}`);
85
103
  const repos = [];
86
104
  for (const target of targets) {
87
105
  try {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * flowchart phase: map the control flow of one process / function / algorithm
3
+ * as a flowchart — start/end, process steps, decisions (with branch labels),
4
+ * I/O, and subroutine calls. Persisted to the diagrams tables with
5
+ * `type = 'flowchart'`.
6
+ */
7
+ import { type DiagramPhaseResult } from '../diagram-shared/generate.js';
8
+ export interface FlowchartPhaseOptions {
9
+ productId?: string;
10
+ repoId?: string;
11
+ diagramId: string;
12
+ guidance?: string;
13
+ verbose?: boolean;
14
+ }
15
+ export declare function runFlowchartPhase(options: FlowchartPhaseOptions): Promise<DiagramPhaseResult>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * flowchart phase: map the control flow of one process / function / algorithm
3
+ * as a flowchart — start/end, process steps, decisions (with branch labels),
4
+ * I/O, and subroutine calls. Persisted to the diagrams tables with
5
+ * `type = 'flowchart'`.
6
+ */
7
+ import { z } from 'zod';
8
+ import { generateDiagram, } from '../diagram-shared/generate.js';
9
+ import { buildDiagramSystemPrompt, buildDiagramUserPrompt, } from '../diagram-shared/prompts.js';
10
+ const flowchartNode = z.object({
11
+ slug: z.string().min(1),
12
+ name: z.string().min(1),
13
+ kind: z.enum(['start', 'end', 'process', 'decision', 'io', 'subroutine']),
14
+ file: z.string().optional(),
15
+ description: z.string().optional(),
16
+ });
17
+ const flowchartEdge = z.object({
18
+ fromSlug: z.string().min(1),
19
+ toSlug: z.string().min(1),
20
+ kind: z.enum(['flow', 'branch']),
21
+ /** Branch label for edges out of a decision, e.g. "yes" / "no" / "error". */
22
+ label: z.string().optional(),
23
+ sourceFile: z.string().optional(),
24
+ });
25
+ export function runFlowchartPhase(options) {
26
+ return generateDiagram({
27
+ ...options,
28
+ workspaceKey: 'flowchart',
29
+ fenceName: 'flowchart',
30
+ nounPlural: 'steps',
31
+ edgeNounPlural: 'arrows',
32
+ mcpConfig: {
33
+ name: 'flowchart',
34
+ toolName: 'flowchart',
35
+ summaryDescribe: '1-3 sentence narrative of the process this flowchart captures.',
36
+ nodesSchema: z.array(flowchartNode),
37
+ nodesDescribe: 'Every step: start / end / process / decision / io / subroutine. slug MUST be unique. Use exactly one `start`.',
38
+ edgesSchema: z.array(flowchartEdge),
39
+ edgesDescribe: 'Arrows. kind = flow (plain) or branch (out of a decision; set label to the branch condition like "yes"/"no"). Endpoints MUST reference emitted steps.',
40
+ },
41
+ buildSystemPrompt: (a) => buildDiagramSystemPrompt('phase/flowchart', 'flowchart', a),
42
+ buildUserPrompt: (a) => buildDiagramUserPrompt({
43
+ ...a,
44
+ task: 'Map a flowchart for',
45
+ mcpName: 'flowchart',
46
+ toolName: 'flowchart',
47
+ process: 'Pick ONE important process / function / algorithm (guidance may name it; otherwise choose a central one — a request handler, a core algorithm, a job). Read it top to bottom and translate its control flow into steps: a single `start`, `process` steps for actions, `decision` diamonds for branches (each outgoing edge a `branch` with a "yes"/"no"/condition label), `io` for reads/writes, `subroutine` for significant calls, and `end` node(s) for returns/exits. Follow the happy path plus the important branches.',
48
+ }),
49
+ });
50
+ }
@@ -917,7 +917,7 @@ to keep the user informed during long runs. This is observability only — it
917
917
  does not affect the extraction.
918
918
 
919
919
  ScreenSchema fields:
920
- - \`slug\` (unique within the flow), \`name\`, \`route?\`, \`file?\`
920
+ - \`slug\` (unique within the diagram), \`name\`, \`route?\`, \`file?\`
921
921
  - \`kind\`: one of \`page\`, \`modal\`, \`drawer\`, \`tab\`, \`state\`
922
922
  - \`layout\`: one of \`centered\`, \`sidebar\`, \`split\`, \`list-detail\`, \`tabs\`, \`stacked\`
923
923
  - \`header?\`: \`{ title, subtitle?, back?, actions?: [{ label, variant?, icon? }] }\`
@@ -971,7 +971,7 @@ to keep the user informed during long runs. This is observability only — it
971
971
  does not affect the extraction.
972
972
 
973
973
  DataNodeSchema fields:
974
- - \`slug\` (unique within the flow), \`name\`, \`kind\`, \`file?\`
974
+ - \`slug\` (unique within the diagram), \`name\`, \`kind\`, \`file?\`
975
975
  - \`kind\`: one of \`source\`, \`dataset\`, \`transform\`, \`sink\`, \`queue\`, \`model\`
976
976
  - \`description?\`: one-sentence summary
977
977
  - \`tech?\`: technology / format hint (e.g. \`postgres\`, \`parquet\`, \`kafka\`, \`openai-api\`)
@@ -1021,5 +1021,181 @@ submit_data_flow({
1021
1021
  ]
1022
1022
  })
1023
1023
  \`\`\`
1024
+ `,
1025
+ 'er-diagram': `
1026
+ **CRITICAL — How to return the result**:
1027
+
1028
+ Return the extraction by calling the MCP tool
1029
+ \`mcp__er-diagram__submit_er_diagram\` **exactly once** with three arguments:
1030
+
1031
+ - \`summary\` — 1-3 sentence narrative of the data model (core entities + relationships)
1032
+ - \`entities\` — array of ErEntity objects (every table / view / enum / junction)
1033
+ - \`relations\` — array of ErRelation objects (foreign-key / inheritance edges)
1034
+
1035
+ The tool validates the arguments against the schema. If it returns an error,
1036
+ fix the issue it describes and call the tool again. After a successful call,
1037
+ end your turn — do not also paste the same data as a fenced text block.
1038
+
1039
+ You can also call \`mcp__er-diagram__record_progress({ phase, message })\` at
1040
+ each phase boundary (detection / enumeration / entities / relations / submission)
1041
+ to keep the user informed during long runs. This is observability only — it
1042
+ does not affect the extraction.
1043
+
1044
+ ErEntity fields:
1045
+ - \`slug\` (unique within the diagram), \`name\`, \`kind\`, \`file?\`
1046
+ - \`kind\`: one of \`entity\`, \`view\`, \`enum\`, \`junction\`
1047
+ - \`description?\`: one-sentence summary
1048
+ - \`columns?\`: array of \`{ name, type?, isPrimaryKey?, isForeignKey?, isNullable?, isUnique?, description?, references? }\`
1049
+ - \`references\`: for FKs, \`target-entity-slug.column\` (e.g. \`users.id\`)
1050
+ - \`stats?\`: array of \`{ label, value }\` (row counts, cardinality hints)
1051
+
1052
+ ErRelation fields:
1053
+ - \`fromSlug\` (child / FK holder), \`toSlug\` (parent / referenced) — both MUST appear in entities
1054
+ - \`kind\`: one of \`one-to-one\`, \`one-to-many\`, \`many-to-many\`, \`inherits\`
1055
+ - \`label?\`: free-form descriptor (e.g. \`placed by\`, \`belongs to\`)
1056
+ - \`sourceColumn?\` / \`targetColumn?\`: the FK column and the referenced column
1057
+ - \`sourceFile?\`: file containing the FK constraint / association
1058
+
1059
+ Direction convention: fromSlug holds the foreign key (child / "many" side),
1060
+ toSlug is the referenced entity (parent / "one" side). \`orders.user_id → users.id\`
1061
+ becomes \`{ fromSlug: "orders", toSlug: "users", kind: "one-to-many" }\`.
1062
+
1063
+ Schematic example of the tool call:
1064
+
1065
+ \`\`\`
1066
+ submit_er_diagram({
1067
+ summary: "Storefront schema: users place orders made of order-items that reference products; roles are linked to users through a junction table.",
1068
+ entities: [
1069
+ { slug: "users", name: "users", kind: "entity",
1070
+ file: "db/migrations/0001_users.sql",
1071
+ columns: [
1072
+ { name: "id", type: "uuid", isPrimaryKey: true },
1073
+ { name: "email", type: "varchar(255)", isUnique: true },
1074
+ { name: "created_at", type: "timestamptz" }
1075
+ ] },
1076
+ { slug: "orders", name: "orders", kind: "entity",
1077
+ file: "db/migrations/0002_orders.sql",
1078
+ columns: [
1079
+ { name: "id", type: "uuid", isPrimaryKey: true },
1080
+ { name: "user_id", type: "uuid", isForeignKey: true, references: "users.id" },
1081
+ { name: "status", type: "order_status" }
1082
+ ] },
1083
+ { slug: "order-status", name: "order_status", kind: "enum",
1084
+ file: "db/migrations/0002_orders.sql" }
1085
+ ],
1086
+ relations: [
1087
+ { fromSlug: "orders", toSlug: "users", kind: "one-to-many",
1088
+ label: "placed by", sourceColumn: "user_id", targetColumn: "id",
1089
+ sourceFile: "db/migrations/0002_orders.sql" }
1090
+ ]
1091
+ })
1092
+ \`\`\`
1093
+ `,
1094
+ 'sequence-diagram': `
1095
+ **CRITICAL — How to return the result**:
1096
+
1097
+ Return the extraction by calling the MCP tool
1098
+ \`mcp__sequence-diagram__submit_sequence_diagram\` **exactly once** with three arguments:
1099
+
1100
+ - \`summary\` — 1-3 sentence narrative naming the scenario, the participants, and the outcome
1101
+ - \`participants\` — array of SequenceParticipant objects (every lifeline)
1102
+ - \`messages\` — array of SequenceMessage objects, each with an explicit \`order\`
1103
+
1104
+ The tool validates the arguments against the schema. If it returns an error,
1105
+ fix the issue it describes and call the tool again. After a successful call,
1106
+ end your turn — do not also paste the same data as a fenced text block.
1107
+
1108
+ You can also call \`mcp__sequence-diagram__record_progress({ phase, message })\`
1109
+ at each phase boundary (detection / enumeration / participants / messages /
1110
+ submission) to keep the user informed during long runs. This is observability
1111
+ only — it does not affect the extraction.
1112
+
1113
+ A sequence diagram captures ONE scenario. Map a single end-to-end flow of control.
1114
+
1115
+ SequenceParticipant fields:
1116
+ - \`slug\` (unique within the diagram), \`name\`, \`kind\`, \`file?\`
1117
+ - \`kind\`: one of \`actor\`, \`service\`, \`component\`, \`database\`, \`queue\`, \`external\`
1118
+ - \`description?\`: one-sentence role summary
1119
+
1120
+ SequenceMessage fields:
1121
+ - \`fromSlug\` (sender), \`toSlug\` (receiver) — both MUST appear in participants
1122
+ - \`kind\`: one of \`sync\`, \`async\`, \`return\`, \`self\`
1123
+ - \`order\`: **1-based integer** position in the execution timeline (do not skip or reuse numbers)
1124
+ - \`label?\`: the call / message text (\`POST /login\`, \`validateToken(jwt)\`, \`rows\`)
1125
+ - \`sourceFile?\`: file (+ line) where the call happens
1126
+
1127
+ Order convention: number messages in real execution order; a \`return\` comes
1128
+ after the \`sync\` call it answers. A \`self\` message has \`fromSlug === toSlug\`.
1129
+
1130
+ Schematic example of the tool call:
1131
+
1132
+ \`\`\`
1133
+ submit_sequence_diagram({
1134
+ summary: "Email/password login: the browser posts credentials to AuthService, which verifies them against the users table and issues a JWT.",
1135
+ participants: [
1136
+ { slug: "user", name: "User", kind: "actor" },
1137
+ { slug: "auth-service", name: "AuthService", kind: "service",
1138
+ file: "src/routes/auth.ts" },
1139
+ { slug: "users", name: "users", kind: "database",
1140
+ file: "db/migrations/0001_users.sql" }
1141
+ ],
1142
+ messages: [
1143
+ { fromSlug: "user", toSlug: "auth-service", kind: "sync", order: 1,
1144
+ label: "POST /login {email, password}", sourceFile: "src/routes/auth.ts" },
1145
+ { fromSlug: "auth-service", toSlug: "users", kind: "sync", order: 2,
1146
+ label: "SELECT … WHERE email = ?", sourceFile: "src/routes/auth.ts" },
1147
+ { fromSlug: "users", toSlug: "auth-service", kind: "return", order: 3,
1148
+ label: "user row" },
1149
+ { fromSlug: "auth-service", toSlug: "auth-service", kind: "self", order: 4,
1150
+ label: "verifyPassword()" },
1151
+ { fromSlug: "auth-service", toSlug: "user", kind: "return", order: 5,
1152
+ label: "200 {token}" }
1153
+ ]
1154
+ })
1155
+ \`\`\`
1156
+ `,
1157
+ 'state-diagram': `
1158
+ **CRITICAL — How to return the result**:
1159
+
1160
+ Call \`mcp__state-diagram__submit_state_diagram\` **exactly once** with:
1161
+ - \`summary\` — what this state machine models
1162
+ - \`nodes\` — states: \`{ slug, name, kind, file?, description?, onEntry?, onExit?, activity? }\`; \`kind\` ∈ \`initial\` | \`state\` | \`final\` | \`choice\` | \`composite\` (exactly one \`initial\`)
1163
+ - \`edges\` — transitions: \`{ fromSlug, toSlug, kind: "transition", label?, sourceFile? }\`; put \`trigger [guard] / action\` in \`label\`
1164
+
1165
+ Every fromSlug / toSlug MUST reference a node slug. The tool validates; on error,
1166
+ fix and call again. You may call \`mcp__state-diagram__record_progress\` for status.
1167
+ `,
1168
+ 'class-diagram': `
1169
+ **CRITICAL — How to return the result**:
1170
+
1171
+ Call \`mcp__class-diagram__submit_class_diagram\` **exactly once** with:
1172
+ - \`summary\` — the core types and how they relate
1173
+ - \`nodes\` — \`{ slug, name, kind, file?, description?, stereotype?, attributes?: [{ name, type?, visibility?, isStatic? }], methods?: [{ name, params?, returnType?, visibility?, isStatic?, isAbstract? }] }\`; \`kind\` ∈ \`class\` | \`interface\` | \`abstract\` | \`enum\`
1174
+ - \`edges\` — \`{ fromSlug, toSlug, kind, label?, sourceFile? }\`; \`kind\` ∈ \`inheritance\` | \`implementation\` | \`composition\` | \`aggregation\` | \`association\` | \`dependency\` (fromSlug = child/owner/dependent)
1175
+
1176
+ Every fromSlug / toSlug MUST reference a node slug. The tool validates; on error,
1177
+ fix and call again. You may call \`mcp__class-diagram__record_progress\` for status.
1178
+ `,
1179
+ 'architecture-diagram': `
1180
+ **CRITICAL — How to return the result**:
1181
+
1182
+ Call \`mcp__architecture-diagram__submit_architecture_diagram\` **exactly once** with:
1183
+ - \`summary\` — the architecture and its main building blocks
1184
+ - \`nodes\` — components: \`{ slug, name, kind, file?, description?, tech?, responsibilities?: [string] }\`; \`kind\` ∈ \`module\` | \`service\` | \`package\` | \`ui\` | \`datastore\` | \`external\`
1185
+ - \`edges\` — dependencies: \`{ fromSlug, toSlug, kind, label?, sourceFile? }\`; \`kind\` ∈ \`depends-on\` | \`calls\` | \`imports\` | \`data\` (fromSlug = depender)
1186
+
1187
+ Every fromSlug / toSlug MUST reference a node slug. The tool validates; on error,
1188
+ fix and call again. You may call \`mcp__architecture-diagram__record_progress\` for status.
1189
+ `,
1190
+ flowchart: `
1191
+ **CRITICAL — How to return the result**:
1192
+
1193
+ Call \`mcp__flowchart__submit_flowchart\` **exactly once** with:
1194
+ - \`summary\` — the process this flowchart captures
1195
+ - \`nodes\` — steps: \`{ slug, name, kind, file?, description? }\`; \`kind\` ∈ \`start\` | \`end\` | \`process\` | \`decision\` | \`io\` | \`subroutine\` (exactly one \`start\`)
1196
+ - \`edges\` — \`{ fromSlug, toSlug, kind, label?, sourceFile? }\`; \`kind\` ∈ \`flow\` | \`branch\` (branch edges out of a \`decision\` set \`label\` to "yes"/"no"/condition)
1197
+
1198
+ Every fromSlug / toSlug MUST reference a node slug. The tool validates; on error,
1199
+ fix and call again. You may call \`mcp__flowchart__record_progress\` for status.
1024
1200
  `,
1025
1201
  };
@@ -19,7 +19,10 @@
19
19
  import type { SupabaseClient } from '@supabase/supabase-js';
20
20
  import type { RecipeSummary } from './types.js';
21
21
  export interface RecipesPhaseOptions {
22
- productId: string;
22
+ /** Product-scoped scan. Mutually exclusive with `repoId`. */
23
+ productId?: string;
24
+ /** Repo-only scan: a single repositories row, no product context. */
25
+ repoId?: string;
23
26
  scanId: string;
24
27
  guidance?: string;
25
28
  verbose?: boolean;
@@ -44,6 +47,10 @@ export declare function listProductRecipeLinks(supabase: SupabaseClient, product
44
47
  recipe_id: string;
45
48
  name: string;
46
49
  }[]>;
50
+ export declare function listRepositoryRecipeLinks(supabase: SupabaseClient, repositoryId: string): Promise<{
51
+ recipe_id: string;
52
+ name: string;
53
+ }[]>;
47
54
  /**
48
55
  * Claim the row by flipping `pending` → `running`. Returns true on success
49
56
  * (we won the claim) and false when the row has already moved on (e.g. user
@@ -17,7 +17,7 @@
17
17
  * progress survives even if the agent later errors out.
18
18
  */
19
19
  import { query } from '@anthropic-ai/claude-agent-sdk';
20
- import { getGitHubConfigByProduct } from '../../api/github.js';
20
+ import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
21
21
  import { DEFAULT_MODEL } from '../../constants.js';
22
22
  import { getSupabase } from '../../supabase/client.js';
23
23
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
@@ -33,8 +33,14 @@ const MAX_TURNS = 200;
33
33
  // flowing) lets the row go stale and the reader can mark it failed.
34
34
  const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
35
35
  export async function runRecipesPhase(options) {
36
- const { productId, scanId, guidance, verbose } = options;
37
- logInfo(`Starting recipes scan for product ${productId}`);
36
+ const { productId, repoId, scanId, guidance, verbose } = options;
37
+ const repoOnly = !productId && Boolean(repoId);
38
+ if (productId) {
39
+ logInfo(`Starting recipes scan for product ${productId}`);
40
+ }
41
+ else {
42
+ logInfo(`Starting recipes scan for repository ${repoId}`);
43
+ }
38
44
  const supabase = getSupabase();
39
45
  const claimed = await markRunning(supabase, scanId);
40
46
  if (!claimed) {
@@ -43,19 +49,42 @@ export async function runRecipesPhase(options) {
43
49
  message: 'Recipe scan row is no longer in a runnable state (likely cancelled before the CLI started)',
44
50
  };
45
51
  }
46
- const teamId = await getProductTeamId(supabase, productId);
47
- if (!teamId) {
48
- const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
49
- await markFailed(supabase, scanId, msg);
50
- return { status: 'error', message: msg };
52
+ // Resolve the team (recipes are team-scoped) + repo basics. In repo-only
53
+ // mode both come off the repositories row; in product mode from the product.
54
+ let teamId;
55
+ let repoBasics = {
56
+ fullName: null,
57
+ description: null,
58
+ };
59
+ if (repoOnly) {
60
+ const basics = await getRepositoryBasics(repoId);
61
+ teamId = basics.teamId;
62
+ repoBasics = { fullName: basics.fullName, description: basics.description };
63
+ if (!teamId) {
64
+ const msg = 'Repository is not associated with a team; recipes are team-scoped and require one.';
65
+ await markFailed(supabase, scanId, msg);
66
+ return { status: 'error', message: msg };
67
+ }
51
68
  }
52
- const githubConfig = await getGitHubConfigByProduct(productId, verbose);
69
+ else {
70
+ teamId = await getProductTeamId(supabase, productId);
71
+ if (!teamId) {
72
+ const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
73
+ await markFailed(supabase, scanId, msg);
74
+ return { status: 'error', message: msg };
75
+ }
76
+ }
77
+ const githubConfig = repoOnly
78
+ ? await getGitHubConfigByRepository(repoId, verbose)
79
+ : await getGitHubConfigByProduct(productId, verbose);
53
80
  if (!githubConfig.configured ||
54
81
  !githubConfig.token ||
55
82
  !githubConfig.owner ||
56
83
  !githubConfig.repo) {
57
84
  const msg = githubConfig.message ||
58
- 'GitHub repository not configured for this product. Connect a repo first.';
85
+ (repoOnly
86
+ ? 'GitHub repository not configured. Connect the repo first.'
87
+ : 'GitHub repository not configured for this product. Connect a repo first.');
59
88
  await markFailed(supabase, scanId, msg);
60
89
  return { status: 'error', message: msg };
61
90
  }
@@ -63,13 +92,22 @@ export async function runRecipesPhase(options) {
63
92
  let succeeded = false;
64
93
  try {
65
94
  const workspaceRoot = ensureWorkspaceDir();
66
- const repoKey = `${WORKSPACE_KEY}-${productId}`;
95
+ const repoKey = repoOnly
96
+ ? `${WORKSPACE_KEY}-repo-${repoId}`
97
+ : `${WORKSPACE_KEY}-${productId}`;
67
98
  ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
68
- const [product, scanMeta, teamRecipes, existingLinks] = await Promise.all([
69
- fetchProductBasics(productId),
99
+ const [basics, scanMeta, teamRecipes, existingLinks] = await Promise.all([
100
+ repoOnly
101
+ ? Promise.resolve({
102
+ name: repoBasics.fullName ?? `${githubConfig.owner}/${githubConfig.repo}`,
103
+ description: repoBasics.description ?? undefined,
104
+ })
105
+ : fetchProductBasics(productId),
70
106
  getScanCreator(supabase, scanId),
71
107
  listTeamRecipes(supabase, teamId),
72
- listProductRecipeLinks(supabase, productId),
108
+ repoOnly
109
+ ? listRepositoryRecipeLinks(supabase, repoId)
110
+ : listProductRecipeLinks(supabase, productId),
73
111
  ]);
74
112
  if (!scanMeta) {
75
113
  const msg = 'recipe_scans row vanished mid-run; aborting';
@@ -78,8 +116,8 @@ export async function runRecipesPhase(options) {
78
116
  }
79
117
  const systemPrompt = createRecipesSystemPrompt();
80
118
  const userPrompt = createRecipesUserPrompt({
81
- productName: product.name,
82
- productDescription: product.description,
119
+ productName: basics.name,
120
+ productDescription: basics.description,
83
121
  guidance,
84
122
  teamRecipes,
85
123
  existingLinks: existingLinks.map((l) => ({
@@ -92,7 +130,8 @@ export async function runRecipesPhase(options) {
92
130
  const mcpServer = createRecipesMcpServer({
93
131
  supabase,
94
132
  teamId,
95
- productId,
133
+ productId: repoOnly ? undefined : productId,
134
+ repositoryId: repoOnly ? repoId : undefined,
96
135
  createdBy: scanMeta.created_by,
97
136
  }, counts, teamRecipes, existingLinkIds);
98
137
  logInfo('Running Claude agent to identify recipes...');
@@ -229,6 +268,24 @@ export async function listProductRecipeLinks(supabase, productId) {
229
268
  }
230
269
  return out;
231
270
  }
271
+ export async function listRepositoryRecipeLinks(supabase, repositoryId) {
272
+ const { data, error } = await supabase
273
+ .from('repository_recipes')
274
+ .select('recipe_id, recipes(name)')
275
+ .eq('repository_id', repositoryId);
276
+ if (error || !data) {
277
+ return [];
278
+ }
279
+ const rows = data;
280
+ const out = [];
281
+ for (const r of rows) {
282
+ const recipe = Array.isArray(r.recipes) ? r.recipes[0] : r.recipes;
283
+ if (recipe) {
284
+ out.push({ recipe_id: r.recipe_id, name: recipe.name });
285
+ }
286
+ }
287
+ return out;
288
+ }
232
289
  /**
233
290
  * Claim the row by flipping `pending` → `running`. Returns true on success
234
291
  * (we won the claim) and false when the row has already moved on (e.g. user
@@ -22,7 +22,10 @@ import { type RecipeSummary } from './types.js';
22
22
  export interface RecipesToolContext {
23
23
  supabase: SupabaseClient;
24
24
  teamId: string;
25
- productId: string;
25
+ /** Set in product-scoped scans; links go to `product_recipes`. */
26
+ productId?: string;
27
+ /** Set in repo-only scans; links go to `repository_recipes`. */
28
+ repositoryId?: string;
26
29
  createdBy: string;
27
30
  }
28
31
  /**