@mainahq/core 0.6.0 → 1.0.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.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.6.0",
3
+ "version": "1.0.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",
7
- "homepage": "https://beeeku.github.io/maina/",
7
+ "homepage": "https://mainahq.com/",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/beeeku/maina.git",
10
+ "url": "https://github.com/mainahq/maina.git",
11
11
  "directory": "packages/core"
12
12
  },
13
13
  "engines": {
@@ -493,4 +493,172 @@ describe("createCloudClient", () => {
493
493
  expect(result.error).toBe("Job not found");
494
494
  }
495
495
  });
496
+
497
+ // ── postFeedbackBatch ─────────────────────────────────────────────────
498
+
499
+ test("postFeedbackBatch sends events in snake_case", async () => {
500
+ mockFetch.mockImplementation(() =>
501
+ Promise.resolve(jsonResponse({ data: { received: 3 } })),
502
+ );
503
+
504
+ const client = setupClient();
505
+ const result = await client.postFeedbackBatch([
506
+ {
507
+ promptHash: "hash-1",
508
+ command: "commit",
509
+ accepted: true,
510
+ timestamp: "2026-01-01T00:00:00Z",
511
+ },
512
+ {
513
+ promptHash: "hash-2",
514
+ command: "review",
515
+ accepted: false,
516
+ context: "user edited",
517
+ diffHash: "diff-abc",
518
+ },
519
+ {
520
+ promptHash: "hash-3",
521
+ command: "fix",
522
+ accepted: true,
523
+ },
524
+ ]);
525
+
526
+ expect(result.ok).toBe(true);
527
+ if (result.ok) {
528
+ expect(result.value.received).toBe(3);
529
+ }
530
+
531
+ const call = mockFetch.mock.calls[0] as unknown[];
532
+ const url = call[0] as string;
533
+ expect(url).toBe("https://api.test.maina.dev/feedback/batch");
534
+
535
+ const requestInit = call[1] as RequestInit;
536
+ expect(requestInit.method).toBe("POST");
537
+
538
+ const body = JSON.parse(requestInit.body as string);
539
+ expect(body.events).toHaveLength(3);
540
+ // Verify snake_case mapping
541
+ expect(body.events[0].prompt_hash).toBe("hash-1");
542
+ expect(body.events[0].command).toBe("commit");
543
+ expect(body.events[0].accepted).toBe(true);
544
+ expect(body.events[0].timestamp).toBe("2026-01-01T00:00:00Z");
545
+ expect(body.events[1].diff_hash).toBe("diff-abc");
546
+ expect(body.events[1].context).toBe("user edited");
547
+ });
548
+
549
+ test("postFeedbackBatch returns error on failure", async () => {
550
+ mockFetch.mockImplementation(() =>
551
+ Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
552
+ );
553
+
554
+ const client = setupClient();
555
+ const result = await client.postFeedbackBatch([]);
556
+
557
+ expect(result.ok).toBe(false);
558
+ if (!result.ok) {
559
+ expect(result.error).toBe("Unauthorized");
560
+ }
561
+ });
562
+
563
+ // ── getFeedbackImprovements ──────────────────────────────────────────
564
+
565
+ test("getFeedbackImprovements returns improvements with camelCase mapping", async () => {
566
+ mockFetch.mockImplementation(() =>
567
+ Promise.resolve(
568
+ jsonResponse({
569
+ data: {
570
+ improvements: [
571
+ {
572
+ command: "commit",
573
+ prompt_hash: "hash-abc",
574
+ samples: 50,
575
+ accept_rate: 0.85,
576
+ status: "healthy",
577
+ },
578
+ {
579
+ command: "review",
580
+ prompt_hash: "hash-def",
581
+ samples: 30,
582
+ accept_rate: 0.4,
583
+ status: "needs_improvement",
584
+ },
585
+ ],
586
+ team_totals: {
587
+ total_events: 200,
588
+ accept_rate: 0.72,
589
+ },
590
+ },
591
+ }),
592
+ ),
593
+ );
594
+
595
+ const client = setupClient();
596
+ const result = await client.getFeedbackImprovements();
597
+
598
+ expect(result.ok).toBe(true);
599
+ if (result.ok) {
600
+ expect(result.value.improvements).toHaveLength(2);
601
+ expect(result.value.improvements[0]?.command).toBe("commit");
602
+ expect(result.value.improvements[0]?.promptHash).toBe("hash-abc");
603
+ expect(result.value.improvements[0]?.samples).toBe(50);
604
+ expect(result.value.improvements[0]?.acceptRate).toBe(0.85);
605
+ expect(result.value.improvements[0]?.status).toBe("healthy");
606
+ expect(result.value.improvements[1]?.status).toBe("needs_improvement");
607
+ expect(result.value.teamTotals.totalEvents).toBe(200);
608
+ expect(result.value.teamTotals.acceptRate).toBe(0.72);
609
+ }
610
+
611
+ const call = mockFetch.mock.calls[0] as unknown[];
612
+ const url = call[0] as string;
613
+ expect(url).toBe("https://api.test.maina.dev/feedback/improvements");
614
+ });
615
+
616
+ test("getFeedbackImprovements handles camelCase response", async () => {
617
+ mockFetch.mockImplementation(() =>
618
+ Promise.resolve(
619
+ jsonResponse({
620
+ data: {
621
+ improvements: [
622
+ {
623
+ command: "fix",
624
+ promptHash: "hash-ghi",
625
+ samples: 10,
626
+ acceptRate: 0.95,
627
+ status: "excellent",
628
+ },
629
+ ],
630
+ teamTotals: {
631
+ totalEvents: 100,
632
+ acceptRate: 0.9,
633
+ },
634
+ },
635
+ }),
636
+ ),
637
+ );
638
+
639
+ const client = setupClient();
640
+ const result = await client.getFeedbackImprovements();
641
+
642
+ expect(result.ok).toBe(true);
643
+ if (result.ok) {
644
+ expect(result.value.improvements[0]?.promptHash).toBe("hash-ghi");
645
+ expect(result.value.improvements[0]?.acceptRate).toBe(0.95);
646
+ expect(result.value.teamTotals.totalEvents).toBe(100);
647
+ expect(result.value.teamTotals.acceptRate).toBe(0.9);
648
+ }
649
+ });
650
+
651
+ test("getFeedbackImprovements returns error on failure", async () => {
652
+ mockFetch.mockImplementation(() =>
653
+ Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
654
+ );
655
+
656
+ const client = setupClient();
657
+ const result = await client.getFeedbackImprovements();
658
+
659
+ expect(result.ok).toBe(false);
660
+ if (!result.ok) {
661
+ expect(result.error).toBe("Forbidden");
662
+ }
663
+ });
496
664
  });
