@mainahq/core 0.2.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 (156) hide show
  1. package/README.md +31 -0
  2. package/package.json +37 -0
  3. package/src/ai/__tests__/ai.test.ts +207 -0
  4. package/src/ai/__tests__/design-approaches.test.ts +192 -0
  5. package/src/ai/__tests__/spec-questions.test.ts +191 -0
  6. package/src/ai/__tests__/tiers.test.ts +110 -0
  7. package/src/ai/commit-msg.ts +28 -0
  8. package/src/ai/design-approaches.ts +76 -0
  9. package/src/ai/index.ts +205 -0
  10. package/src/ai/pr-summary.ts +60 -0
  11. package/src/ai/spec-questions.ts +74 -0
  12. package/src/ai/tiers.ts +52 -0
  13. package/src/ai/try-generate.ts +89 -0
  14. package/src/ai/validate.ts +66 -0
  15. package/src/benchmark/__tests__/reporter.test.ts +525 -0
  16. package/src/benchmark/__tests__/runner.test.ts +113 -0
  17. package/src/benchmark/__tests__/story-loader.test.ts +152 -0
  18. package/src/benchmark/reporter.ts +332 -0
  19. package/src/benchmark/runner.ts +91 -0
  20. package/src/benchmark/story-loader.ts +88 -0
  21. package/src/benchmark/types.ts +95 -0
  22. package/src/cache/__tests__/keys.test.ts +97 -0
  23. package/src/cache/__tests__/manager.test.ts +312 -0
  24. package/src/cache/__tests__/ttl.test.ts +94 -0
  25. package/src/cache/keys.ts +44 -0
  26. package/src/cache/manager.ts +231 -0
  27. package/src/cache/ttl.ts +77 -0
  28. package/src/config/__tests__/config.test.ts +376 -0
  29. package/src/config/index.ts +198 -0
  30. package/src/context/__tests__/budget.test.ts +179 -0
  31. package/src/context/__tests__/engine.test.ts +163 -0
  32. package/src/context/__tests__/episodic.test.ts +291 -0
  33. package/src/context/__tests__/relevance.test.ts +323 -0
  34. package/src/context/__tests__/retrieval.test.ts +143 -0
  35. package/src/context/__tests__/selector.test.ts +174 -0
  36. package/src/context/__tests__/semantic.test.ts +252 -0
  37. package/src/context/__tests__/treesitter.test.ts +229 -0
  38. package/src/context/__tests__/working.test.ts +236 -0
  39. package/src/context/budget.ts +130 -0
  40. package/src/context/engine.ts +394 -0
  41. package/src/context/episodic.ts +251 -0
  42. package/src/context/relevance.ts +325 -0
  43. package/src/context/retrieval.ts +325 -0
  44. package/src/context/selector.ts +93 -0
  45. package/src/context/semantic.ts +331 -0
  46. package/src/context/treesitter.ts +216 -0
  47. package/src/context/working.ts +192 -0
  48. package/src/db/__tests__/db.test.ts +151 -0
  49. package/src/db/index.ts +211 -0
  50. package/src/db/schema.ts +84 -0
  51. package/src/design/__tests__/design.test.ts +310 -0
  52. package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
  53. package/src/design/__tests__/review.test.ts +561 -0
  54. package/src/design/index.ts +297 -0
  55. package/src/design/review.ts +327 -0
  56. package/src/explain/__tests__/explain.test.ts +173 -0
  57. package/src/explain/index.ts +181 -0
  58. package/src/features/__tests__/analyzer.test.ts +358 -0
  59. package/src/features/__tests__/checklist.test.ts +454 -0
  60. package/src/features/__tests__/numbering.test.ts +319 -0
  61. package/src/features/__tests__/quality.test.ts +295 -0
  62. package/src/features/__tests__/traceability.test.ts +147 -0
  63. package/src/features/analyzer.ts +445 -0
  64. package/src/features/checklist.ts +366 -0
  65. package/src/features/index.ts +18 -0
  66. package/src/features/numbering.ts +404 -0
  67. package/src/features/quality.ts +349 -0
  68. package/src/features/test-stubs.ts +157 -0
  69. package/src/features/traceability.ts +260 -0
  70. package/src/feedback/__tests__/async-feedback.test.ts +52 -0
  71. package/src/feedback/__tests__/collector.test.ts +219 -0
  72. package/src/feedback/__tests__/compress.test.ts +150 -0
  73. package/src/feedback/__tests__/preferences.test.ts +169 -0
  74. package/src/feedback/collector.ts +135 -0
  75. package/src/feedback/compress.ts +92 -0
  76. package/src/feedback/preferences.ts +108 -0
  77. package/src/git/__tests__/git.test.ts +62 -0
  78. package/src/git/index.ts +110 -0
  79. package/src/hooks/__tests__/runner.test.ts +266 -0
  80. package/src/hooks/index.ts +8 -0
  81. package/src/hooks/runner.ts +130 -0
  82. package/src/index.ts +356 -0
  83. package/src/init/__tests__/init.test.ts +228 -0
  84. package/src/init/index.ts +364 -0
  85. package/src/language/__tests__/detect.test.ts +77 -0
  86. package/src/language/__tests__/profile.test.ts +51 -0
  87. package/src/language/detect.ts +70 -0
  88. package/src/language/profile.ts +110 -0
  89. package/src/prompts/__tests__/defaults.test.ts +52 -0
  90. package/src/prompts/__tests__/engine.test.ts +183 -0
  91. package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
  92. package/src/prompts/__tests__/evolution.test.ts +187 -0
  93. package/src/prompts/__tests__/loader.test.ts +105 -0
  94. package/src/prompts/candidates/review-v2.md +55 -0
  95. package/src/prompts/defaults/ai-review.md +49 -0
  96. package/src/prompts/defaults/commit.md +30 -0
  97. package/src/prompts/defaults/context.md +26 -0
  98. package/src/prompts/defaults/design-approaches.md +57 -0
  99. package/src/prompts/defaults/design-hld-lld.md +55 -0
  100. package/src/prompts/defaults/design.md +53 -0
  101. package/src/prompts/defaults/explain.md +31 -0
  102. package/src/prompts/defaults/fix.md +32 -0
  103. package/src/prompts/defaults/index.ts +38 -0
  104. package/src/prompts/defaults/review.md +41 -0
  105. package/src/prompts/defaults/spec-questions.md +59 -0
  106. package/src/prompts/defaults/tests.md +72 -0
  107. package/src/prompts/engine.ts +137 -0
  108. package/src/prompts/evolution.ts +409 -0
  109. package/src/prompts/loader.ts +71 -0
  110. package/src/review/__tests__/review.test.ts +288 -0
  111. package/src/review/comprehensive.ts +362 -0
  112. package/src/review/index.ts +417 -0
  113. package/src/stats/__tests__/tracker.test.ts +323 -0
  114. package/src/stats/index.ts +11 -0
  115. package/src/stats/tracker.ts +492 -0
  116. package/src/ticket/__tests__/ticket.test.ts +273 -0
  117. package/src/ticket/index.ts +185 -0
  118. package/src/utils.ts +87 -0
  119. package/src/verify/__tests__/ai-review.test.ts +242 -0
  120. package/src/verify/__tests__/coverage.test.ts +83 -0
  121. package/src/verify/__tests__/detect.test.ts +175 -0
  122. package/src/verify/__tests__/diff-filter.test.ts +338 -0
  123. package/src/verify/__tests__/fix.test.ts +478 -0
  124. package/src/verify/__tests__/linters/clippy.test.ts +45 -0
  125. package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
  126. package/src/verify/__tests__/linters/ruff.test.ts +64 -0
  127. package/src/verify/__tests__/mutation.test.ts +141 -0
  128. package/src/verify/__tests__/pipeline.test.ts +553 -0
  129. package/src/verify/__tests__/proof.test.ts +97 -0
  130. package/src/verify/__tests__/secretlint.test.ts +190 -0
  131. package/src/verify/__tests__/semgrep.test.ts +217 -0
  132. package/src/verify/__tests__/slop.test.ts +366 -0
  133. package/src/verify/__tests__/sonar.test.ts +113 -0
  134. package/src/verify/__tests__/syntax-guard.test.ts +227 -0
  135. package/src/verify/__tests__/trivy.test.ts +191 -0
  136. package/src/verify/__tests__/visual.test.ts +139 -0
  137. package/src/verify/ai-review.ts +276 -0
  138. package/src/verify/coverage.ts +134 -0
  139. package/src/verify/detect.ts +171 -0
  140. package/src/verify/diff-filter.ts +183 -0
  141. package/src/verify/fix.ts +317 -0
  142. package/src/verify/linters/clippy.ts +52 -0
  143. package/src/verify/linters/go-vet.ts +32 -0
  144. package/src/verify/linters/ruff.ts +47 -0
  145. package/src/verify/mutation.ts +143 -0
  146. package/src/verify/pipeline.ts +328 -0
  147. package/src/verify/proof.ts +277 -0
  148. package/src/verify/secretlint.ts +168 -0
  149. package/src/verify/semgrep.ts +170 -0
  150. package/src/verify/slop.ts +493 -0
  151. package/src/verify/sonar.ts +146 -0
  152. package/src/verify/syntax-guard.ts +251 -0
  153. package/src/verify/trivy.ts +161 -0
  154. package/src/verify/visual.ts +460 -0
  155. package/src/workflow/__tests__/context.test.ts +110 -0
  156. package/src/workflow/context.ts +81 -0
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { MainaConfig } from "../../config/index";
3
+ import { getTaskTier, resolveModel } from "../tiers";
4
+
5
+ const TEST_CONFIG: MainaConfig = {
6
+ models: {
7
+ mechanical: "google/gemini-2.5-flash",
8
+ standard: "anthropic/claude-sonnet-4",
9
+ architectural: "anthropic/claude-sonnet-4-5",
10
+ local: "ollama/qwen3-coder-8b",
11
+ },
12
+ provider: "openrouter",
13
+ budget: {
14
+ daily: 5.0,
15
+ perTask: 0.5,
16
+ alertAt: 0.8,
17
+ },
18
+ };
19
+
20
+ describe("getTaskTier", () => {
21
+ test("commit maps to mechanical", () => {
22
+ expect(getTaskTier("commit")).toBe("mechanical");
23
+ });
24
+
25
+ test("tests maps to mechanical", () => {
26
+ expect(getTaskTier("tests")).toBe("mechanical");
27
+ });
28
+
29
+ test("slop maps to mechanical", () => {
30
+ expect(getTaskTier("slop")).toBe("mechanical");
31
+ });
32
+
33
+ test("compress maps to mechanical", () => {
34
+ expect(getTaskTier("compress")).toBe("mechanical");
35
+ });
36
+
37
+ test("review maps to standard", () => {
38
+ expect(getTaskTier("review")).toBe("standard");
39
+ });
40
+
41
+ test("plan maps to standard", () => {
42
+ expect(getTaskTier("plan")).toBe("standard");
43
+ });
44
+
45
+ test("design maps to standard", () => {
46
+ expect(getTaskTier("design")).toBe("standard");
47
+ });
48
+
49
+ test("fix maps to standard", () => {
50
+ expect(getTaskTier("fix")).toBe("standard");
51
+ });
52
+
53
+ test("design-review maps to architectural", () => {
54
+ expect(getTaskTier("design-review")).toBe("architectural");
55
+ });
56
+
57
+ test("architecture maps to architectural", () => {
58
+ expect(getTaskTier("architecture")).toBe("architectural");
59
+ });
60
+
61
+ test("learn maps to architectural", () => {
62
+ expect(getTaskTier("learn")).toBe("architectural");
63
+ });
64
+
65
+ test("unknown task defaults to standard", () => {
66
+ expect(getTaskTier("unknown")).toBe("standard");
67
+ });
68
+
69
+ test("empty string defaults to standard", () => {
70
+ expect(getTaskTier("")).toBe("standard");
71
+ });
72
+ });
73
+
74
+ describe("resolveModel", () => {
75
+ test("returns correct modelId for mechanical tier task", () => {
76
+ const result = resolveModel("commit", TEST_CONFIG);
77
+ expect(result.tier).toBe("mechanical");
78
+ expect(result.modelId).toBe("google/gemini-2.5-flash");
79
+ expect(result.provider).toBe("openrouter");
80
+ });
81
+
82
+ test("returns correct modelId for standard tier task", () => {
83
+ const result = resolveModel("review", TEST_CONFIG);
84
+ expect(result.tier).toBe("standard");
85
+ expect(result.modelId).toBe("anthropic/claude-sonnet-4");
86
+ expect(result.provider).toBe("openrouter");
87
+ });
88
+
89
+ test("returns correct modelId for architectural tier task", () => {
90
+ const result = resolveModel("architecture", TEST_CONFIG);
91
+ expect(result.tier).toBe("architectural");
92
+ expect(result.modelId).toBe("anthropic/claude-sonnet-4-5");
93
+ expect(result.provider).toBe("openrouter");
94
+ });
95
+
96
+ test("provider comes from config", () => {
97
+ const customConfig: MainaConfig = {
98
+ ...TEST_CONFIG,
99
+ provider: "custom-provider",
100
+ };
101
+ const result = resolveModel("commit", customConfig);
102
+ expect(result.provider).toBe("custom-provider");
103
+ });
104
+
105
+ test("unknown task resolves to standard tier model", () => {
106
+ const result = resolveModel("bogus-task", TEST_CONFIG);
107
+ expect(result.tier).toBe("standard");
108
+ expect(result.modelId).toBe("anthropic/claude-sonnet-4");
109
+ });
110
+ });
@@ -0,0 +1,28 @@
1
+ import { tryAIGenerate } from "./try-generate";
2
+
3
+ /**
4
+ * Generate a conventional commit message from a diff using AI.
5
+ *
6
+ * Returns null when no API key is available or on any failure,
7
+ * allowing callers to fall back to manual message entry.
8
+ */
9
+ export async function generateCommitMessage(
10
+ diff: string,
11
+ stagedFiles: string[],
12
+ mainaDir: string,
13
+ ): Promise<string | null> {
14
+ const aiResult = await tryAIGenerate(
15
+ "commit",
16
+ mainaDir,
17
+ { diff, files: stagedFiles.join(", ") },
18
+ `Generate a conventional commit message for this diff:\n\n${diff}\n\nFiles: ${stagedFiles.join(", ")}`,
19
+ );
20
+
21
+ if (aiResult.text && aiResult.fromAI) {
22
+ // Clean up: extract first line as commit message
23
+ const firstLine = aiResult.text.trim().split("\n")[0] ?? "";
24
+ return firstLine.replace(/^["'`]|["'`]$/g, "").trim() || null;
25
+ }
26
+
27
+ return null;
28
+ }
@@ -0,0 +1,76 @@
1
+ import type { Result } from "../db/index";
2
+ import { tryAIGenerate } from "./try-generate";
3
+
4
+ export interface DesignApproach {
5
+ name: string;
6
+ description: string;
7
+ pros: string[];
8
+ cons: string[];
9
+ recommended: boolean;
10
+ }
11
+
12
+ const MAX_APPROACHES = 3;
13
+
14
+ /**
15
+ * Generates 2-3 design approaches with pros/cons/recommendation by asking
16
+ * the AI to analyze the design context and propose alternatives.
17
+ *
18
+ * Returns an empty array when:
19
+ * - Context is empty
20
+ * - AI is not available (no API key)
21
+ * - AI returns malformed JSON
22
+ */
23
+ export async function generateDesignApproaches(
24
+ designContext: string,
25
+ mainaDir: string,
26
+ ): Promise<Result<DesignApproach[], string>> {
27
+ if (!designContext.trim()) {
28
+ return { ok: true, value: [] };
29
+ }
30
+
31
+ const result = await tryAIGenerate(
32
+ "design-approaches",
33
+ mainaDir,
34
+ { context: designContext },
35
+ `Propose 2-3 architectural approaches for this design decision as a JSON array.\n\n${designContext}`,
36
+ );
37
+
38
+ if (!result.text) {
39
+ return { ok: true, value: [] };
40
+ }
41
+
42
+ try {
43
+ const parsed = parseApproachesJSON(result.text);
44
+ const validated = parsed.filter(isValidApproach);
45
+ return { ok: true, value: validated.slice(0, MAX_APPROACHES) };
46
+ } catch {
47
+ return { ok: true, value: [] };
48
+ }
49
+ }
50
+
51
+ function parseApproachesJSON(text: string): unknown[] {
52
+ let cleaned = text.trim();
53
+ const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
54
+ if (fenceMatch?.[1]) {
55
+ cleaned = fenceMatch[1].trim();
56
+ }
57
+
58
+ const parsed: unknown = JSON.parse(cleaned);
59
+ if (!Array.isArray(parsed)) {
60
+ return [];
61
+ }
62
+ return parsed;
63
+ }
64
+
65
+ function isValidApproach(item: unknown): item is DesignApproach {
66
+ if (typeof item !== "object" || item === null) return false;
67
+ const obj = item as Record<string, unknown>;
68
+ return (
69
+ typeof obj.name === "string" &&
70
+ obj.name.length > 0 &&
71
+ typeof obj.description === "string" &&
72
+ Array.isArray(obj.pros) &&
73
+ Array.isArray(obj.cons) &&
74
+ typeof obj.recommended === "boolean"
75
+ );
76
+ }
@@ -0,0 +1,205 @@
1
+ import { buildCacheKey, hashContent } from "../cache/keys";
2
+ import { createCacheManager } from "../cache/manager";
3
+ import { getTtl } from "../cache/ttl";
4
+ import {
5
+ getApiKey,
6
+ loadConfig,
7
+ resolveProvider,
8
+ shouldDelegateToHost,
9
+ } from "../config/index";
10
+ import { resolveModel } from "./tiers";
11
+ import { validateAIOutput } from "./validate";
12
+
13
+ export interface GenerateOptions {
14
+ task: string;
15
+ systemPrompt: string;
16
+ userPrompt: string;
17
+ files?: string[]; // for cache key
18
+ mainaDir?: string; // for cache storage
19
+ }
20
+
21
+ export interface GenerateResult {
22
+ text: string;
23
+ cached: boolean;
24
+ model: string;
25
+ tokens?: { input: number; output: number };
26
+ slopWarnings?: string[];
27
+ }
28
+
29
+ interface StoredResult {
30
+ text: string;
31
+ model: string;
32
+ tokens?: { input: number; output: number };
33
+ }
34
+
35
+ /**
36
+ * Performs the actual AI SDK call. Isolated here so tests never need to invoke it.
37
+ * Returns null on any error so callers can handle gracefully.
38
+ */
39
+ export async function callModel(
40
+ modelId: string,
41
+ provider: string,
42
+ apiKey: string,
43
+ system: string,
44
+ user: string,
45
+ ): Promise<{
46
+ text: string;
47
+ tokens?: { input: number; output: number };
48
+ } | null> {
49
+ try {
50
+ const { generateText } = await import("ai");
51
+ const { createOpenAI } = await import("@ai-sdk/openai");
52
+
53
+ // Provider-specific base URLs
54
+ let baseURL: string | undefined;
55
+ if (provider === "openrouter") {
56
+ baseURL = "https://openrouter.ai/api/v1";
57
+ } else if (provider === "anthropic") {
58
+ baseURL = "https://api.anthropic.com/v1";
59
+ }
60
+
61
+ const openai = createOpenAI({
62
+ apiKey,
63
+ baseURL,
64
+ });
65
+
66
+ const result = await generateText({
67
+ model: openai(modelId),
68
+ system,
69
+ prompt: user,
70
+ });
71
+
72
+ return {
73
+ text: result.text,
74
+ tokens:
75
+ result.usage != null
76
+ ? {
77
+ input: result.usage.inputTokens ?? 0,
78
+ output: result.usage.outputTokens ?? 0,
79
+ }
80
+ : undefined,
81
+ };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Main AI generation function with cache-first strategy.
89
+ *
90
+ * 1. Hash the prompts to build a stable cache key.
91
+ * 2. Return cached result if available.
92
+ * 3. If no API key, return a helpful error result (never throw).
93
+ * 4. Call the model, cache the result, and return it.
94
+ */
95
+ export async function generate(
96
+ options: GenerateOptions,
97
+ ): Promise<GenerateResult> {
98
+ const { task, systemPrompt, userPrompt, files, mainaDir } = options;
99
+
100
+ const config = await loadConfig();
101
+ const resolved = resolveModel(task, config);
102
+ const provider = resolveProvider(config);
103
+ // In host mode with Anthropic, use a sensible model instead of OpenRouter model IDs
104
+ const modelId =
105
+ provider === "anthropic" && resolved.modelId.startsWith("google/")
106
+ ? "claude-sonnet-4-20250514"
107
+ : provider === "anthropic" && resolved.modelId.includes("/")
108
+ ? (resolved.modelId.split("/")[1] ?? resolved.modelId)
109
+ : resolved.modelId;
110
+
111
+ // Build cache key
112
+ const promptHash = hashContent(systemPrompt + userPrompt);
113
+ const cacheKey = await buildCacheKey({
114
+ task,
115
+ files,
116
+ promptHash,
117
+ model: modelId,
118
+ });
119
+
120
+ // Set up cache (no-op manager if mainaDir not provided)
121
+ const effectiveMainaDir = mainaDir ?? ".maina";
122
+ const cache = createCacheManager(effectiveMainaDir);
123
+
124
+ // Cache hit
125
+ const cached = cache.get(cacheKey);
126
+ if (cached !== null) {
127
+ try {
128
+ const stored = JSON.parse(cached.value) as StoredResult;
129
+ return {
130
+ text: stored.text,
131
+ cached: true,
132
+ model: stored.model,
133
+ tokens: stored.tokens,
134
+ };
135
+ } catch {
136
+ // Corrupted cache entry — fall through to re-generate
137
+ }
138
+ }
139
+
140
+ // Host delegation: when running inside Claude Code/Cursor without own API key,
141
+ // return the prompt so the host agent can process it via MCP or skills
142
+ if (shouldDelegateToHost()) {
143
+ const delegationText = `[HOST_DELEGATION] Task: ${task}\n\nSystem: ${systemPrompt}\n\nUser: ${userPrompt}`;
144
+ // Cache the delegation prompt to avoid rebuilding on repeat calls
145
+ const ttl = getTtl(task as Parameters<typeof getTtl>[0]);
146
+ const storedDelegation = { text: delegationText, model: "host" };
147
+ cache.set(cacheKey, JSON.stringify(storedDelegation), {
148
+ ttl,
149
+ model: "host",
150
+ });
151
+ return {
152
+ text: delegationText,
153
+ cached: false,
154
+ model: "host",
155
+ };
156
+ }
157
+
158
+ // Check for API key
159
+ const apiKey = getApiKey();
160
+ if (apiKey === null) {
161
+ return {
162
+ text: "No API key found. Set MAINA_API_KEY or OPENROUTER_API_KEY environment variable to use AI features.",
163
+ cached: false,
164
+ model: "",
165
+ };
166
+ }
167
+
168
+ // Call the model
169
+ const aiResult = await callModel(
170
+ modelId,
171
+ provider,
172
+ apiKey,
173
+ systemPrompt,
174
+ userPrompt,
175
+ );
176
+
177
+ if (aiResult === null) {
178
+ return {
179
+ text: "AI call failed. Check your API key and network connection.",
180
+ cached: false,
181
+ model: modelId,
182
+ };
183
+ }
184
+
185
+ // Store in cache
186
+ const ttl = getTtl(task as Parameters<typeof getTtl>[0]);
187
+ const storedResult: StoredResult = {
188
+ text: aiResult.text,
189
+ model: modelId,
190
+ tokens: aiResult.tokens,
191
+ };
192
+ cache.set(cacheKey, JSON.stringify(storedResult), { ttl, model: modelId });
193
+
194
+ // Validate AI output for slop patterns
195
+ const validation = validateAIOutput(aiResult.text);
196
+
197
+ return {
198
+ text: validation.sanitized,
199
+ cached: false,
200
+ model: modelId,
201
+ tokens: aiResult.tokens,
202
+ slopWarnings:
203
+ validation.warnings.length > 0 ? validation.warnings : undefined,
204
+ };
205
+ }
@@ -0,0 +1,60 @@
1
+ import { tryAIGenerate } from "./try-generate";
2
+
3
+ /**
4
+ * Generate a structured PR summary from a diff and commit list.
5
+ *
6
+ * Returns a markdown body with ## Summary, ## What Changed, and ## Review sections.
7
+ * Falls back to commit list if AI is unavailable.
8
+ */
9
+ export async function generatePrSummary(
10
+ diff: string,
11
+ commits: Array<{ hash: string; message: string }>,
12
+ reviewSummary: string,
13
+ mainaDir: string,
14
+ ): Promise<string> {
15
+ const commitList = commits
16
+ .map((c) => `- ${c.message} (${c.hash.slice(0, 7)})`)
17
+ .join("\n");
18
+
19
+ const aiResult = await tryAIGenerate(
20
+ "pr",
21
+ mainaDir,
22
+ { diff, commits: commitList },
23
+ `Write a concise PR description for the following changes.
24
+
25
+ ## Commits
26
+ ${commitList}
27
+
28
+ ## Diff (truncated)
29
+ ${diff.slice(0, 8000)}
30
+
31
+ Instructions:
32
+ - Start with a 1-2 sentence summary of WHAT this PR does and WHY
33
+ - Then a "## What Changed" section with 3-6 bullet points grouped by theme (not one per commit)
34
+ - Focus on user-visible impact, not implementation details
35
+ - Do not repeat commit hashes or the full commit log
36
+ - Do not include a review section (that's added separately)
37
+ - Use markdown formatting`,
38
+ );
39
+
40
+ if (aiResult.text && aiResult.fromAI) {
41
+ return `${aiResult.text.trim()}
42
+
43
+ ## Review
44
+
45
+ ${reviewSummary}`;
46
+ }
47
+
48
+ // Fallback: structured commit list
49
+ return `## Summary
50
+
51
+ ${commits.length} commit(s) in this PR.
52
+
53
+ ## What Changed
54
+
55
+ ${commitList}
56
+
57
+ ## Review
58
+
59
+ ${reviewSummary}`;
60
+ }
@@ -0,0 +1,74 @@
1
+ import type { Result } from "../db/index";
2
+ import { tryAIGenerate } from "./try-generate";
3
+
4
+ export interface SpecQuestion {
5
+ question: string;
6
+ type: "text" | "select";
7
+ options?: string[];
8
+ reason: string;
9
+ }
10
+
11
+ const MAX_QUESTIONS = 5;
12
+
13
+ /**
14
+ * Generates clarifying questions from plan.md content by asking the AI
15
+ * to identify ambiguities, missing edge cases, and unstated assumptions.
16
+ *
17
+ * Returns an empty array when:
18
+ * - Plan content is empty
19
+ * - AI is not available (no API key)
20
+ * - AI returns malformed JSON
21
+ */
22
+ export async function generateSpecQuestions(
23
+ planContent: string,
24
+ mainaDir: string,
25
+ ): Promise<Result<SpecQuestion[], string>> {
26
+ if (!planContent.trim()) {
27
+ return { ok: true, value: [] };
28
+ }
29
+
30
+ const result = await tryAIGenerate(
31
+ "spec-questions",
32
+ mainaDir,
33
+ { plan: planContent },
34
+ `Analyze this implementation plan and return 3-5 clarifying questions as a JSON array.\n\n${planContent}`,
35
+ );
36
+
37
+ if (!result.text) {
38
+ return { ok: true, value: [] };
39
+ }
40
+
41
+ try {
42
+ const parsed = parseQuestionsJSON(result.text);
43
+ const validated = parsed.filter(isValidQuestion);
44
+ return { ok: true, value: validated.slice(0, MAX_QUESTIONS) };
45
+ } catch {
46
+ return { ok: true, value: [] };
47
+ }
48
+ }
49
+
50
+ function parseQuestionsJSON(text: string): unknown[] {
51
+ // Strip markdown code fences if present
52
+ let cleaned = text.trim();
53
+ const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
54
+ if (fenceMatch?.[1]) {
55
+ cleaned = fenceMatch[1].trim();
56
+ }
57
+
58
+ const parsed: unknown = JSON.parse(cleaned);
59
+ if (!Array.isArray(parsed)) {
60
+ return [];
61
+ }
62
+ return parsed;
63
+ }
64
+
65
+ function isValidQuestion(item: unknown): item is SpecQuestion {
66
+ if (typeof item !== "object" || item === null) return false;
67
+ const obj = item as Record<string, unknown>;
68
+ return (
69
+ typeof obj.question === "string" &&
70
+ obj.question.length > 0 &&
71
+ (obj.type === "text" || obj.type === "select") &&
72
+ typeof obj.reason === "string"
73
+ );
74
+ }
@@ -0,0 +1,52 @@
1
+ import type { MainaConfig } from "../config/index";
2
+
3
+ export type ModelTier = "mechanical" | "standard" | "architectural" | "local";
4
+
5
+ export interface ModelResolution {
6
+ tier: ModelTier;
7
+ modelId: string;
8
+ provider: string;
9
+ }
10
+
11
+ const MECHANICAL_TASKS = new Set([
12
+ "commit",
13
+ "tests",
14
+ "slop",
15
+ "compress",
16
+ "code-review",
17
+ ]);
18
+ const ARCHITECTURAL_TASKS = new Set(["design-review", "architecture", "learn"]);
19
+
20
+ /**
21
+ * Maps a task name to its model tier.
22
+ * - mechanical: commit, tests, slop, compress
23
+ * - standard: review, plan, design, fix (and any unknown task)
24
+ * - architectural: design-review, architecture, learn
25
+ * - local: not auto-assigned; user must explicitly set
26
+ */
27
+ export function getTaskTier(task: string): ModelTier {
28
+ if (MECHANICAL_TASKS.has(task)) {
29
+ return "mechanical";
30
+ }
31
+ if (ARCHITECTURAL_TASKS.has(task)) {
32
+ return "architectural";
33
+ }
34
+ // standard is the default for known standard tasks and all unknowns
35
+ return "standard";
36
+ }
37
+
38
+ /**
39
+ * Resolves the model ID and provider for a given task using the provided config.
40
+ */
41
+ export function resolveModel(
42
+ task: string,
43
+ config: MainaConfig,
44
+ ): ModelResolution {
45
+ const tier = getTaskTier(task);
46
+ const modelId = config.models[tier];
47
+ return {
48
+ tier,
49
+ modelId,
50
+ provider: config.provider,
51
+ };
52
+ }
@@ -0,0 +1,89 @@
1
+ import { getApiKey, isHostMode } from "../config/index";
2
+
3
+ export interface DelegationPrompt {
4
+ task: string;
5
+ systemPrompt: string;
6
+ userPrompt: string;
7
+ promptHash: string;
8
+ }
9
+
10
+ export interface TryAIResult {
11
+ text: string | null;
12
+ fromAI: boolean;
13
+ hostDelegation: boolean;
14
+ promptHash?: string;
15
+ /** Structured prompt for host to process when hostDelegation is true */
16
+ delegation?: DelegationPrompt;
17
+ }
18
+
19
+ /**
20
+ * Single entry point for all AI calls.
21
+ *
22
+ * Returns:
23
+ * - { text, fromAI: true } when AI generates a response (has API key)
24
+ * - { text: null, hostDelegation: true, delegation } when in host mode (no key)
25
+ * The delegation contains structured prompts for the host agent to process.
26
+ * - { text: null } when AI is not available and not in host mode
27
+ */
28
+ export async function tryAIGenerate(
29
+ task: string,
30
+ mainaDir: string,
31
+ variables: Record<string, string>,
32
+ userPrompt: string,
33
+ ): Promise<TryAIResult> {
34
+ const apiKey = getApiKey();
35
+
36
+ // Not in host mode and no key → unavailable
37
+ if (!apiKey && !isHostMode()) {
38
+ return { text: null, fromAI: false, hostDelegation: false };
39
+ }
40
+
41
+ try {
42
+ const { buildSystemPrompt } = await import("../prompts/engine");
43
+
44
+ // Build the prompt (needed for both direct call and delegation)
45
+ const builtPrompt = await buildSystemPrompt(task, mainaDir, variables);
46
+
47
+ // If we have an API key, make the direct call
48
+ if (apiKey) {
49
+ const { generate } = await import("./index");
50
+ const result = await generate({
51
+ task,
52
+ systemPrompt: builtPrompt.prompt,
53
+ userPrompt,
54
+ mainaDir,
55
+ });
56
+
57
+ // Skip delegation responses from generate() — we handle it below
58
+ if (
59
+ result.text &&
60
+ !result.text.startsWith("[HOST_DELEGATION]") &&
61
+ !result.text.includes("API key")
62
+ ) {
63
+ return {
64
+ text: result.text,
65
+ fromAI: true,
66
+ hostDelegation: false,
67
+ promptHash: builtPrompt.hash,
68
+ };
69
+ }
70
+ }
71
+
72
+ // Host mode — return structured delegation for host agent to process
73
+ return {
74
+ text: null,
75
+ fromAI: false,
76
+ hostDelegation: true,
77
+ promptHash: builtPrompt.hash,
78
+ delegation: {
79
+ task,
80
+ systemPrompt: builtPrompt.prompt,
81
+ userPrompt,
82
+ promptHash: builtPrompt.hash,
83
+ },
84
+ };
85
+ } catch {
86
+ // AI failure — return null for fallback
87
+ }
88
+ return { text: null, fromAI: false, hostDelegation: false };
89
+ }