@mainahq/core 0.5.0 → 0.7.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -250,4 +250,415 @@ describe("createCloudClient", () => {
250
250
  expect(result.error).toContain("Network unreachable");
251
251
  }
252
252
  });
253
+
254
+ // ── submitVerify ─────────────────────────────────────────────────────────
255
+
256
+ test("submitVerify sends diff, repo, and base_branch", async () => {
257
+ mockFetch.mockImplementation(() =>
258
+ Promise.resolve(jsonResponse({ data: { jobId: "job-abc-123" } })),
259
+ );
260
+
261
+ const client = setupClient();
262
+ const result = await client.submitVerify({
263
+ diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new",
264
+ repo: "acme/app",
265
+ baseBranch: "main",
266
+ });
267
+
268
+ expect(result.ok).toBe(true);
269
+ if (result.ok) {
270
+ expect(result.value.jobId).toBe("job-abc-123");
271
+ }
272
+
273
+ const call = mockFetch.mock.calls[0] as unknown[];
274
+ const url = call[0] as string;
275
+ const requestInit = call[1] as RequestInit;
276
+ expect(url).toBe("https://api.test.maina.dev/verify");
277
+ expect(requestInit.method).toBe("POST");
278
+ const body = JSON.parse(requestInit.body as string);
279
+ expect(body.diff).toContain("+new");
280
+ expect(body.repo).toBe("acme/app");
281
+ expect(body.base_branch).toBe("main");
282
+ });
283
+
284
+ test("submitVerify omits base_branch when not provided", async () => {
285
+ mockFetch.mockImplementation(() =>
286
+ Promise.resolve(jsonResponse({ data: { jobId: "job-def-456" } })),
287
+ );
288
+
289
+ const client = setupClient();
290
+ await client.submitVerify({
291
+ diff: "some diff",
292
+ repo: "acme/app",
293
+ });
294
+
295
+ const call = mockFetch.mock.calls[0] as unknown[];
296
+ const requestInit = call[1] as RequestInit;
297
+ const body = JSON.parse(requestInit.body as string);
298
+ expect(body.base_branch).toBeUndefined();
299
+ });
300
+
301
+ test("submitVerify returns error on 401", async () => {
302
+ mockFetch.mockImplementation(() =>
303
+ Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
304
+ );
305
+
306
+ const client = setupClient();
307
+ const result = await client.submitVerify({
308
+ diff: "diff",
309
+ repo: "acme/app",
310
+ });
311
+
312
+ expect(result.ok).toBe(false);
313
+ if (!result.ok) {
314
+ expect(result.error).toBe("Unauthorized");
315
+ }
316
+ });
317
+
318
+ // ── getVerifyStatus ──────────────────────────────────────────────────────
319
+
320
+ test("getVerifyStatus returns queued status", async () => {
321
+ mockFetch.mockImplementation(() =>
322
+ Promise.resolve(
323
+ jsonResponse({
324
+ data: { status: "queued", currentStep: "Waiting in queue" },
325
+ }),
326
+ ),
327
+ );
328
+
329
+ const client = setupClient();
330
+ const result = await client.getVerifyStatus("job-abc-123");
331
+
332
+ expect(result.ok).toBe(true);
333
+ if (result.ok) {
334
+ expect(result.value.status).toBe("queued");
335
+ expect(result.value.currentStep).toBe("Waiting in queue");
336
+ }
337
+
338
+ const call = mockFetch.mock.calls[0] as unknown[];
339
+ const url = call[0] as string;
340
+ expect(url).toBe("https://api.test.maina.dev/verify/job-abc-123/status");
341
+ });
342
+
343
+ test("getVerifyStatus returns running status", async () => {
344
+ mockFetch.mockImplementation(() =>
345
+ Promise.resolve(
346
+ jsonResponse({
347
+ data: {
348
+ status: "running",
349
+ currentStep: "Running Biome lint",
350
+ },
351
+ }),
352
+ ),
353
+ );
354
+
355
+ const client = setupClient();
356
+ const result = await client.getVerifyStatus("job-abc-123");
357
+
358
+ expect(result.ok).toBe(true);
359
+ if (result.ok) {
360
+ expect(result.value.status).toBe("running");
361
+ expect(result.value.currentStep).toBe("Running Biome lint");
362
+ }
363
+ });
364
+
365
+ test("getVerifyStatus returns error on 404", async () => {
366
+ mockFetch.mockImplementation(() =>
367
+ Promise.resolve(jsonResponse({ error: "Job not found" }, 404)),
368
+ );
369
+
370
+ const client = setupClient();
371
+ const result = await client.getVerifyStatus("nonexistent");
372
+
373
+ expect(result.ok).toBe(false);
374
+ if (!result.ok) {
375
+ expect(result.error).toBe("Job not found");
376
+ }
377
+ });
378
+
379
+ test("getVerifyStatus retries on 500", async () => {
380
+ let attempts = 0;
381
+ mockFetch.mockImplementation(() => {
382
+ attempts++;
383
+ if (attempts === 1) {
384
+ return Promise.resolve(jsonResponse({ error: "Internal error" }, 500));
385
+ }
386
+ return Promise.resolve(
387
+ jsonResponse({
388
+ data: { status: "done", currentStep: "Complete" },
389
+ }),
390
+ );
391
+ });
392
+
393
+ const client = setupClient({ maxRetries: 2 });
394
+ const result = await client.getVerifyStatus("job-abc-123");
395
+
396
+ expect(result.ok).toBe(true);
397
+ expect(attempts).toBe(2);
398
+ });
399
+
400
+ // ── getVerifyResult ──────────────────────────────────────────────────────
401
+
402
+ test("getVerifyResult returns passing result", async () => {
403
+ const verifyResult = {
404
+ id: "job-abc-123",
405
+ status: "done",
406
+ passed: true,
407
+ findings: [],
408
+ findingsErrors: 0,
409
+ findingsWarnings: 0,
410
+ proofKey: "proof-xyz-789",
411
+ durationMs: 4523,
412
+ };
413
+ mockFetch.mockImplementation(() =>
414
+ Promise.resolve(jsonResponse({ data: verifyResult })),
415
+ );
416
+
417
+ const client = setupClient();
418
+ const result = await client.getVerifyResult("job-abc-123");
419
+
420
+ expect(result.ok).toBe(true);
421
+ if (result.ok) {
422
+ expect(result.value.id).toBe("job-abc-123");
423
+ expect(result.value.passed).toBe(true);
424
+ expect(result.value.findings).toHaveLength(0);
425
+ expect(result.value.proofKey).toBe("proof-xyz-789");
426
+ expect(result.value.durationMs).toBe(4523);
427
+ }
428
+
429
+ const call = mockFetch.mock.calls[0] as unknown[];
430
+ const url = call[0] as string;
431
+ expect(url).toBe("https://api.test.maina.dev/verify/job-abc-123");
432
+ });
433
+
434
+ test("getVerifyResult returns failing result with findings", async () => {
435
+ const verifyResult = {
436
+ id: "job-fail-456",
437
+ status: "failed",
438
+ passed: false,
439
+ findings: [
440
+ {
441
+ tool: "biome",
442
+ file: "src/index.ts",
443
+ line: 42,
444
+ message: "Unexpected console.log",
445
+ severity: "error",
446
+ ruleId: "no-console",
447
+ },
448
+ {
449
+ tool: "semgrep",
450
+ file: "src/utils.ts",
451
+ line: 10,
452
+ message: "Potential SQL injection",
453
+ severity: "warning",
454
+ },
455
+ ],
456
+ findingsErrors: 1,
457
+ findingsWarnings: 1,
458
+ proofKey: null,
459
+ durationMs: 3200,
460
+ };
461
+ mockFetch.mockImplementation(() =>
462
+ Promise.resolve(jsonResponse({ data: verifyResult })),
463
+ );
464
+
465
+ const client = setupClient();
466
+ const result = await client.getVerifyResult("job-fail-456");
467
+
468
+ expect(result.ok).toBe(true);
469
+ if (result.ok) {
470
+ expect(result.value.passed).toBe(false);
471
+ expect(result.value.findings).toHaveLength(2);
472
+ expect(result.value.findings[0]?.tool).toBe("biome");
473
+ expect(result.value.findings[0]?.severity).toBe("error");
474
+ expect(result.value.findings[0]?.ruleId).toBe("no-console");
475
+ expect(result.value.findings[1]?.tool).toBe("semgrep");
476
+ expect(result.value.findings[1]?.ruleId).toBeUndefined();
477
+ expect(result.value.findingsErrors).toBe(1);
478
+ expect(result.value.findingsWarnings).toBe(1);
479
+ expect(result.value.proofKey).toBeNull();
480
+ }
481
+ });
482
+
483
+ test("getVerifyResult returns error on 404", async () => {
484
+ mockFetch.mockImplementation(() =>
485
+ Promise.resolve(jsonResponse({ error: "Job not found" }, 404)),
486
+ );
487
+
488
+ const client = setupClient();
489
+ const result = await client.getVerifyResult("nonexistent");
490
+
491
+ expect(result.ok).toBe(false);
492
+ if (!result.ok) {
493
+ expect(result.error).toBe("Job not found");
494
+ }
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
+ });
253
664
  });
