@mainahq/core 1.0.2 → 1.1.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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/ai/__tests__/availability.test.ts +131 -0
  3. package/src/ai/__tests__/delegation.test.ts +55 -1
  4. package/src/ai/availability.ts +23 -0
  5. package/src/ai/delegation.ts +5 -3
  6. package/src/context/__tests__/budget.test.ts +29 -6
  7. package/src/context/__tests__/engine.test.ts +1 -0
  8. package/src/context/__tests__/selector.test.ts +23 -3
  9. package/src/context/__tests__/wiki.test.ts +349 -0
  10. package/src/context/budget.ts +12 -8
  11. package/src/context/engine.ts +37 -0
  12. package/src/context/selector.ts +30 -4
  13. package/src/context/wiki.ts +296 -0
  14. package/src/db/index.ts +12 -0
  15. package/src/feedback/__tests__/capture.test.ts +166 -0
  16. package/src/feedback/__tests__/signals.test.ts +144 -0
  17. package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
  18. package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
  19. package/src/feedback/capture.ts +102 -0
  20. package/src/feedback/signals.ts +68 -0
  21. package/src/index.ts +108 -1
  22. package/src/init/__tests__/init.test.ts +477 -18
  23. package/src/init/index.ts +419 -13
  24. package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
  25. package/src/prompts/defaults/index.ts +3 -1
  26. package/src/prompts/defaults/wiki-compile.md +20 -0
  27. package/src/prompts/defaults/wiki-query.md +18 -0
  28. package/src/stats/__tests__/tool-usage.test.ts +133 -0
  29. package/src/stats/tracker.ts +92 -0
  30. package/src/verify/__tests__/builtin.test.ts +270 -0
  31. package/src/verify/__tests__/pipeline.test.ts +11 -8
  32. package/src/verify/builtin.ts +350 -0
  33. package/src/verify/pipeline.ts +32 -2
  34. package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
  35. package/src/verify/tools/wiki-lint-runner.ts +38 -0
  36. package/src/verify/tools/wiki-lint.ts +898 -0
  37. package/src/wiki/__tests__/compiler.test.ts +389 -0
  38. package/src/wiki/__tests__/extractors/code.test.ts +99 -0
  39. package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
  40. package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
  41. package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
  42. package/src/wiki/__tests__/graph.test.ts +344 -0
  43. package/src/wiki/__tests__/hooks.test.ts +119 -0
  44. package/src/wiki/__tests__/indexer.test.ts +285 -0
  45. package/src/wiki/__tests__/linker.test.ts +230 -0
  46. package/src/wiki/__tests__/louvain.test.ts +229 -0
  47. package/src/wiki/__tests__/query.test.ts +316 -0
  48. package/src/wiki/__tests__/schema.test.ts +114 -0
  49. package/src/wiki/__tests__/signals.test.ts +474 -0
  50. package/src/wiki/__tests__/state.test.ts +168 -0
  51. package/src/wiki/__tests__/tracking.test.ts +118 -0
  52. package/src/wiki/__tests__/types.test.ts +387 -0
  53. package/src/wiki/compiler.ts +1075 -0
  54. package/src/wiki/extractors/code.ts +90 -0
  55. package/src/wiki/extractors/decision.ts +217 -0
  56. package/src/wiki/extractors/feature.ts +206 -0
  57. package/src/wiki/extractors/workflow.ts +112 -0
  58. package/src/wiki/graph.ts +445 -0
  59. package/src/wiki/hooks.ts +49 -0
  60. package/src/wiki/indexer.ts +105 -0
  61. package/src/wiki/linker.ts +117 -0
  62. package/src/wiki/louvain.ts +190 -0
  63. package/src/wiki/prompts/compile-architecture.md +59 -0
  64. package/src/wiki/prompts/compile-decision.md +66 -0
  65. package/src/wiki/prompts/compile-entity.md +56 -0
  66. package/src/wiki/prompts/compile-feature.md +60 -0
  67. package/src/wiki/prompts/compile-module.md +42 -0
  68. package/src/wiki/prompts/wiki-query.md +25 -0
  69. package/src/wiki/query.ts +338 -0
  70. package/src/wiki/schema.ts +111 -0
  71. package/src/wiki/signals.ts +368 -0
  72. package/src/wiki/state.ts +89 -0
  73. package/src/wiki/tracking.ts +30 -0
  74. package/src/wiki/types.ts +169 -0
  75. package/src/workflow/context.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { checkAIAvailability } from "../availability";
