@mainahq/core 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@ai-sdk/openai": "^3.0.50",
34
+ "@orama/orama": "^3.1.18",
34
35
  "ai": "^6.0.145",
35
36
  "drizzle-orm": "^0.45.2"
36
37
  },
@@ -103,11 +103,22 @@ export function parseDelegationRequest(text: string): DelegationRequest | null {
103
103
 
104
104
  /**
105
105
  * Output a delegation request to stderr.
106
- * Uses stderr so that MCP stdio transport (which uses stdout for JSON-RPC)
107
- * is never corrupted by delegation text.
108
- * In CLI mode, stderr is still visible in the terminal.
106
+ * Only outputs when running inside an AI tool that can process it
107
+ * (detected via CLAUDE_CODE, CURSOR, or similar env vars).
108
+ * Silent in bare terminal to avoid confusing users.
109
109
  */
110
110
  export function outputDelegationRequest(req: DelegationRequest): void {
111
+ // Only output delegation when inside an AI tool that can process it
112
+ const inAITool =
113
+ process.env.CLAUDE_CODE === "1" ||
114
+ process.env.CLAUDE_PROJECT_DIR ||
115
+ process.env.CURSOR_TRACE_ID ||
116
+ process.env.CONTINUE_GLOBAL_DIR;
117
+
118
+ if (!inAITool) {
119
+ return;
120
+ }
121
+
111
122
  const formatted = formatDelegationRequest(req);
112
123
  process.stderr.write(`\n${formatted}\n`);
113
124
  }
@@ -244,7 +244,17 @@ export function createCloudClient(config: CloudConfig): CloudClient {
244
244
 
245
245
  getPrompts: () => request<PromptRecord[]>("GET", "/prompts"),
246
246
 
247
- putPrompts: (prompts) => request<void>("PUT", "/prompts", { prompts }),
247
+ putPrompts: async (prompts) => {
248
+ for (const p of prompts) {
249
+ const result = await request<void>("PUT", "/prompts", {
250
+ name: p.id ?? p.path?.replace(/\.md$/, "") ?? "unknown",
251
+ content: p.content,
252
+ hash: p.hash,
253
+ });
254
+ if (!result.ok) return result;
255
+ }
256
+ return { ok: true as const, value: undefined };
257
+ },
248
258
 
249
259
  getTeam: () => request<TeamInfo>("GET", "/team"),
250
260
 
package/src/index.ts CHANGED
@@ -444,6 +444,15 @@ export {
444
444
  type CompileOptions as WikiCompileOptions,
445
445
  compile as compileWiki,
446
446
  } from "./wiki/compiler";
447
+ // Wiki — Consult
448
+ export {
449
+ consultWikiForBrainstorm,
450
+ consultWikiForDesign,
451
+ consultWikiForPlan,
452
+ type WikiBrainstormContext,
453
+ type WikiConsultResult,
454
+ type WikiDesignConsultResult,
455
+ } from "./wiki/consult";
447
456
  export { type CodeEntity, extractCodeEntities } from "./wiki/extractors/code";
448
457
  export {
449
458
  extractDecisions,
@@ -486,6 +495,15 @@ export {
486
495
  validateArticleStructure,
487
496
  type WikiSchema,
488
497
  } from "./wiki/schema";
498
+ // Wiki — Search
499
+ export {
500
+ buildSearchIndex,
501
+ loadSearchIndex,
502
+ saveSearchIndex,
503
+ searchWiki,
504
+ type WikiSearchIndex,
505
+ type WikiSearchResult,
506
+ } from "./wiki/search";
489
507
  // Wiki — Signals
490
508
  export {
491
509
  type ArticleLoadSignal,
package/src/init/index.ts CHANGED
@@ -1063,6 +1063,13 @@ async function tryGenerateConstitution(
1063
1063
  stack: DetectedStack,
1064
1064
  ): Promise<string | null> {
1065
1065
  try {
1066
+ // Only attempt AI generation if we have an actual API key.
1067
+ // Host delegation just dumps prompts to stderr which confuses users.
1068
+ const { getApiKey } = await import("../config/index");
1069
+ if (!getApiKey()) {
1070
+ return null;
1071
+ }
1072
+
1066
1073
  const { tryAIGenerate } = await import("../ai/try-generate");
1067
1074
  const mainaDir = join(repoRoot, ".maina");
1068
1075
  const summary = buildProjectSummary(repoRoot, stack);
@@ -7,6 +7,9 @@
7
7
  * Deterministic checks only — no AI calls.
8
8
  */
9
9
 
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
10
13
  // ── Types ────────────────────────────────────────────────────────────────────
11
14
 
12
15
  export interface ReviewStageResult {
@@ -28,6 +31,8 @@ export interface ReviewOptions {
28
31
  planContent?: string | null;
29
32
  conventions?: string | null;
30
33
  mainaDir?: string; // enables AI review when provided
34
+ /** Accepted ADR summaries for spec compliance checking */
35
+ decisionSummaries?: string[] | null;
31
36
  }
32
37
 
33
38
  export interface ReviewResult {
@@ -171,6 +176,7 @@ function extractAddedLines(
171
176
  export function reviewSpecCompliance(
172
177
  diff: string,
173
178
  planContent: string | null,
179
+ decisionSummaries?: string[] | null,
174
180
  ): ReviewStageResult {
175
181
  const findings: ReviewFinding[] = [];
176
182
 
@@ -229,6 +235,34 @@ export function reviewSpecCompliance(
229
235
  }
230
236
  }
231
237
 
238
+ // Check if changes align with accepted ADRs
239
+ if (decisionSummaries && decisionSummaries.length > 0) {
240
+ const addedLines = extractAddedLines(diff);
241
+ const addedText = addedLines.map((l) => l.text.toLowerCase()).join(" ");
242
+
243
+ for (const summary of decisionSummaries) {
244
+ // Extract tool/technology mentions from ADR summary
245
+ const summaryLower = summary.toLowerCase();
246
+ // Check for contradictions: if ADR mentions tool A but added code uses conflicting tool B
247
+ const knownConflicts: Array<[string, string]> = [
248
+ ["biome", "eslint"],
249
+ ["biome", "prettier"],
250
+ ["bun:test", "jest"],
251
+ ["bun:test", "vitest"],
252
+ ];
253
+
254
+ for (const [preferred, rejected] of knownConflicts) {
255
+ if (summaryLower.includes(preferred) && addedText.includes(rejected)) {
256
+ findings.push({
257
+ stage: "spec-compliance",
258
+ severity: "warning",
259
+ message: `ADR requires ${preferred} but added code references ${rejected}: "${summary.slice(0, 80)}"`,
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+
232
266
  const hasWarningsOrErrors = findings.some(
233
267
  (f) => f.severity === "warning" || f.severity === "error",
234
268
  );
@@ -376,6 +410,51 @@ export async function reviewCodeQualityWithAI(
376
410
  return deterministicResult;
377
411
  }
378
412
 
413
+ // ── Decision Loader ─────────────────────────────────────────────────────────
414
+
415
+ /**
416
+ * Load accepted ADR summaries from the wiki decisions directory.
417
+ * Returns an array of one-line summaries for each accepted decision.
418
+ */
419
+ function loadDecisionSummaries(mainaDir: string): string[] | null {
420
+ const decisionsDir = join(mainaDir, "wiki", "decisions");
421
+ if (!existsSync(decisionsDir)) return null;
422
+
423
+ let entries: string[];
424
+ try {
425
+ entries = readdirSync(decisionsDir);
426
+ } catch {
427
+ return null;
428
+ }
429
+
430
+ const summaries: string[] = [];
431
+ for (const entry of entries) {
432
+ if (!entry.endsWith(".md")) continue;
433
+ try {
434
+ const content = readFileSync(join(decisionsDir, entry), "utf-8");
435
+ const statusMatch = content.match(/>\s*Status:\s*\*\*(\w+)\*\*/);
436
+ const status = statusMatch?.[1] ?? "";
437
+ if (status !== "accepted") continue;
438
+
439
+ const titleMatch = content.match(/^#\s+(.+)/);
440
+ const title =
441
+ titleMatch?.[1]?.replace(/^Decision:\s*/i, "").trim() ?? entry;
442
+
443
+ // Extract key constraint from decision section
444
+ const decisionMatch = content.match(
445
+ /## Decision\n\n([\s\S]*?)(?=\n## |\n---|$)/,
446
+ );
447
+ const decision = decisionMatch?.[1]?.trim().split("\n")[0] ?? "";
448
+
449
+ summaries.push(`${title}: ${decision}`);
450
+ } catch {
451
+ // skip unreadable files
452
+ }
453
+ }
454
+
455
+ return summaries.length > 0 ? summaries : null;
456
+ }
457
+
379
458
  // ── Two-Stage Review ────────────────────────────────────────────────────────
380
459
 
381
460
  /**
@@ -388,9 +467,16 @@ export async function reviewCodeQualityWithAI(
388
467
  export async function runTwoStageReview(
389
468
  options: ReviewOptions,
390
469
  ): Promise<ReviewResult> {
470
+ // Load decision summaries from wiki if mainaDir is provided
471
+ let decisionSummaries = options.decisionSummaries ?? null;
472
+ if (!decisionSummaries && options.mainaDir) {
473
+ decisionSummaries = loadDecisionSummaries(options.mainaDir);
474
+ }
475
+
391
476
  const stage1 = reviewSpecCompliance(
392
477
  options.diff,
393
478
  options.planContent ?? null,
479
+ decisionSummaries,
394
480
  );
395
481
 
396
482
  if (!stage1.passed) {
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Tests for wiki consult — pre-command wiki consultation.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import {
9
+ consultWikiForBrainstorm,
10
+ consultWikiForDesign,
11
+ consultWikiForPlan,
12
+ } from "../consult";
13
+
14
+ // ── Test Helpers ────────────────────────────────────────────────────────────
15
+
16
+ let tmpDir: string;
17
+ let wikiDir: string;
18
+
19
+ function createTmpDir(): string {
20
+ const dir = join(
21
+ import.meta.dir,
22
+ `tmp-consult-${Date.now()}-${Math.random().toString(36).slice(2)}`,
23
+ );
24
+ mkdirSync(dir, { recursive: true });
25
+ return dir;
26
+ }
27
+
28
+ function seedWiki(wiki: string): void {
29
+ const subdirs = [
30
+ "modules",
31
+ "entities",
32
+ "features",
33
+ "decisions",
34
+ "architecture",
35
+ ];
36
+ for (const subdir of subdirs) {
37
+ mkdirSync(join(wiki, subdir), { recursive: true });
38
+ }
39
+
40
+ // Module: verify with 11 entities
41
+ writeFileSync(
42
+ join(wiki, "modules", "verify.md"),
43
+ [
44
+ "# Module: verify",
45
+ "",
46
+ "> Auto-generated module article for `verify`.",
47
+ "",
48
+ "## Entities",
49
+ "",
50
+ "- **runPipeline** (function) — `verify/pipeline.ts:10`",
51
+ "- **syntaxGuard** (function) — `verify/syntax.ts:5`",
52
+ "- **diffFilter** (function) — `verify/diff.ts:1`",
53
+ "- **slopDetector** (function) — `verify/slop.ts:8`",
54
+ "- **reviewCode** (function) — `verify/review.ts:3`",
55
+ "- **PipelineResult** (interface) — `verify/types.ts:1`",
56
+ "- **Finding** (interface) — `verify/types.ts:20`",
57
+ "- **ToolRunner** (interface) — `verify/types.ts:40`",
58
+ "- **SyntaxError** (interface) — `verify/types.ts:60`",
59
+ "- **DiffFilter** (interface) — `verify/types.ts:80`",
60
+ "- **SlopPattern** (interface) — `verify/types.ts:100`",
61
+ ].join("\n"),
62
+ );
63
+
64
+ // Module: context with 8 entities
65
+ writeFileSync(
66
+ join(wiki, "modules", "context.md"),
67
+ [
68
+ "# Module: context",
69
+ "",
70
+ "> Auto-generated module article for `context`.",
71
+ "",
72
+ "## Entities",
73
+ "",
74
+ "- **assembleContext** (function) — `context/engine.ts:10`",
75
+ "- **parseFile** (function) — `context/treesitter.ts:5`",
76
+ "- **extractEntities** (function) — `context/treesitter.ts:50`",
77
+ "- **ContextResult** (interface) — `context/types.ts:1`",
78
+ "- **TokenBudget** (interface) — `context/budget.ts:1`",
79
+ "- **SemanticLayer** (class) — `context/semantic.ts:10`",
80
+ "- **EpisodicLayer** (class) — `context/episodic.ts:10`",
81
+ "- **WorkingLayer** (class) — `context/working.ts:10`",
82
+ ].join("\n"),
83
+ );
84
+
85
+ // Decision: accepted — multi-language with Biome
86
+ writeFileSync(
87
+ join(wiki, "decisions", "0002-multi-language-verify-pipeline.md"),
88
+ [
89
+ "# Decision: Multi-language verify pipeline",
90
+ "",
91
+ "> Status: **accepted**",
92
+ "",
93
+ "## Context",
94
+ "",
95
+ "Maina needs multi-language support for the verify pipeline.",
96
+ "",
97
+ "## Decision",
98
+ "",
99
+ "Use Biome for TypeScript linting. Use ruff for Python. Use clippy for Rust.",
100
+ "Introduce LanguageProfile abstraction for each supported language.",
101
+ ].join("\n"),
102
+ );
103
+
104
+ // Decision: proposed — spec quality
105
+ writeFileSync(
106
+ join(wiki, "decisions", "0001-spec-quality.md"),
107
+ [
108
+ "# Decision: Spec quality system",
109
+ "",
110
+ "> Status: **proposed**",
111
+ "",
112
+ "## Context",
113
+ "",
114
+ "Specifications need quality scoring to prevent bad specs.",
115
+ "",
116
+ "## Decision",
117
+ "",
118
+ "Build a spec scoring system with measurability and testability checks.",
119
+ ].join("\n"),
120
+ );
121
+
122
+ // Feature: similar to hardening
123
+ writeFileSync(
124
+ join(wiki, "features", "024-v03x-hardening.md"),
125
+ [
126
+ "# Feature: Implementation Plan — v0.3.x Hardening",
127
+ "",
128
+ "## Status",
129
+ "",
130
+ "Verify pipeline improvements, gap fixes, RL loop integration.",
131
+ ].join("\n"),
132
+ );
133
+
134
+ // Feature: benchmark
135
+ writeFileSync(
136
+ join(wiki, "features", "010-benchmark-harness.md"),
137
+ [
138
+ "# Feature: Benchmark harness",
139
+ "",
140
+ "## Status",
141
+ "",
142
+ "Full lifecycle benchmark comparison framework.",
143
+ ].join("\n"),
144
+ );
145
+
146
+ // Architecture
147
+ writeFileSync(
148
+ join(wiki, "architecture", "three-engines.md"),
149
+ [
150
+ "# Architecture: Three Engines",
151
+ "",
152
+ "Maina uses three engines: Context, Prompt, and Verify.",
153
+ "Context observes, Prompt learns, Verify verifies.",
154
+ ].join("\n"),
155
+ );
156
+
157
+ writeFileSync(
158
+ join(wiki, "architecture", "verification-pipeline.md"),
159
+ [
160
+ "# Architecture: Verification Pipeline",
161
+ "",
162
+ "The verify pipeline has multiple stages: syntax guard, parallel tools, diff filter, AI fix, review.",
163
+ ].join("\n"),
164
+ );
165
+ }
166
+
167
+ beforeEach(() => {
168
+ tmpDir = createTmpDir();
169
+ wikiDir = join(tmpDir, "wiki");
170
+ });
171
+
172
+ afterEach(() => {
173
+ try {
174
+ rmSync(tmpDir, { recursive: true, force: true });
175
+ } catch {
176
+ // ignore
177
+ }
178
+ });
179
+
180
+ // ── consultWikiForPlan ─────────────────────────────────────────────────────
181
+
182
+ describe("consultWikiForPlan", () => {
183
+ test("finds related modules by keyword", () => {
184
+ seedWiki(wikiDir);
185
+ const result = consultWikiForPlan(
186
+ wikiDir,
187
+ "improve the verify pipeline syntax checking",
188
+ );
189
+
190
+ expect(result.relatedModules.length).toBeGreaterThan(0);
191
+ const verifyMod = result.relatedModules.find((m) => m.name === "verify");
192
+ expect(verifyMod).toBeDefined();
193
+ expect(verifyMod?.entities).toBe(11);
194
+ });
195
+
196
+ test("returns suggestions for modules with many entities", () => {
197
+ seedWiki(wikiDir);
198
+ const result = consultWikiForPlan(
199
+ wikiDir,
200
+ "improve the verify pipeline syntax checking",
201
+ );
202
+
203
+ const extendSuggestion = result.suggestions.find((s) =>
204
+ s.includes("consider extending"),
205
+ );
206
+ expect(extendSuggestion).toBeDefined();
207
+ expect(extendSuggestion).toContain("verify");
208
+ });
209
+
210
+ test("finds related decisions", () => {
211
+ seedWiki(wikiDir);
212
+ const result = consultWikiForPlan(
213
+ wikiDir,
214
+ "add multi-language linting support",
215
+ );
216
+
217
+ expect(result.relatedDecisions.length).toBeGreaterThan(0);
218
+ const multiLang = result.relatedDecisions.find(
219
+ (d) => d.id === "0002-multi-language-verify-pipeline",
220
+ );
221
+ expect(multiLang).toBeDefined();
222
+ expect(multiLang?.status).toBe("accepted");
223
+ });
224
+
225
+ test("finds related features", () => {
226
+ seedWiki(wikiDir);
227
+ const result = consultWikiForPlan(
228
+ wikiDir,
229
+ "hardening improvements for verify gaps",
230
+ );
231
+
232
+ expect(result.relatedFeatures.length).toBeGreaterThan(0);
233
+ const hardening = result.relatedFeatures.find((f) => f.id.includes("024"));
234
+ expect(hardening).toBeDefined();
235
+ });
236
+
237
+ test("generates feature similarity suggestions", () => {
238
+ seedWiki(wikiDir);
239
+ const result = consultWikiForPlan(
240
+ wikiDir,
241
+ "hardening improvements for verify gaps",
242
+ );
243
+
244
+ const featureSuggestion = result.suggestions.find((s) =>
245
+ s.includes("did something similar"),
246
+ );
247
+ expect(featureSuggestion).toBeDefined();
248
+ });
249
+
250
+ test("handles missing wiki gracefully", () => {
251
+ const result = consultWikiForPlan("/nonexistent/wiki", "some feature");
252
+ expect(result.relatedModules).toEqual([]);
253
+ expect(result.relatedDecisions).toEqual([]);
254
+ expect(result.relatedFeatures).toEqual([]);
255
+ expect(result.suggestions).toEqual([]);
256
+ });
257
+
258
+ test("handles empty description", () => {
259
+ seedWiki(wikiDir);
260
+ const result = consultWikiForPlan(wikiDir, "");
261
+ expect(result.relatedModules).toEqual([]);
262
+ });
263
+ });
264
+
265
+ // ── consultWikiForDesign ───────────────────────────────────────────────────
266
+
267
+ describe("consultWikiForDesign", () => {
268
+ test("detects conflicts with existing ADRs", () => {
269
+ seedWiki(wikiDir);
270
+ const result = consultWikiForDesign(
271
+ wikiDir,
272
+ "Use ESLint for linting TypeScript code instead of current tools",
273
+ );
274
+
275
+ expect(result.conflicts.length).toBeGreaterThan(0);
276
+ const biomeConflict = result.conflicts.find((c) =>
277
+ c.reason.includes("biome"),
278
+ );
279
+ expect(biomeConflict).toBeDefined();
280
+ });
281
+
282
+ test("detects alignments with existing ADRs", () => {
283
+ seedWiki(wikiDir);
284
+ const result = consultWikiForDesign(
285
+ wikiDir,
286
+ "Extend the verify pipeline to support multi-language linting with ruff",
287
+ );
288
+
289
+ expect(result.alignments.length).toBeGreaterThan(0);
290
+ });
291
+
292
+ test("handles missing wiki gracefully", () => {
293
+ const result = consultWikiForDesign("/nonexistent/wiki", "some decision");
294
+ expect(result.conflicts).toEqual([]);
295
+ expect(result.alignments).toEqual([]);
296
+ });
297
+
298
+ test("handles empty proposal", () => {
299
+ seedWiki(wikiDir);
300
+ const result = consultWikiForDesign(wikiDir, "");
301
+ expect(result.conflicts).toEqual([]);
302
+ expect(result.alignments).toEqual([]);
303
+ });
304
+ });
305
+
306
+ // ── consultWikiForBrainstorm ───────────────────────────────────────────────
307
+
308
+ describe("consultWikiForBrainstorm", () => {
309
+ test("loads architecture context", () => {
310
+ seedWiki(wikiDir);
311
+ const result = consultWikiForBrainstorm(wikiDir);
312
+
313
+ expect(result.architecture).toContain("Three Engines");
314
+ expect(result.architecture).toContain("Verification Pipeline");
315
+ });
316
+
317
+ test("counts modules and decisions", () => {
318
+ seedWiki(wikiDir);
319
+ const result = consultWikiForBrainstorm(wikiDir);
320
+
321
+ expect(result.moduleCount).toBe(2);
322
+ expect(result.decisionCount).toBe(2);
323
+ });
324
+
325
+ test("loads recent features", () => {
326
+ seedWiki(wikiDir);
327
+ const result = consultWikiForBrainstorm(wikiDir);
328
+
329
+ expect(result.recentFeatures.length).toBe(2);
330
+ // Sorted descending by ID, so 024 comes first
331
+ expect(result.recentFeatures[0]).toContain("024");
332
+ });
333
+
334
+ test("handles missing wiki gracefully", () => {
335
+ const result = consultWikiForBrainstorm("/nonexistent/wiki");
336
+ expect(result.architecture).toBe("");
337
+ expect(result.moduleCount).toBe(0);
338
+ expect(result.decisionCount).toBe(0);
339
+ expect(result.recentFeatures).toEqual([]);
340
+ });
341
+ });