@@ -10,6 +10,9 @@ import type {
10
10
  ApiResponse,
11
11
  CloudConfig,
12
12
  CloudFeedbackPayload,
13
+ CloudPromptImprovement,
14
+ FeedbackEvent,
15
+ FeedbackImprovementsResponse,
13
16
  PromptRecord,
14
17
  SubmitVerifyPayload,
15
18
  TeamInfo,
@@ -74,6 +77,16 @@ export interface CloudClient {
74
77
  payload: CloudFeedbackPayload,
75
78
  ): Promise<Result<{ recorded: boolean }, string>>;
76
79
 
80
+ /** Upload a batch of feedback events to the cloud. */
81
+ postFeedbackBatch(
82
+ events: FeedbackEvent[],
83
+ ): Promise<Result<{ received: number }, string>>;
84
+
85
+ /** Fetch feedback-based improvement suggestions from the cloud. */
86
+ getFeedbackImprovements(): Promise<
87
+ Result<FeedbackImprovementsResponse, string>
88
+ >;
89
+
77
90
  /** Submit a diff for cloud verification. */
78
91
  submitVerify(
79
92
  payload: SubmitVerifyPayload,
@@ -201,6 +214,47 @@ export function createCloudClient(config: CloudConfig): CloudClient {
201
214
  postFeedback: (payload) =>
202
215
  request<{ recorded: boolean }>("POST", "/feedback", payload),
203
216
 
217
+ postFeedbackBatch: async (events) => {
218
+ // Map camelCase → snake_case for cloud API
219
+ const snakeEvents = events.map((e) => ({
220
+ prompt_hash: e.promptHash,
221
+ command: e.command,
222
+ accepted: e.accepted,
223
+ context: e.context,
224
+ diff_hash: e.diffHash,
225
+ timestamp: e.timestamp,
226
+ }));
227
+ return request<{ received: number }>("POST", "/feedback/batch", {
228
+ events: snakeEvents,
229
+ });
230
+ },
231
+
232
+ getFeedbackImprovements: async () => {
233
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
234
+ const result = await request<any>("GET", "/feedback/improvements");
235
+ if (!result.ok) return result;
236
+ const d = result.value;
237
+ const rawImprovements = d.improvements ?? [];
238
+ const improvements: CloudPromptImprovement[] = rawImprovements.map(
239
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
240
+ (i: any) => ({
241
+ command: i.command,
242
+ promptHash: i.promptHash ?? i.prompt_hash,
243
+ samples: i.samples,
244
+ acceptRate: i.acceptRate ?? i.accept_rate,
245
+ status: i.status,
246
+ }),
247
+ );
248
+ const totals = d.teamTotals ?? d.team_totals ?? {};
249
+ return ok({
250
+ improvements,
251
+ teamTotals: {
252
+ totalEvents: totals.totalEvents ?? totals.total_events ?? 0,
253
+ acceptRate: totals.acceptRate ?? totals.accept_rate ?? 0,
254
+ },
255
+ });
256
+ },
257
+
204
258
  submitVerify: async (payload) => {
205
259
  // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
206
260
  const result = await request<any>("POST", "/verify", {
@@ -156,3 +156,45 @@ export interface CloudFeedbackPayload {
156
156
  /** Optional context about the feedback. */
157
157
  context?: string;
158
158
  }
159
+
160
+ // ── Feedback Batch (learn --cloud) ─────────────────────────────────────────
161
+
162
+ export interface FeedbackEvent {
163
+ /** Prompt hash the feedback refers to. */
164
+ promptHash: string;
165
+ /** Command that generated the output. */
166
+ command: string;
167
+ /** Whether the user accepted the output. */
168
+ accepted: boolean;
169
+ /** Optional context about the feedback. */
170
+ context?: string;
171
+ /** Optional diff hash for traceability. */
172
+ diffHash?: string;
173
+ /** ISO-8601 timestamp. */
174
+ timestamp?: string;
175
+ }
176
+
177
+ export interface FeedbackBatchPayload {
178
+ /** Array of feedback events to upload. */
179
+ events: FeedbackEvent[];
180
+ }
181
+
182
+ export interface CloudPromptImprovement {
183
+ /** Command the improvement applies to. */
184
+ command: string;
185
+ /** Prompt hash that was analysed. */
186
+ promptHash: string;
187
+ /** Number of feedback samples analysed. */
188
+ samples: number;
189
+ /** Accept rate (0–1). */
190
+ acceptRate: number;
191
+ /** Health status based on accept rate. */
192
+ status: "needs_improvement" | "healthy" | "excellent";
193
+ }
194
+
195
+ export interface FeedbackImprovementsResponse {
196
+ /** Per-command improvement assessments. */
197
+ improvements: CloudPromptImprovement[];
198
+ /** Aggregated team-wide feedback totals. */
199
+ teamTotals: { totalEvents: number; acceptRate: number };
200
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { recordOutcome } from "../../prompts/engine";
5
+ import { exportFeedbackForCloud } from "../sync";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = join(
11
+ import.meta.dir,
12
+ `tmp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13
+ );
14
+ mkdirSync(tmpDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ const { rmSync } = require("node:fs");
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ } catch {
22
+ // ignore
23
+ }
24
+ });
25
+
26
+ describe("exportFeedbackForCloud", () => {
27
+ test("returns empty array when no feedback exists", () => {
28
+ const events = exportFeedbackForCloud(tmpDir);
29
+ expect(events).toEqual([]);
30
+ });
31
+
32
+ test("exports accepted feedback events", () => {
33
+ recordOutcome(tmpDir, "hash-abc", {
34
+ accepted: true,
35
+ command: "commit",
36
+ });
37
+
38
+ const events = exportFeedbackForCloud(tmpDir);
39
+
40
+ expect(events).toHaveLength(1);
41
+ expect(events[0]?.promptHash).toBe("hash-abc");
42
+ expect(events[0]?.command).toBe("commit");
43
+ expect(events[0]?.accepted).toBe(true);
44
+ expect(events[0]?.timestamp).toBeDefined();
45
+ });
46
+
47
+ test("exports rejected feedback events", () => {
48
+ recordOutcome(tmpDir, "hash-def", {
49
+ accepted: false,
50
+ command: "review",
51
+ });
52
+
53
+ const events = exportFeedbackForCloud(tmpDir);
54
+
55
+ expect(events).toHaveLength(1);
56
+ expect(events[0]?.accepted).toBe(false);
57
+ expect(events[0]?.command).toBe("review");
58
+ });
59
+
60
+ test("exports context when present", () => {
61
+ recordOutcome(tmpDir, "hash-ctx", {
62
+ accepted: true,
63
+ command: "commit",
64
+ context: "user edited the message",
65
+ });
66
+
67
+ const events = exportFeedbackForCloud(tmpDir);
68
+
69
+ expect(events).toHaveLength(1);
70
+ expect(events[0]?.context).toBe("user edited the message");
71
+ });
72
+
73
+ test("omits context when not present", () => {
74
+ recordOutcome(tmpDir, "hash-no-ctx", {
75
+ accepted: true,
76
+ command: "commit",
77
+ });
78
+
79
+ const events = exportFeedbackForCloud(tmpDir);
80
+
81
+ expect(events).toHaveLength(1);
82
+ expect(events[0]?.context).toBeUndefined();
83
+ });
84
+
85
+ test("exports multiple events in chronological order", () => {
86
+ recordOutcome(tmpDir, "hash-1", { accepted: true, command: "commit" });
87
+ recordOutcome(tmpDir, "hash-2", { accepted: false, command: "review" });
88
+ recordOutcome(tmpDir, "hash-3", { accepted: true, command: "fix" });
89
+
90
+ const events = exportFeedbackForCloud(tmpDir);
91
+
92
+ expect(events).toHaveLength(3);
93
+ expect(events[0]?.promptHash).toBe("hash-1");
94
+ expect(events[1]?.promptHash).toBe("hash-2");
95
+ expect(events[2]?.promptHash).toBe("hash-3");
96
+ });
97
+
98
+ test("returns empty array on invalid db path", () => {
99
+ const events = exportFeedbackForCloud(
100
+ "/nonexistent/path/that/does/not/exist",
101
+ );
102
+ expect(events).toEqual([]);
103
+ });
104
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Feedback sync — exports local feedback records for cloud upload.
3
+ *
4
+ * Reads from the local SQLite feedback.db and maps records to the
5
+ * cloud-compatible FeedbackEvent format for batch upload.
6
+ */
7
+
8
+ import type { FeedbackEvent } from "../cloud/types";
9
+ import { getFeedbackDb } from "../db/index";
10
+
11
+ /** Raw row shape from the feedback table. */
12
+ interface FeedbackRow {
13
+ prompt_hash: string;
14
+ command: string;
15
+ accepted: number;
16
+ context: string | null;
17
+ created_at: string;
18
+ }
19
+
20
+ /**
21
+ * Export all local feedback records in the cloud-compatible format.
22
+ *
23
+ * Reads from the feedback table in the SQLite database at `mainaDir/feedback.db`
24
+ * and maps each row to a `FeedbackEvent` object ready for batch upload.
25
+ */
26
+ export function exportFeedbackForCloud(mainaDir: string): FeedbackEvent[] {
27
+ const dbResult = getFeedbackDb(mainaDir);
28
+ if (!dbResult.ok) {
29
+ return [];
30
+ }
31
+
32
+ const { db } = dbResult.value;
33
+
34
+ const rows = db
35
+ .query(
36
+ "SELECT prompt_hash, command, accepted, context, created_at FROM feedback ORDER BY created_at ASC",
37
+ )
38
+ .all() as FeedbackRow[];
39
+
40
+ return rows.map((row) => {
41
+ const event: FeedbackEvent = {
42
+ promptHash: row.prompt_hash,
43
+ command: row.command,
44
+ accepted: row.accepted === 1,
45
+ timestamp: row.created_at,
46
+ };
47
+
48
+ if (row.context) {
49
+ event.context = row.context;
50
+ }
51
+
52
+ return event;
53
+ });
54
+ }
package/src/index.ts CHANGED
@@ -64,7 +64,11 @@ export type {
64
64
  ApiResponse,
65
65
  CloudConfig,
66
66
  CloudFeedbackPayload,
67
+ CloudPromptImprovement,
67
68
  DeviceCodeResponse,
69
+ FeedbackBatchPayload,
70
+ FeedbackEvent,
71
+ FeedbackImprovementsResponse,
68
72
  PromptRecord,
69
73
  SubmitVerifyPayload,
70
74
  TeamInfo,
@@ -168,6 +172,7 @@ export {
168
172
  type RulePreference,
169
173
  savePreferences,
170
174
  } from "./feedback/preferences";
175
+ export { exportFeedbackForCloud } from "./feedback/sync";
171
176
  export {
172
177
  analyzeWorkflowTrace,
173
178
  type PromptImprovement,
package/src/init/index.ts CHANGED
@@ -177,7 +177,7 @@ function buildAgentsMd(stack: DetectedStack): string {
177
177
 
178
178
  return `# AGENTS.md
179
179
 
180
- This repo uses [Maina](https://github.com/beeeku/maina) for verification-first development.
180
+ This repo uses [Maina](https://github.com/mainahq/maina) for verification-first development.
181
181
 
182
182
  ## Quick Start
183
183
  \`\`\`bash
@@ -204,6 +204,7 @@ maina commit # verify + commit
204
204
  |------|---------|-----------|
205
205
  | \`.maina/constitution.md\` | Project DNA — stack, rules, gates | Team (stable, rarely changes) |
206
206
  | \`AGENTS.md\` | Agent instructions — commands, conventions | Team |
207
+ | \`.github/copilot-instructions.md\` | Copilot agent instructions + MCP tools | Team |
207
208
  | \`CLAUDE.md\` | Claude Code specific instructions | Optional, Claude Code users |
208
209
  | \`.maina/prompts/*.md\` | Prompt overrides for review/commit/etc | Maina (via \`maina learn\`) |
209
210
 
@@ -213,6 +214,45 @@ maina commit # verify + commit
213
214
  `;
214
215
  }
215
216
 
217
+ function buildCopilotInstructions(stack: DetectedStack): string {
218
+ const runCmd = stack.runtime === "bun" ? "bun" : "npm";
219
+ return `# Copilot Instructions
220
+
221
+ You are working on a codebase verified by [Maina](https://mainahq.com), the verification-first developer OS. Maina MCP tools are available — use them.
222
+
223
+ ## Workflow
224
+
225
+ 1. **Get context** — call \`maina getContext\` to understand codebase state
226
+ 2. **Write tests first** — TDD always. Write failing tests, then implement
227
+ 3. **Verify your work** — call \`maina verify\` before requesting review
228
+ 4. **Check for slop** — call \`maina checkSlop\` on changed files
229
+ 5. **Review your code** — call \`maina reviewCode\` with your diff
230
+
231
+ ## Available MCP Tools
232
+
233
+ | Tool | When to use |
234
+ |------|-------------|
235
+ | \`getContext\` | Before starting — understand branch state and verification status |
236
+ | \`verify\` | After changes — run the full verification pipeline |
237
+ | \`checkSlop\` | On changed files — detect AI-generated slop patterns |
238
+ | \`reviewCode\` | On your diff — two-stage review (spec compliance + code quality) |
239
+ | \`suggestTests\` | When implementing — generate TDD test stubs |
240
+ | \`getConventions\` | Understand project coding conventions |
241
+
242
+ ## Conventions
243
+
244
+ - Runtime: ${stack.runtime}
245
+ - Test: \`${runCmd} test\`
246
+ - Commits: conventional commits (feat, fix, refactor, test, docs, chore)
247
+ - No \`console.log\` in production code
248
+ - Diff-only: only fix issues on changed lines
249
+
250
+ ## When Working on Audit Issues
251
+
252
+ Issues labeled \`audit\` come from maina's daily verification. Fix the specific findings listed — don't refactor unrelated code.
253
+ `;
254
+ }
255
+
216
256
  const REVIEW_PROMPT_TEMPLATE = `# Review Prompt
217
257
 
218
258
  Review the following code changes for:
@@ -288,6 +328,10 @@ function getFileManifest(stack: DetectedStack): FileEntry[] {
288
328
  relativePath: ".github/workflows/maina-ci.yml",
289
329
  content: buildCiWorkflow(stack),
290
330
  },
331
+ {
332
+ relativePath: ".github/copilot-instructions.md",
333
+ content: buildCopilotInstructions(stack),
334
+ },
291
335
  ];
292
336
  }
293
337