3
+
4
+ describe("checkAIAvailability", () => {
5
+ const originalEnv = { ...process.env };
6
+
7
+ beforeEach(() => {
8
+ // Clear all relevant env vars before each test
9
+ delete process.env.MAINA_API_KEY;
10
+ delete process.env.OPENROUTER_API_KEY;
11
+ delete process.env.ANTHROPIC_API_KEY;
12
+ delete process.env.MAINA_HOST_MODE;
13
+ delete process.env.CLAUDECODE;
14
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
15
+ delete process.env.CURSOR;
16
+ });
17
+
18
+ afterEach(() => {
19
+ // Restore original env
20
+ for (const key of [
21
+ "MAINA_API_KEY",
22
+ "OPENROUTER_API_KEY",
23
+ "ANTHROPIC_API_KEY",
24
+ "MAINA_HOST_MODE",
25
+ "CLAUDECODE",
26
+ "CLAUDE_CODE_ENTRYPOINT",
27
+ "CURSOR",
28
+ ]) {
29
+ if (originalEnv[key] !== undefined) {
30
+ process.env[key] = originalEnv[key];
31
+ } else {
32
+ delete process.env[key];
33
+ }
34
+ }
35
+ });
36
+
37
+ it("returns api-key method when MAINA_API_KEY is set", () => {
38
+ process.env.MAINA_API_KEY = "test-key-123";
39
+
40
+ const result = checkAIAvailability();
41
+
42
+ expect(result.available).toBe(true);
43
+ expect(result.method).toBe("api-key");
44
+ expect(result.reason).toBeUndefined();
45
+ });
46
+
47
+ it("returns api-key method when OPENROUTER_API_KEY is set", () => {
48
+ process.env.OPENROUTER_API_KEY = "or-key-456";
49
+
50
+ const result = checkAIAvailability();
51
+
52
+ expect(result.available).toBe(true);
53
+ expect(result.method).toBe("api-key");
54
+ expect(result.reason).toBeUndefined();
55
+ });
56
+
57
+ it("returns api-key method when ANTHROPIC_API_KEY is set", () => {
58
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
59
+
60
+ const result = checkAIAvailability();
61
+
62
+ expect(result.available).toBe(true);
63
+ expect(result.method).toBe("api-key");
64
+ expect(result.reason).toBeUndefined();
65
+ });
66
+
67
+ it("returns host-delegation when CLAUDECODE env is set", () => {
68
+ process.env.CLAUDECODE = "1";
69
+
70
+ const result = checkAIAvailability();
71
+
72
+ expect(result.available).toBe(true);
73
+ expect(result.method).toBe("host-delegation");
74
+ expect(result.reason).toBeUndefined();
75
+ });
76
+
77
+ it("returns host-delegation when CLAUDE_CODE_ENTRYPOINT is set", () => {
78
+ process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
79
+
80
+ const result = checkAIAvailability();
81
+
82
+ expect(result.available).toBe(true);
83
+ expect(result.method).toBe("host-delegation");
84
+ expect(result.reason).toBeUndefined();
85
+ });
86
+
87
+ it("returns host-delegation when CURSOR is set", () => {
88
+ process.env.CURSOR = "1";
89
+
90
+ const result = checkAIAvailability();
91
+
92
+ expect(result.available).toBe(true);
93
+ expect(result.method).toBe("host-delegation");
94
+ expect(result.reason).toBeUndefined();
95
+ });
96
+
97
+ it("returns host-delegation when MAINA_HOST_MODE is true", () => {
98
+ process.env.MAINA_HOST_MODE = "true";
99
+
100
+ const result = checkAIAvailability();
101
+
102
+ expect(result.available).toBe(true);
103
+ expect(result.method).toBe("host-delegation");
104
+ expect(result.reason).toBeUndefined();
105
+ });
106
+
107
+ it("returns none when no key and no host environment", () => {
108
+ const result = checkAIAvailability();
109
+
110
+ expect(result.available).toBe(false);
111
+ expect(result.method).toBe("none");
112
+ });
113
+
114
+ it("includes a reason message when method is none", () => {
115
+ const result = checkAIAvailability();
116
+
117
+ expect(result.reason).toBeDefined();
118
+ expect(result.reason).toContain("No API key found");
119
+ expect(result.reason).toContain("maina init");
120
+ });
121
+
122
+ it("prefers api-key over host-delegation when both available", () => {
123
+ process.env.MAINA_API_KEY = "test-key";
124
+ process.env.CLAUDECODE = "1";
125
+
126
+ const result = checkAIAvailability();
127
+
128
+ expect(result.available).toBe(true);
129
+ expect(result.method).toBe("api-key");
130
+ });
131
+ });
@@ -1,7 +1,8 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import {
3
3
  type DelegationRequest,
4
4
  formatDelegationRequest,
5
+ outputDelegationRequest,
5
6
  parseDelegationRequest,
6
7
  } from "../delegation";