package/src/cloud/auth.ts CHANGED
@@ -144,7 +144,7 @@ export async function startDeviceFlow(
144
144
  }
145
145
 
146
146
  const body = (await response.json()) as {
147
- data?: DeviceCodeResponse;
147
+ data?: Record<string, unknown>;
148
148
  error?: string;
149
149
  };
150
150
  if (body.error) {
@@ -153,7 +153,14 @@ export async function startDeviceFlow(
153
153
  if (!body.data) {
154
154
  return err("Invalid response: missing data");
155
155
  }
156
- return ok(body.data);
156
+ const d = body.data;
157
+ return ok({
158
+ userCode: (d.userCode ?? d.user_code) as string,
159
+ deviceCode: (d.deviceCode ?? d.device_code) as string,
160
+ verificationUri: (d.verificationUri ?? d.verification_uri) as string,
161
+ interval: (d.interval ?? 5) as number,
162
+ expiresIn: (d.expiresIn ?? d.expires_in ?? 900) as number,
163
+ });
157
164
  } catch (e) {
158
165
  return err(
159
166
  `Device flow request failed: ${e instanceof Error ? e.message : String(e)}`,
@@ -188,8 +195,8 @@ export async function pollForToken(
188
195
  Accept: "application/json",
189
196
  },
190
197
  body: JSON.stringify({
191
- deviceCode,
192
- grantType: "urn:ietf:params:oauth:grant-type:device_code",
198
+ device_code: deviceCode,
199
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
193
200
  }),
194
201
  });
195
202
 
@@ -204,7 +211,7 @@ export async function pollForToken(
204
211
  }
205
212
 
206
213
  const body = (await response.json()) as {
207
- data?: TokenResponse;
214
+ data?: Record<string, unknown>;
208
215
  error?: string;
209
216
  };
210
217
  if (body.error) {
@@ -217,7 +224,12 @@ export async function pollForToken(
217
224
  if (!body.data) {
218
225
  return err("Invalid token response: missing data");
219
226
  }
220
- return ok(body.data);
227
+ const d = body.data;
228
+ return ok({
229
+ accessToken: (d.accessToken ?? d.access_token) as string,
230
+ refreshToken: (d.refreshToken ?? d.refresh_token) as string | undefined,
231
+ expiresIn: (d.expiresIn ?? d.expires_in ?? 0) as number,
232
+ });
221
233
  } catch (e) {
222
234
  // Network errors during polling are transient — keep trying
223
235
  if (Date.now() >= deadline) {
@@ -10,9 +10,15 @@ import type {
10
10
  ApiResponse,
11
11
  CloudConfig,
12
12
  CloudFeedbackPayload,
13
+ CloudPromptImprovement,
14
+ FeedbackEvent,
15
+ FeedbackImprovementsResponse,
13
16
  PromptRecord,
17
+ SubmitVerifyPayload,
14
18
  TeamInfo,
15
19
  TeamMember,
20
+ VerifyResultResponse,
21
+ VerifyStatusResponse,
16
22
  } from "./types";
17
23
 
18
24
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -70,6 +76,27 @@ export interface CloudClient {
70
76
  postFeedback(
71
77
  payload: CloudFeedbackPayload,
72
78
  ): Promise<Result<{ recorded: boolean }, string>>;
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
+
90
+ /** Submit a diff for cloud verification. */
91
+ submitVerify(
92
+ payload: SubmitVerifyPayload,
93
+ ): Promise<Result<{ jobId: string }, string>>;
94
+
95
+ /** Poll the status of a verification job. */
96
+ getVerifyStatus(jobId: string): Promise<Result<VerifyStatusResponse, string>>;
97
+
98
+ /** Retrieve the full result of a completed verification job. */
99
+ getVerifyResult(jobId: string): Promise<Result<VerifyResultResponse, string>>;
73
100
  }
74
101
 
75
102
  /**
@@ -186,5 +213,92 @@ export function createCloudClient(config: CloudConfig): CloudClient {
186
213
 
187
214
  postFeedback: (payload) =>
188
215
  request<{ recorded: boolean }>("POST", "/feedback", payload),
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
+
258
+ submitVerify: async (payload) => {
259
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
260
+ const result = await request<any>("POST", "/verify", {
261
+ diff: payload.diff,
262
+ repo: payload.repo,
263
+ base_branch: payload.baseBranch,
264
+ });
265
+ if (!result.ok) return result;
266
+ const d = result.value;
267
+ return ok({ jobId: d.jobId ?? d.job_id });
268
+ },
269
+
270
+ getVerifyStatus: async (jobId) => {
271
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
272
+ const result = await request<any>("GET", `/verify/${jobId}/status`);
273
+ if (!result.ok) return result;
274
+ const d = result.value;
275
+ return ok({
276
+ status: d.status,
277
+ currentStep: d.currentStep ?? d.current_step ?? d.step ?? "",
278
+ });
279
+ },
280
+
281
+ getVerifyResult: async (jobId) => {
282
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
283
+ const result = await request<any>("GET", `/verify/${jobId}`);
284
+ if (!result.ok) return result;
285
+ const d = result.value;
286
+ const items = d.findings?.items ?? d.findings ?? [];
287
+ return ok({
288
+ id: d.id,
289
+ status: d.status,
290
+ passed: d.passed,
291
+ findings: items,
292
+ findingsErrors:
293
+ d.findingsErrors ?? d.findings_errors ?? d.findings?.errors ?? 0,
294
+ findingsWarnings:
295
+ d.findingsWarnings ??
296
+ d.findings_warnings ??
297
+ d.findings?.warnings ??
298
+ 0,
299
+ proofKey: d.proofKey ?? d.proof_key ?? d.proof_url ?? null,
300
+ durationMs: d.durationMs ?? d.duration_ms ?? 0,
301
+ });
302
+ },
189
303
  };
190
304
  }
@@ -8,7 +8,7 @@
8
8
  // ── Configuration ───────────────────────────────────────────────────────────
9
9
 
10
10
  export interface CloudConfig {
11
- /** Base URL of the maina cloud API (e.g. "https://api.maina.dev"). */
11
+ /** Base URL of the maina cloud API (e.g. "https://api.mainahq.com"). */
12
12
  baseUrl: string;
13
13
  /** Bearer token for authenticated requests. */
14
14
  token?: string;
@@ -90,6 +90,58 @@ export interface ApiResponse<T> {
90
90
  meta?: Record<string, unknown>;
91
91
  }
92
92
 
93
+ // ── Verify ─────────────────────────────────────────────────────────────────
94
+
95
+ export interface SubmitVerifyPayload {
96
+ /** Diff content to verify. */
97
+ diff: string;
98
+ /** Repository identifier (e.g. "owner/repo"). */
99
+ repo: string;
100
+ /** Base branch to compare against. */
101
+ baseBranch?: string;
102
+ }
103
+
104
+ export interface VerifyStatusResponse {
105
+ /** Current job status. */
106
+ status: "queued" | "running" | "done" | "failed";
107
+ /** Human-readable description of the current step. */
108
+ currentStep: string;
109
+ }
110
+
111
+ export interface VerifyFinding {
112
+ /** Tool that produced the finding (e.g. "biome", "semgrep"). */
113
+ tool: string;
114
+ /** File path relative to repository root. */
115
+ file: string;
116
+ /** Line number in the file. */
117
+ line: number;
118
+ /** Finding description. */
119
+ message: string;
120
+ /** Severity level. */
121
+ severity: "error" | "warning" | "info";
122
+ /** Optional rule identifier. */
123
+ ruleId?: string;
124
+ }
125
+
126
+ export interface VerifyResultResponse {
127
+ /** Job identifier. */
128
+ id: string;
129
+ /** Final job status. */
130
+ status: "done" | "failed";
131
+ /** Whether all checks passed. */
132
+ passed: boolean;
133
+ /** Array of individual findings from verification tools. */
134
+ findings: VerifyFinding[];
135
+ /** Count of error-level findings. */
136
+ findingsErrors: number;
137
+ /** Count of warning-level findings. */
138
+ findingsWarnings: number;
139
+ /** Proof key for passing verification (null when failed). */
140
+ proofKey: string | null;
141
+ /** Total verification duration in milliseconds. */
142
+ durationMs: number;
143
+ }
144
+
93
145
  // ── Feedback ────────────────────────────────────────────────────────────────
94
146
 
95
147
  export interface CloudFeedbackPayload {
@@ -104,3 +156,45 @@ export interface CloudFeedbackPayload {
104
156
  /** Optional context about the feedback. */
105
157
  context?: string;
106
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,11 +64,19 @@ 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,
73
+ SubmitVerifyPayload,
69
74
  TeamInfo,
70
75
  TeamMember,
71
76
  TokenResponse,
77
+ VerifyFinding,
78
+ VerifyResultResponse,
79
+ VerifyStatusResponse,
72
80
  } from "./cloud/types";
73
81
  // Config
74
82
  export { getApiKey, isHostMode, shouldDelegateToHost } from "./config/index";
@@ -164,6 +172,7 @@ export {
164
172
  type RulePreference,
165
173
  savePreferences,
166
174
  } from "./feedback/preferences";
175
+ export { exportFeedbackForCloud } from "./feedback/sync";
167
176
  export {
168
177
  analyzeWorkflowTrace,
169
178
  type PromptImprovement,
@@ -306,16 +315,12 @@ export {
306
315
  runCoverage,
307
316
  } from "./verify/coverage";
308
317
  export {
309
- type DetectedTool,
310
318
  detectTool,
311
319
  detectTools,
312
320
  isToolAvailable,
313
321
  TOOL_REGISTRY,
314
- type ToolName,
315
322
  } from "./verify/detect";
316
323
  export {
317
- type DiffFilterResult,
318
- type Finding,
319
324
  filterByDiff,
320
325
  filterByDiffWithMap,
321
326
  parseChangedLines,
@@ -343,12 +348,7 @@ export {
343
348
  runMutation,
344
349
  } from "./verify/mutation";
345
350
  // Verify — Pipeline
346
- export {
347
- type PipelineOptions,
348
- type PipelineResult,
349
- runPipeline,
350
- type ToolReport,
351
- } from "./verify/pipeline";
351
+ export { runPipeline } from "./verify/pipeline";
352
352
  // Verify — Proof
353
353
  export {
354
354
  formatVerificationProof,
@@ -376,12 +376,22 @@ export {
376
376
  } from "./verify/sonar";
377
377
  export {
378
378
  parseBiomeOutput,
379
- type SyntaxDiagnostic,
380
- type SyntaxGuardResult,
381
379
  syntaxGuard,
382
380
  } from "./verify/syntax-guard";
383
381
  // Verify — Typecheck + Consistency (built-in checks)
384
382
  export { runTypecheck, type TypecheckResult } from "./verify/typecheck";
383
+ // Verify — Public Type Surface (consolidated for external consumers like maina-cloud)
384
+ export type {
385
+ DetectedTool,
386
+ DiffFilterResult,
387
+ Finding,
388
+ PipelineOptions,
389
+ PipelineResult,
390
+ SyntaxDiagnostic,
391
+ SyntaxGuardResult,
392
+ ToolName,
393
+ ToolReport,
394
+ } from "./verify/types";
385
395
  // Verify — Visual
386
396
  export {
387
397
  captureScreenshot,
@@ -0,0 +1 @@
1
+ {"packages": []}
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ DetectedTool,
4
+ Finding,
5
+ PipelineOptions,
6
+ PipelineResult,
7
+ ToolReport,
8
+ } from "../types";
9
+
10
+ // ─── Verify Type Exports ────────────────────────────────────────────────────
11
+
12
+ describe("verify/types re-exports", () => {
13
+ test("Finding type has expected shape", () => {
14
+ const finding: Finding = {
15
+ tool: "semgrep",
16
+ file: "src/index.ts",
17
+ line: 42,
18
+ message: "unused variable",
19
+ severity: "warning",
20
+ };
21
+ expect(finding.tool).toBe("semgrep");
22
+ expect(finding.file).toBe("src/index.ts");
23
+ expect(finding.line).toBe(42);
24
+ expect(finding.message).toBe("unused variable");
25
+ expect(finding.severity).toBe("warning");
26
+ });
27
+
28
+ test("Finding type supports optional fields", () => {
29
+ const finding: Finding = {
30
+ tool: "eslint",
31
+ file: "app.ts",
32
+ line: 10,
33
+ column: 5,
34
+ message: "no-unused-vars",
35
+ severity: "error",
36
+ ruleId: "no-unused-vars",
37
+ };
38
+ expect(finding.column).toBe(5);
39
+ expect(finding.ruleId).toBe("no-unused-vars");
40
+ });
41
+
42
+ test("ToolReport type has expected shape", () => {
43
+ const report: ToolReport = {
44
+ tool: "trivy",
45
+ findings: [],
46
+ skipped: false,
47
+ duration: 123,
48
+ };
49
+ expect(report.tool).toBe("trivy");
50
+ expect(report.findings).toEqual([]);
51
+ expect(report.skipped).toBe(false);
52
+ expect(report.duration).toBe(123);
53
+ });
54
+
55
+ test("PipelineResult type has expected shape", () => {
56
+ const result: PipelineResult = {
57
+ passed: true,
58
+ syntaxPassed: true,
59
+ tools: [],
60
+ findings: [],
61
+ hiddenCount: 0,
62
+ detectedTools: [],
63
+ duration: 500,
64
+ cacheHits: 2,
65
+ cacheMisses: 1,
66
+ };
67
+ expect(result.passed).toBe(true);
68
+ expect(result.syntaxPassed).toBe(true);
69
+ expect(result.tools).toEqual([]);
70
+ expect(result.findings).toEqual([]);
71
+ expect(result.hiddenCount).toBe(0);
72
+ expect(result.detectedTools).toEqual([]);
73
+ expect(result.duration).toBe(500);
74
+ expect(result.cacheHits).toBe(2);
75
+ expect(result.cacheMisses).toBe(1);
76
+ });
77
+
78
+ test("PipelineOptions type has expected shape", () => {
79
+ const opts: PipelineOptions = {
80
+ files: ["src/app.ts"],
81
+ baseBranch: "main",
82
+ diffOnly: true,
83
+ deep: false,
84
+ cwd: "/tmp",
85
+ mainaDir: ".maina",
86
+ languages: ["typescript"],
87
+ };
88
+ expect(opts.files).toEqual(["src/app.ts"]);
89
+ expect(opts.baseBranch).toBe("main");
90
+ expect(opts.diffOnly).toBe(true);
91
+ expect(opts.deep).toBe(false);
92
+ });
93
+
94
+ test("DetectedTool type has expected shape", () => {
95
+ const tool: DetectedTool = {
96
+ name: "biome",
97
+ command: "biome",
98
+ version: "1.5.0",
99
+ available: true,
100
+ };
101
+ expect(tool.name).toBe("biome");
102
+ expect(tool.command).toBe("biome");
103
+ expect(tool.version).toBe("1.5.0");
104
+ expect(tool.available).toBe(true);
105
+ });
106
+
107
+ test("DetectedTool supports null version", () => {
108
+ const tool: DetectedTool = {
109
+ name: "semgrep",
110
+ command: "semgrep",
111
+ version: null,
112
+ available: false,
113
+ };
114
+ expect(tool.version).toBeNull();
115
+ expect(tool.available).toBe(false);
116
+ });
117
+ });
118
+
119
+ // ─── Verify types re-exported from @mainahq/core index ──────────────────────
120
+
121
+ describe("verify types from core index", () => {
122
+ test("types are importable from core index", async () => {
123
+ const coreIndex = await import("../../index");
124
+ // The module should export these — we verify by checking the module loaded
125
+ // Type-only exports don't appear at runtime, but the module must resolve
126
+ expect(coreIndex).toBeDefined();
127
+ expect(typeof coreIndex.VERSION).toBe("string");
128
+ });
129
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Verify Engine — Public Type Exports.
3
+ *
4
+ * Consolidated re-exports of all verify types for the public API surface.
5
+ * Consumers (e.g. maina-cloud) import from here or from @mainahq/core.
6
+ * Types are NOT duplicated — each re-exports from its source module.
7
+ */
8
+
9
+ // DetectedTool, ToolName from detect
10
+ export type { DetectedTool, ToolName } from "./detect";
11
+ // Finding + DiffFilterResult from diff-filter
12
+ export type { DiffFilterResult, Finding } from "./diff-filter";
13
+ // PipelineResult, PipelineOptions, ToolReport from pipeline
14
+ export type { PipelineOptions, PipelineResult, ToolReport } from "./pipeline";
15
+
16
+ // SyntaxDiagnostic, SyntaxGuardResult from syntax-guard (used in PipelineResult)
17
+ export type { SyntaxDiagnostic, SyntaxGuardResult } from "./syntax-guard";