@mainahq/core 0.7.0 → 1.0.1

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.
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Feedback sync — exports local feedback records for cloud upload.
2
+ * Feedback sync — exports local feedback records and workflow stats for cloud upload.
3
3
  *
4
4
  * Reads from the local SQLite feedback.db and maps records to the
5
5
  * cloud-compatible FeedbackEvent format for batch upload.
6
+ * Also exports workflow step counts for analytics.
6
7
  */
7
8
 
8
- import type { FeedbackEvent } from "../cloud/types";
9
- import { getFeedbackDb } from "../db/index";
9
+ import type { EpisodicCloudEntry, FeedbackEvent } from "../cloud/types";
10
+ import { getEntries } from "../context/episodic";
11
+ import { getFeedbackDb, getStatsDb } from "../db/index";
10
12
 
11
13
  /** Raw row shape from the feedback table. */
12
14
  interface FeedbackRow {
@@ -52,3 +54,90 @@ export function exportFeedbackForCloud(mainaDir: string): FeedbackEvent[] {
52
54
  return event;
53
55
  });
54
56
  }
57
+
58
+ /** Workflow stats summary for cloud analytics. */
59
+ export interface WorkflowStats {
60
+ totalCommits: number;
61
+ totalVerifyTimeMs: number;
62
+ avgVerifyTimeMs: number;
63
+ totalFindings: number;
64
+ totalContextTokens: number;
65
+ cacheHitRate: number;
66
+ passRate: number;
67
+ }
68
+
69
+ /**
70
+ * Export workflow stats from the local stats.db for cloud analytics.
71
+ * Returns aggregated numbers the dashboard can display.
72
+ */
73
+ export function exportWorkflowStats(mainaDir: string): WorkflowStats | null {
74
+ const dbResult = getStatsDb(mainaDir);
75
+ if (!dbResult.ok) return null;
76
+
77
+ const { db } = dbResult.value;
78
+ try {
79
+ const row = db
80
+ .query(
81
+ `SELECT
82
+ COUNT(*) as total_commits,
83
+ COALESCE(SUM(verify_duration_ms), 0) as total_verify_ms,
84
+ COALESCE(AVG(verify_duration_ms), 0) as avg_verify_ms,
85
+ COALESCE(SUM(findings_total), 0) as total_findings,
86
+ COALESCE(SUM(context_tokens), 0) as total_context_tokens,
87
+ COALESCE(SUM(cache_hits), 0) as total_cache_hits,
88
+ COALESCE(SUM(cache_misses), 0) as total_cache_misses,
89
+ COALESCE(SUM(CASE WHEN pipeline_passed = 1 THEN 1 ELSE 0 END), 0) as passed_count
90
+ FROM commit_snapshots`,
91
+ )
92
+ .get() as {
93
+ total_commits: number;
94
+ total_verify_ms: number;
95
+ avg_verify_ms: number;
96
+ total_findings: number;
97
+ total_context_tokens: number;
98
+ total_cache_hits: number;
99
+ total_cache_misses: number;
100
+ passed_count: number;
101
+ } | null;
102
+
103
+ if (!row || row.total_commits === 0) return null;
104
+
105
+ const totalCacheOps = row.total_cache_hits + row.total_cache_misses;
106
+
107
+ return {
108
+ totalCommits: row.total_commits,
109
+ totalVerifyTimeMs: row.total_verify_ms,
110
+ avgVerifyTimeMs: Math.round(row.avg_verify_ms),
111
+ totalFindings: row.total_findings,
112
+ totalContextTokens: row.total_context_tokens,
113
+ cacheHitRate:
114
+ totalCacheOps > 0 ? row.total_cache_hits / totalCacheOps : 0,
115
+ passRate: row.passed_count / row.total_commits,
116
+ };
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Export local episodic entries for cloud upload.
124
+ *
125
+ * Reads from the episodic_entries table in the context DB and maps
126
+ * each entry to the cloud-compatible EpisodicCloudEntry format.
127
+ *
128
+ * @param repo - Repository identifier (e.g. "owner/repo") to tag entries with.
129
+ */
130
+ export function exportEpisodicForCloud(
131
+ mainaDir: string,
132
+ repo: string,
133
+ ): EpisodicCloudEntry[] {
134
+ const entries = getEntries(mainaDir);
135
+
136
+ return entries.map((entry) => ({
137
+ repo,
138
+ entryType: entry.type,
139
+ title: entry.summary || entry.type,
140
+ summary: entry.content,
141
+ relevanceScore: entry.relevance,
142
+ }));
143
+ }
@@ -6,6 +6,7 @@ import {
6
6
  getDiff,
7
7
  getRecentCommits,
8
8
  getRepoRoot,
9
+ getRepoSlug,
9
10
  getStagedFiles,
10
11
  } from "../index";
11
12
 
@@ -59,4 +60,18 @@ describe("git operations", () => {
59
60
  const diff = await getDiff();
60
61
  expect(typeof diff).toBe("string");
61
62
  });
63
+
64
+ test("getRepoSlug() returns owner/repo format", async () => {
65
+ const slug = await getRepoSlug();
66
+ expect(typeof slug).toBe("string");
67
+ // Should contain a slash (owner/repo) or at minimum be non-empty
68
+ expect(slug.length).toBeGreaterThan(0);
69
+ // If remote exists, should be owner/repo format
70
+ if (slug !== "unknown" && slug.includes("/")) {
71
+ const parts = slug.split("/");
72
+ expect(parts).toHaveLength(2);
73
+ expect(parts[0]?.length).toBeGreaterThan(0);
74
+ expect(parts[1]?.length).toBeGreaterThan(0);
75
+ }
76
+ });
62
77
  });