7
8
 
@@ -103,3 +104,56 @@ Some output after`;
103
104
  expect(parsed?.prompt).toBe("hello");
104
105
  });
105
106
  });
107
+
108
+ describe("outputDelegationRequest", () => {
109
+ let stderrChunks: string[];
110
+ let stdoutChunks: string[];
111
+ let originalStderrWrite: typeof process.stderr.write;
112
+ let originalStdoutWrite: typeof process.stdout.write;
113
+
114
+ beforeEach(() => {
115
+ stderrChunks = [];
116
+ stdoutChunks = [];
117
+ originalStderrWrite = process.stderr.write;
118
+ originalStdoutWrite = process.stdout.write;
119
+
120
+ process.stderr.write = ((chunk: string | Uint8Array) => {
121
+ stderrChunks.push(
122
+ typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
123
+ );
124
+ return true;
125
+ }) as typeof process.stderr.write;
126
+
127
+ process.stdout.write = ((chunk: string | Uint8Array) => {
128
+ stdoutChunks.push(
129
+ typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
130
+ );
131
+ return true;
132
+ }) as typeof process.stdout.write;
133
+ });
134
+
135
+ afterEach(() => {
136
+ process.stderr.write = originalStderrWrite;
137
+ process.stdout.write = originalStdoutWrite;
138
+ });
139
+
140
+ it("writes to stderr, not stdout (prevents MCP protocol corruption)", () => {
141
+ const req: DelegationRequest = {
142
+ task: "review",
143
+ context: "test context",
144
+ prompt: "test prompt",
145
+ expectedFormat: "text",
146
+ };
147
+
148
+ outputDelegationRequest(req);
149
+
150
+ // Must write to stderr
151
+ expect(stderrChunks.length).toBeGreaterThan(0);
152
+ const stderrOutput = stderrChunks.join("");
153
+ expect(stderrOutput).toContain("---MAINA_AI_REQUEST---");
154
+ expect(stderrOutput).toContain("task: review");
155
+
156
+ // Must NOT write to stdout (stdout is reserved for JSON-RPC in MCP)
157
+ expect(stdoutChunks.length).toBe(0);
158
+ });
159
+ });
@@ -0,0 +1,23 @@
1
+ import { getApiKey, isHostMode } from "../config/index";
2
+
3
+ export interface AIAvailability {
4
+ available: boolean;
5
+ method: "api-key" | "host-delegation" | "none";
6
+ reason?: string;
7
+ }
8
+
9
+ export function checkAIAvailability(): AIAvailability {
10
+ const apiKey = getApiKey();
11
+ if (apiKey !== null) {
12
+ return { available: true, method: "api-key" };
13
+ }
14
+ if (isHostMode()) {
15
+ return { available: true, method: "host-delegation" };
16
+ }
17
+ return {
18
+ available: false,
19
+ method: "none",
20
+ reason:
21
+ "No API key found and not running inside an AI agent. Run `maina init` to set up or run inside Claude Code/Cursor.",
22
+ };
23
+ }
@@ -102,10 +102,12 @@ export function parseDelegationRequest(text: string): DelegationRequest | null {
102
102
  }
103
103
 
104
104
  /**
105
- * Output a delegation request to stdout.
106
- * Used by tryAIGenerate when in host mode.
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.
107
109
  */
108
110
  export function outputDelegationRequest(req: DelegationRequest): void {
109
111
  const formatted = formatDelegationRequest(req);
110
- process.stdout.write(`\n${formatted}\n`);
112
+ process.stderr.write(`\n${formatted}\n`);
111
113
  }
@@ -42,12 +42,13 @@ describe("getBudgetRatio", () => {
42
42
  describe("assembleBudget", () => {
43
43
  test("allocations sum correctly for default mode with default context window", () => {
44
44
  const allocation = assembleBudget("default");
45
- // working + episodic + semantic + retrieval + headroom === total
45
+ // working + episodic + semantic + retrieval + wiki + headroom === total
46
46
  const layerSum =
47
47
  allocation.working +
48
48
  allocation.episodic +
49
49
  allocation.semantic +
50
50
  allocation.retrieval +
51
+ allocation.wiki +
51
52
  allocation.headroom;
52
53
  expect(layerSum).toBe(allocation.total);
53
54
  });
@@ -75,7 +76,8 @@ describe("assembleBudget", () => {
75
76
  allocation.working +
76
77
  allocation.episodic +
77
78
  allocation.semantic +
78
- allocation.retrieval;
79
+ allocation.retrieval +
80
+ allocation.wiki;
79
81
  expect(layerSum).toBe(budget);
80
82
  });
81
83
 
@@ -83,15 +85,35 @@ describe("assembleBudget", () => {
83
85
  const modelContext = 200_000;
84
86
  const allocationDefault = assembleBudget("default", modelContext);
85
87
  const allocationFocused = assembleBudget("focused", modelContext);
86
- // working = ~25% of budget; for focused: budget = 0.4 * 200_000 = 80_000
87
- // working = floor(80_000 * 0.25) = 20_000
88
+ // working = ~12% of budget; for focused: budget = 0.4 * 200_000 = 80_000
89
+ // working = floor(80_000 * 0.12) = 9_600
88
90
  expect(allocationFocused.working).toBe(
89
- Math.floor(Math.floor(modelContext * 0.4) * 0.25),
91
+ Math.floor(Math.floor(modelContext * 0.4) * 0.12),
90
92
  );
91
93
  expect(allocationDefault.working).toBe(
92
- Math.floor(Math.floor(modelContext * 0.6) * 0.25),
94
+ Math.floor(Math.floor(modelContext * 0.6) * 0.12),
93
95
  );
94
96
  });
97
+
98
+ test("wiki allocation is ~12% of usable budget", () => {
99
+ const modelContext = 200_000;
100
+ const allocation = assembleBudget("default", modelContext);
101
+ const budget = Math.floor(modelContext * 0.6);
102
+ expect(allocation.wiki).toBe(Math.floor(budget * 0.12));
103
+ });
104
+
105
+ test("all 5 layers sum to budget for default mode", () => {
106
+ const modelContext = 200_000;
107
+ const allocation = assembleBudget("default", modelContext);
108
+ const budget = Math.floor(modelContext * 0.6);
109
+ const layerSum =
110
+ allocation.working +
111
+ allocation.episodic +
112
+ allocation.semantic +
113
+ allocation.retrieval +
114
+ allocation.wiki;
115
+ expect(layerSum).toBe(budget);
116
+ });
95
117
  });
96
118
 
97
119
  describe("truncateToFit", () => {
@@ -109,6 +131,7 @@ describe("truncateToFit", () => {
109
131
  semantic: 200,
110
132
  episodic: 300,
111
133
  retrieval: 400,
134
+ wiki: 0,
112
135
  headroom: 0,
113
136
  total: 1000,
114
137
  ...overrides,
@@ -156,6 +156,7 @@ describe("assembleContext", () => {
156
156
  expect(typeof result.budget.episodic).toBe("number");
157
157
  expect(typeof result.budget.semantic).toBe("number");
158
158
  expect(typeof result.budget.retrieval).toBe("number");
159
+ expect(typeof result.budget.wiki).toBe("number");
159
160
  expect(typeof result.budget.total).toBe("number");
160
161
  expect(typeof result.budget.headroom).toBe("number");
161
162
  expect(result.budget.total).toBeGreaterThan(0);
@@ -3,28 +3,31 @@ import type { BudgetMode } from "../budget.ts";
3
3
  import { getBudgetMode, getContextNeeds, needsLayer } from "../selector.ts";
4
4
 
5
5
  describe("getContextNeeds", () => {
6
- it("commit needs only working + conventions", () => {
6
+ it("commit needs only working + conventions + wiki", () => {
7
7
  const needs = getContextNeeds("commit");
8
8
  expect(needs.working).toBe(true);
9
9
  expect(needs.episodic).toBe(false);
10
10
  expect(needs.semantic).toEqual(["conventions"]);
11
11
  expect(needs.retrieval).toBe(false);
12
+ expect(needs.wiki).toBe(true);
12
13
  });
13
14
 
14
- it("context command needs all 4 layers", () => {
15
+ it("context command needs all 5 layers", () => {
15
16
  const needs = getContextNeeds("context");
16
17
  expect(needs.working).toBe(true);
17
18
  expect(needs.episodic).toBe(true);
18
19
  expect(needs.semantic).toBe(true);
19
20
  expect(needs.retrieval).toBe(true);
21
+ expect(needs.wiki).toBe(true);
20
22
  });
21
23
 
22
- it("verify needs working + recent-reviews + adrs + conventions", () => {
24
+ it("verify needs working + recent-reviews + adrs + conventions + wiki", () => {
23
25
  const needs = getContextNeeds("verify");
24
26
  expect(needs.working).toBe(true);
25
27
  expect(needs.episodic).toEqual(["recent-reviews"]);
26
28
  expect(needs.semantic).toEqual(["adrs", "conventions"]);
27
29
  expect(needs.retrieval).toBe(false);
30
+ expect(needs.wiki).toBe(true);
28
31
  });
29
32
 
30
33
  it("review returns correct context needs", () => {
@@ -33,6 +36,7 @@ describe("getContextNeeds", () => {
33
36
  expect(needs.episodic).toEqual(["past-reviews"]);
34
37
  expect(needs.semantic).toEqual(["adrs"]);
35
38
  expect(needs.retrieval).toBe(false);
39
+ expect(needs.wiki).toBe(true);
36
40
  });
37
41
 
38
42
  it("plan returns correct context needs", () => {
@@ -41,6 +45,7 @@ describe("getContextNeeds", () => {
41
45
  expect(needs.semantic).toEqual(["adrs", "conventions"]);
42
46
  expect(needs.episodic).toBe(false);
43
47
  expect(needs.retrieval).toBe(false);
48
+ expect(needs.wiki).toBe(true);
44
49
  });
45
50
 
46
51
  it("explain returns correct context needs", () => {
@@ -49,6 +54,7 @@ describe("getContextNeeds", () => {
49
54
  expect(needs.episodic).toBe(false);
50
55
  expect(needs.semantic).toBe(true);
51
56
  expect(needs.retrieval).toBe(true);
57
+ expect(needs.wiki).toBe(true);
52
58
  });
53
59
 
54
60
  it("design returns correct context needs", () => {
@@ -57,6 +63,7 @@ describe("getContextNeeds", () => {
57
63
  expect(needs.episodic).toBe(false);
58
64
  expect(needs.semantic).toEqual(["adrs"]);
59
65
  expect(needs.retrieval).toBe(false);
66
+ expect(needs.wiki).toBe(true);
60
67
  });
61
68
 
62
69
  it("ticket returns correct context needs", () => {
@@ -65,6 +72,7 @@ describe("getContextNeeds", () => {
65
72
  expect(needs.episodic).toBe(false);
66
73
  expect(needs.semantic).toEqual(["modules"]);
67
74
  expect(needs.retrieval).toBe(false);
75
+ expect(needs.wiki).toBe(false);
68
76
  });
69
77
 
70
78
  it("analyze returns correct context needs", () => {
@@ -73,6 +81,7 @@ describe("getContextNeeds", () => {
73
81
  expect(needs.episodic).toBe(true);
74
82
  expect(needs.semantic).toBe(true);
75
83
  expect(needs.retrieval).toBe(false);
84
+ expect(needs.wiki).toBe(true);
76
85
  });
77
86
 
78
87
  it("pr returns correct context needs", () => {
@@ -81,6 +90,7 @@ describe("getContextNeeds", () => {
81
90
  expect(needs.episodic).toEqual(["past-reviews"]);
82
91
  expect(needs.semantic).toBe(true);
83
92
  expect(needs.retrieval).toBe(true);
93
+ expect(needs.wiki).toBe(true);
84
94
  });
85
95
  });
86
96
 
@@ -129,6 +139,16 @@ describe("needsLayer", () => {
129
139
  const needs = getContextNeeds("commit");
130
140
  expect(needsLayer(needs, "retrieval")).toBe(false);
131
141
  });
142
+
143
+ it("correctly identifies when wiki layer is needed", () => {
144
+ const needs = getContextNeeds("commit");
145
+ expect(needsLayer(needs, "wiki")).toBe(true);
146
+ });
147
+
148
+ it("correctly identifies when wiki layer is not needed", () => {
149
+ const needs = getContextNeeds("ticket");
150
+ expect(needsLayer(needs, "wiki")).toBe(false);
151
+ });
132
152
  });
133
153
 
134
154
  describe("getBudgetMode", () => {