package/src/git/index.ts CHANGED
@@ -108,3 +108,28 @@ export async function getTrackedFiles(cwd?: string): Promise<string[]> {
108
108
  if (!output) return [];
109
109
  return output.split("\n").filter((line) => line.trim().length > 0);
110
110
  }
111
+
112
+ /**
113
+ * Extract the "owner/repo" slug from the git remote origin URL.
114
+ * Handles HTTPS (https://github.com/owner/repo.git) and
115
+ * SSH (git@github.com:owner/repo.git) formats.
116
+ * Returns the directory basename as fallback if parsing fails.
117
+ */
118
+ export async function getRepoSlug(cwd?: string): Promise<string> {
119
+ const url = await exec(["remote", "get-url", "origin"], cwd);
120
+ if (url) {
121
+ // SSH: git@github.com:owner/repo.git
122
+ const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
123
+ if (sshMatch?.[1]) return sshMatch[1];
124
+ // HTTPS: https://github.com/owner/repo.git
125
+ const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
126
+ if (httpsMatch?.[1]) return httpsMatch[1];
127
+ }
128
+ // Fallback: use directory name
129
+ const root = await exec(["rev-parse", "--show-toplevel"], cwd);
130
+ if (root) {
131
+ const parts = root.split("/");
132
+ return parts[parts.length - 1] ?? "unknown";
133
+ }
134
+ return "unknown";
135
+ }
package/src/index.ts CHANGED
@@ -63,9 +63,11 @@ export { type CloudClient, createCloudClient } from "./cloud/client";
63
63
  export type {
64
64
  ApiResponse,
65
65
  CloudConfig,
66
+ CloudEpisodicEntry,
66
67
  CloudFeedbackPayload,
67
68
  CloudPromptImprovement,
68
69
  DeviceCodeResponse,
70
+ EpisodicCloudEntry,
69
71
  FeedbackBatchPayload,
70
72
  FeedbackEvent,
71
73
  FeedbackImprovementsResponse,
@@ -172,7 +174,12 @@ export {
172
174
  type RulePreference,
173
175
  savePreferences,
174
176
  } from "./feedback/preferences";
175
- export { exportFeedbackForCloud } from "./feedback/sync";
177
+ export {
178
+ exportEpisodicForCloud,
179
+ exportFeedbackForCloud,
180
+ exportWorkflowStats,
181
+ type WorkflowStats,
182
+ } from "./feedback/sync";
176
183
  export {
177
184
  analyzeWorkflowTrace,
178
185
  type PromptImprovement,
@@ -188,6 +195,7 @@ export {
188
195
  getDiff,
189
196
  getRecentCommits,
190
197
  getRepoRoot,
198
+ getRepoSlug,
191
199
  getStagedFiles,
192
200
  getTrackedFiles,
193
201
  } from "./git/index";
@@ -317,8 +325,11 @@ export {
317
325
  export {
318
326
  detectTool,
319
327
  detectTools,
328
+ getToolsForLanguages,
320
329
  isToolAvailable,
321
330
  TOOL_REGISTRY,
331
+ type ToolRegistryEntry,
332
+ type ToolTier,
322
333
  } from "./verify/detect";
323
334
  export {
324
335
  filterByDiff,
@@ -0,0 +1,237 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { bootstrap } from "../index";
6
+
7
+ function makeTmpDir(): string {
8
+ const dir = join(
9
+ tmpdir(),
10
+ `maina-detect-stack-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
11
+ );
12
+ mkdirSync(dir, { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ describe("detectStack — languages field", () => {
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = makeTmpDir();
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ test("empty directory returns languages with 'unknown'", async () => {
28
+ const result = await bootstrap(tmpDir);
29
+ expect(result.ok).toBe(true);
30
+ if (result.ok) {
31
+ expect(result.value.detectedStack.languages).toBeDefined();
32
+ expect(Array.isArray(result.value.detectedStack.languages)).toBe(true);
33
+ expect(result.value.detectedStack.languages).toContain("unknown");
34
+ }
35
+ });
36
+
37
+ test("TypeScript project includes 'typescript' in languages", async () => {
38
+ writeFileSync(
39
+ join(tmpDir, "package.json"),
40
+ JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
41
+ );
42
+ writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
43
+
44
+ const result = await bootstrap(tmpDir);
45
+ expect(result.ok).toBe(true);
46
+ if (result.ok) {
47
+ expect(result.value.detectedStack.languages).toContain("typescript");
48
+ expect(result.value.detectedStack.languages).not.toContain("unknown");
49
+ }
50
+ });
51
+
52
+ test("JavaScript project includes 'javascript' in languages", async () => {
53
+ writeFileSync(
54
+ join(tmpDir, "package.json"),
55
+ JSON.stringify({ dependencies: { express: "^4.0.0" } }),
56
+ );
57
+
58
+ const result = await bootstrap(tmpDir);
59
+ expect(result.ok).toBe(true);
60
+ if (result.ok) {
61
+ expect(result.value.detectedStack.languages).toContain("javascript");
62
+ expect(result.value.detectedStack.languages).not.toContain("unknown");
63
+ }
64
+ });
65
+
66
+ test("go.mod detected as 'go' language", async () => {
67
+ writeFileSync(
68
+ join(tmpDir, "go.mod"),
69
+ "module example.com/foo\n\ngo 1.21\n",
70
+ );
71
+
72
+ const result = await bootstrap(tmpDir);
73
+ expect(result.ok).toBe(true);
74
+ if (result.ok) {
75
+ expect(result.value.detectedStack.languages).toContain("go");
76
+ }
77
+ });
78
+
79
+ test("Cargo.toml detected as 'rust' language", async () => {
80
+ writeFileSync(
81
+ join(tmpDir, "Cargo.toml"),
82
+ '[package]\nname = "test"\nversion = "0.1.0"\n',
83
+ );
84
+
85
+ const result = await bootstrap(tmpDir);
86
+ expect(result.ok).toBe(true);
87
+ if (result.ok) {
88
+ expect(result.value.detectedStack.languages).toContain("rust");
89
+ }
90
+ });
91
+
92
+ test("pyproject.toml detected as 'python' language", async () => {
93
+ writeFileSync(join(tmpDir, "pyproject.toml"), "[project]\nname = 'test'\n");
94
+
95
+ const result = await bootstrap(tmpDir);
96
+ expect(result.ok).toBe(true);
97
+ if (result.ok) {
98
+ expect(result.value.detectedStack.languages).toContain("python");
99
+ }
100
+ });
101
+
102
+ test("requirements.txt detected as 'python' language", async () => {
103
+ writeFileSync(join(tmpDir, "requirements.txt"), "flask==2.0.0\n");
104
+
105
+ const result = await bootstrap(tmpDir);
106
+ expect(result.ok).toBe(true);
107
+ if (result.ok) {
108
+ expect(result.value.detectedStack.languages).toContain("python");
109
+ }
110
+ });
111
+
112
+ test("setup.py detected as 'python' language", async () => {
113
+ writeFileSync(join(tmpDir, "setup.py"), "from setuptools import setup\n");
114
+
115
+ const result = await bootstrap(tmpDir);
116
+ expect(result.ok).toBe(true);
117
+ if (result.ok) {
118
+ expect(result.value.detectedStack.languages).toContain("python");
119
+ }
120
+ });
121
+
122
+ test("pom.xml detected as 'java' language", async () => {
123
+ writeFileSync(join(tmpDir, "pom.xml"), "<project></project>\n");
124
+
125
+ const result = await bootstrap(tmpDir);
126
+ expect(result.ok).toBe(true);
127
+ if (result.ok) {
128
+ expect(result.value.detectedStack.languages).toContain("java");
129
+ }
130
+ });
131
+
132
+ test("build.gradle detected as 'java' language", async () => {
133
+ writeFileSync(join(tmpDir, "build.gradle"), "apply plugin: 'java'\n");
134
+
135
+ const result = await bootstrap(tmpDir);
136
+ expect(result.ok).toBe(true);
137
+ if (result.ok) {
138
+ expect(result.value.detectedStack.languages).toContain("java");
139
+ }
140
+ });
141
+
142
+ test("build.gradle.kts detected as 'java' language", async () => {
143
+ writeFileSync(join(tmpDir, "build.gradle.kts"), 'plugins { id("java") }\n');
144
+
145
+ const result = await bootstrap(tmpDir);
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ expect(result.value.detectedStack.languages).toContain("java");
149
+ }
150
+ });
151
+
152
+ test(".csproj file detected as 'dotnet' language", async () => {
153
+ writeFileSync(join(tmpDir, "MyApp.csproj"), "<Project></Project>\n");
154
+
155
+ const result = await bootstrap(tmpDir);
156
+ expect(result.ok).toBe(true);
157
+ if (result.ok) {
158
+ expect(result.value.detectedStack.languages).toContain("dotnet");
159
+ }
160
+ });
161
+
162
+ test(".sln file detected as 'dotnet' language", async () => {
163
+ writeFileSync(join(tmpDir, "MyApp.sln"), "Microsoft Visual Studio\n");
164
+
165
+ const result = await bootstrap(tmpDir);
166
+ expect(result.ok).toBe(true);
167
+ if (result.ok) {
168
+ expect(result.value.detectedStack.languages).toContain("dotnet");
169
+ }
170
+ });
171
+
172
+ test(".fsproj file detected as 'dotnet' language", async () => {
173
+ writeFileSync(join(tmpDir, "MyApp.fsproj"), "<Project></Project>\n");
174
+
175
+ const result = await bootstrap(tmpDir);
176
+ expect(result.ok).toBe(true);
177
+ if (result.ok) {
178
+ expect(result.value.detectedStack.languages).toContain("dotnet");
179
+ }
180
+ });
181
+
182
+ test("multi-language project detects all languages", async () => {
183
+ // TypeScript + Python
184
+ writeFileSync(
185
+ join(tmpDir, "package.json"),
186
+ JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
187
+ );
188
+ writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
189
+ writeFileSync(join(tmpDir, "requirements.txt"), "flask==2.0.0\n");
190
+
191
+ const result = await bootstrap(tmpDir);
192
+ expect(result.ok).toBe(true);
193
+ if (result.ok) {
194
+ expect(result.value.detectedStack.languages).toContain("typescript");
195
+ expect(result.value.detectedStack.languages).toContain("python");
196
+ expect(
197
+ result.value.detectedStack.languages.length,
198
+ ).toBeGreaterThanOrEqual(2);
199
+ }
200
+ });
201
+
202
+ test("TypeScript + Go + Rust multi-language detection", async () => {
203
+ writeFileSync(
204
+ join(tmpDir, "package.json"),
205
+ JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
206
+ );
207
+ writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
208
+ writeFileSync(join(tmpDir, "go.mod"), "module test\n\ngo 1.21\n");
209
+ writeFileSync(join(tmpDir, "Cargo.toml"), '[package]\nname = "test"\n');
210
+
211
+ const result = await bootstrap(tmpDir);
212
+ expect(result.ok).toBe(true);
213
+ if (result.ok) {
214
+ const langs = result.value.detectedStack.languages;
215
+ expect(langs).toContain("typescript");
216
+ expect(langs).toContain("go");
217
+ expect(langs).toContain("rust");
218
+ expect(langs.length).toBeGreaterThanOrEqual(3);
219
+ }
220
+ });
221
+
222
+ test("languages does not contain duplicates", async () => {
223
+ writeFileSync(
224
+ join(tmpDir, "package.json"),
225
+ JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
226
+ );
227
+ writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
228
+
229
+ const result = await bootstrap(tmpDir);
230
+ expect(result.ok).toBe(true);
231
+ if (result.ok) {
232
+ const langs = result.value.detectedStack.languages;
233
+ const unique = [...new Set(langs)];
234
+ expect(langs.length).toBe(unique.length);
235
+ }
236
+ });
237
+ });
@@ -276,4 +276,188 @@ describe("bootstrap", () => {
276
276
  expect(result.value.detectedStack.linter).toBe("eslint");
277
277
  }
278
278
  });
279
+
280
+ // ── .mcp.json generation ──────────────────────────────────────────────
281
+
282
+ test("creates .mcp.json at repo root", async () => {
283
+ const result = await bootstrap(tmpDir);
284
+ expect(result.ok).toBe(true);
285
+
286
+ const mcpPath = join(tmpDir, ".mcp.json");
287
+ expect(existsSync(mcpPath)).toBe(true);
288
+
289
+ const content = JSON.parse(readFileSync(mcpPath, "utf-8"));
290
+ expect(content.mcpServers).toBeDefined();
291
+ expect(content.mcpServers.maina).toBeDefined();
292
+ expect(content.mcpServers.maina.command).toBe("maina");
293
+ expect(content.mcpServers.maina.args).toEqual(["--mcp"]);
294
+ });
295
+
296
+ test("does not overwrite existing .mcp.json", async () => {
297
+ writeFileSync(join(tmpDir, ".mcp.json"), '{"custom": true}');
298
+
299
+ const result = await bootstrap(tmpDir);
300
+ expect(result.ok).toBe(true);
301
+ if (result.ok) {
302
+ expect(result.value.skipped).toContain(".mcp.json");
303
+ }
304
+
305
+ const content = readFileSync(join(tmpDir, ".mcp.json"), "utf-8");
306
+ expect(JSON.parse(content)).toEqual({ custom: true });
307
+ });
308
+
309
+ // ── Agent instruction files ───────────────────────────────────────────
310
+
311
+ test("creates CLAUDE.md at repo root", async () => {
312
+ const result = await bootstrap(tmpDir);
313
+ expect(result.ok).toBe(true);
314
+
315
+ const claudePath = join(tmpDir, "CLAUDE.md");
316
+ expect(existsSync(claudePath)).toBe(true);
317
+
318
+ const content = readFileSync(claudePath, "utf-8");
319
+ expect(content).toContain("# CLAUDE.md");
320
+ expect(content).toContain("constitution.md");
321
+ expect(content).toContain("brainstorm");
322
+ expect(content).toContain("getContext");
323
+ expect(content).toContain("maina verify");
324
+ });
325
+
326
+ test("creates GEMINI.md at repo root", async () => {
327
+ const result = await bootstrap(tmpDir);
328
+ expect(result.ok).toBe(true);
329
+
330
+ const geminiPath = join(tmpDir, "GEMINI.md");
331
+ expect(existsSync(geminiPath)).toBe(true);
332
+
333
+ const content = readFileSync(geminiPath, "utf-8");
334
+ expect(content).toContain("# GEMINI.md");
335
+ expect(content).toContain("constitution.md");
336
+ expect(content).toContain("brainstorm");
337
+ expect(content).toContain("getContext");
338
+ });
339
+
340
+ test("creates .cursorrules at repo root", async () => {
341
+ const result = await bootstrap(tmpDir);
342
+ expect(result.ok).toBe(true);
343
+
344
+ const cursorPath = join(tmpDir, ".cursorrules");
345
+ expect(existsSync(cursorPath)).toBe(true);
346
+
347
+ const content = readFileSync(cursorPath, "utf-8");
348
+ expect(content).toContain("Cursor Rules");
349
+ expect(content).toContain("constitution.md");
350
+ expect(content).toContain("brainstorm");
351
+ expect(content).toContain("maina verify");
352
+ });
353
+
354
+ test("does not overwrite existing agent files", async () => {
355
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Custom CLAUDE.md\n");
356
+ writeFileSync(join(tmpDir, "GEMINI.md"), "# My Custom GEMINI.md\n");
357
+ writeFileSync(join(tmpDir, ".cursorrules"), "# My Custom Rules\n");
358
+
359
+ const result = await bootstrap(tmpDir);
360
+ expect(result.ok).toBe(true);
361
+ if (result.ok) {
362
+ expect(result.value.skipped).toContain("CLAUDE.md");
363
+ expect(result.value.skipped).toContain("GEMINI.md");
364
+ expect(result.value.skipped).toContain(".cursorrules");
365
+ }
366
+
367
+ expect(readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(
368
+ "# My Custom CLAUDE.md\n",
369
+ );
370
+ });
371
+
372
+ // ── Workflow order in agent files ─────────────────────────────────────
373
+
374
+ test("AGENTS.md includes workflow order", async () => {
375
+ const result = await bootstrap(tmpDir);
376
+ expect(result.ok).toBe(true);
377
+
378
+ const content = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
379
+ expect(content).toContain("Workflow Order");
380
+ expect(content).toContain("brainstorm");
381
+ expect(content).toContain("ticket");
382
+ expect(content).toContain("implement");
383
+ expect(content).toContain("verify");
384
+ });
385
+
386
+ test("copilot instructions include workflow order", async () => {
387
+ const result = await bootstrap(tmpDir);
388
+ expect(result.ok).toBe(true);
389
+
390
+ const content = readFileSync(
391
+ join(tmpDir, ".github", "copilot-instructions.md"),
392
+ "utf-8",
393
+ );
394
+ expect(content).toContain("Workflow Order");
395
+ expect(content).toContain("brainstorm");
396
+ });
397
+
398
+ // ── MCP tools in agent files ──────────────────────────────────────────
399
+
400
+ test("AGENTS.md includes MCP tools table", async () => {
401
+ const result = await bootstrap(tmpDir);
402
+ expect(result.ok).toBe(true);
403
+
404
+ const content = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
405
+ expect(content).toContain("MCP Tools");
406
+ expect(content).toContain("getContext");
407
+ expect(content).toContain("checkSlop");
408
+ expect(content).toContain("reviewCode");
409
+ expect(content).toContain("suggestTests");
410
+ expect(content).toContain("explainModule");
411
+ expect(content).toContain("analyzeFeature");
412
+ });
413
+
414
+ // ── aiGenerate option ─────────────────────────────────────────────────
415
+
416
+ test("aiGenerate defaults to false (backward compatible)", async () => {
417
+ const result = await bootstrap(tmpDir);
418
+ expect(result.ok).toBe(true);
419
+ if (result.ok) {
420
+ // Without aiGenerate, should not have AI-generated constitution
421
+ expect(result.value.aiGenerated).toBeFalsy();
422
+ }
423
+ });
424
+
425
+ test("aiGenerate falls back to static template when AI unavailable", async () => {
426
+ // No API key in env, AI will fail
427
+ const result = await bootstrap(tmpDir, { aiGenerate: true });
428
+ expect(result.ok).toBe(true);
429
+ if (result.ok) {
430
+ // Constitution should still be created (fallback to static)
431
+ expect(result.value.created).toContain(".maina/constitution.md");
432
+ const content = readFileSync(
433
+ join(tmpDir, ".maina", "constitution.md"),
434
+ "utf-8",
435
+ );
436
+ expect(content).toContain("# Project Constitution");
437
+ }
438
+ });
439
+
440
+ // ── Complete file manifest ────────────────────────────────────────────
441
+
442
+ test("creates all expected files in fresh directory", async () => {
443
+ const result = await bootstrap(tmpDir);
444
+ expect(result.ok).toBe(true);
445
+ if (result.ok) {
446
+ const expectedFiles = [
447
+ ".maina/constitution.md",
448
+ ".maina/prompts/review.md",
449
+ ".maina/prompts/commit.md",
450
+ "AGENTS.md",
451
+ ".github/workflows/maina-ci.yml",
452
+ ".github/copilot-instructions.md",
453
+ ".mcp.json",
454
+ "CLAUDE.md",
455
+ "GEMINI.md",
456
+ ".cursorrules",
457
+ ];
458
+ for (const f of expectedFiles) {
459
+ expect(result.value.created).toContain(f);
460
+ }
461
+ }
462
+ });
279
463
  });