@mainahq/core 0.3.0 → 0.4.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 +4 -1
- package/src/cloud/__tests__/auth.test.ts +164 -0
- package/src/cloud/__tests__/client.test.ts +253 -0
- package/src/cloud/auth.ts +232 -0
- package/src/cloud/client.ts +190 -0
- package/src/cloud/types.ts +106 -0
- package/src/feedback/__tests__/trace-analysis.test.ts +98 -0
- package/src/feedback/trace-analysis.ts +153 -0
- package/src/index.ts +46 -0
- package/src/init/__tests__/init.test.ts +51 -0
- package/src/init/index.ts +43 -0
- package/src/language/__tests__/detect.test.ts +61 -1
- package/src/language/__tests__/profile.test.ts +53 -1
- package/src/language/detect.ts +18 -2
- package/src/language/profile.ts +24 -2
- package/src/ticket/index.ts +5 -0
- package/src/verify/__tests__/consistency.test.ts +98 -0
- package/src/verify/__tests__/lighthouse.test.ts +215 -0
- package/src/verify/__tests__/pipeline.test.ts +21 -2
- package/src/verify/__tests__/typecheck.test.ts +160 -0
- package/src/verify/__tests__/zap.test.ts +188 -0
- package/src/verify/consistency.ts +199 -0
- package/src/verify/detect.ts +5 -1
- package/src/verify/lighthouse.ts +173 -0
- package/src/verify/pipeline.ts +20 -2
- package/src/verify/typecheck.ts +178 -0
- package/src/verify/zap.ts +189 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Provides authenticated access to the maina cloud API for prompt sync,
|
|
5
|
+
* team management, and feedback reporting. All methods return Result<T, string>.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Result } from "../db/index";
|
|
9
|
+
import type {
|
|
10
|
+
ApiResponse,
|
|
11
|
+
CloudConfig,
|
|
12
|
+
CloudFeedbackPayload,
|
|
13
|
+
PromptRecord,
|
|
14
|
+
TeamInfo,
|
|
15
|
+
TeamMember,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function ok<T>(value: T): Result<T, string> {
|
|
21
|
+
return { ok: true, value };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function err(error: string): Result<never, string> {
|
|
25
|
+
return { ok: false, error };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
29
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
30
|
+
const INITIAL_BACKOFF_MS = 500;
|
|
31
|
+
|
|
32
|
+
/** Status codes that trigger a retry. */
|
|
33
|
+
function isRetryable(status: number): boolean {
|
|
34
|
+
return status === 429 || status >= 500;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sleep for the given number of milliseconds.
|
|
39
|
+
* Extracted so tests can verify backoff behaviour.
|
|
40
|
+
*/
|
|
41
|
+
function sleep(ms: number): Promise<void> {
|
|
42
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Cloud Client ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface CloudClient {
|
|
48
|
+
/** Check API availability. */
|
|
49
|
+
health(): Promise<Result<{ status: string }, string>>;
|
|
50
|
+
|
|
51
|
+
/** Download team prompts. */
|
|
52
|
+
getPrompts(): Promise<Result<PromptRecord[], string>>;
|
|
53
|
+
|
|
54
|
+
/** Upload local prompts. */
|
|
55
|
+
putPrompts(prompts: PromptRecord[]): Promise<Result<void, string>>;
|
|
56
|
+
|
|
57
|
+
/** Fetch team information. */
|
|
58
|
+
getTeam(): Promise<Result<TeamInfo, string>>;
|
|
59
|
+
|
|
60
|
+
/** List team members. */
|
|
61
|
+
getTeamMembers(): Promise<Result<TeamMember[], string>>;
|
|
62
|
+
|
|
63
|
+
/** Invite a new member by email. */
|
|
64
|
+
inviteTeamMember(
|
|
65
|
+
email: string,
|
|
66
|
+
role?: "admin" | "member",
|
|
67
|
+
): Promise<Result<{ invited: boolean }, string>>;
|
|
68
|
+
|
|
69
|
+
/** Report prompt feedback to the cloud. */
|
|
70
|
+
postFeedback(
|
|
71
|
+
payload: CloudFeedbackPayload,
|
|
72
|
+
): Promise<Result<{ recorded: boolean }, string>>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a cloud API client.
|
|
77
|
+
*
|
|
78
|
+
* Every request attaches `Authorization: Bearer <token>` when a token is
|
|
79
|
+
* present in the config. Transient failures (429, 5xx) are retried up to
|
|
80
|
+
* `maxRetries` times with exponential backoff.
|
|
81
|
+
*/
|
|
82
|
+
export function createCloudClient(config: CloudConfig): CloudClient {
|
|
83
|
+
const {
|
|
84
|
+
baseUrl,
|
|
85
|
+
token,
|
|
86
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
87
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
88
|
+
} = config;
|
|
89
|
+
|
|
90
|
+
/** Build standard headers. */
|
|
91
|
+
function headers(): Record<string, string> {
|
|
92
|
+
const h: Record<string, string> = {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
Accept: "application/json",
|
|
95
|
+
};
|
|
96
|
+
if (token) {
|
|
97
|
+
h.Authorization = `Bearer ${token}`;
|
|
98
|
+
}
|
|
99
|
+
return h;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Make an HTTP request with retry + timeout. */
|
|
103
|
+
async function request<T>(
|
|
104
|
+
method: string,
|
|
105
|
+
path: string,
|
|
106
|
+
body?: unknown,
|
|
107
|
+
): Promise<Result<T, string>> {
|
|
108
|
+
let lastError = "Unknown error";
|
|
109
|
+
|
|
110
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
111
|
+
if (attempt > 0) {
|
|
112
|
+
const backoff = INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
|
|
113
|
+
await sleep(backoff);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
119
|
+
|
|
120
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
121
|
+
method,
|
|
122
|
+
headers: headers(),
|
|
123
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
|
|
129
|
+
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
130
|
+
lastError = `HTTP ${response.status}`;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
let message: string;
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(text) as ApiResponse<unknown>;
|
|
139
|
+
message = parsed.error ?? `HTTP ${response.status}`;
|
|
140
|
+
} catch {
|
|
141
|
+
message = text || `HTTP ${response.status}`;
|
|
142
|
+
}
|
|
143
|
+
return err(message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 204 No Content — return void-compatible
|
|
147
|
+
if (response.status === 204) {
|
|
148
|
+
return ok(undefined as unknown as T);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const json = (await response.json()) as ApiResponse<T>;
|
|
152
|
+
if (json.error) {
|
|
153
|
+
return err(json.error);
|
|
154
|
+
}
|
|
155
|
+
return ok(json.data as T);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (
|
|
158
|
+
e instanceof DOMException &&
|
|
159
|
+
e.name === "AbortError" &&
|
|
160
|
+
attempt < maxRetries
|
|
161
|
+
) {
|
|
162
|
+
lastError = "Request timed out";
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
166
|
+
// Transient network errors are retried via the outer loop
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return err(`Request failed after ${maxRetries + 1} attempts: ${lastError}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
health: () => request<{ status: string }>("GET", "/health"),
|
|
175
|
+
|
|
176
|
+
getPrompts: () => request<PromptRecord[]>("GET", "/prompts"),
|
|
177
|
+
|
|
178
|
+
putPrompts: (prompts) => request<void>("PUT", "/prompts", { prompts }),
|
|
179
|
+
|
|
180
|
+
getTeam: () => request<TeamInfo>("GET", "/team"),
|
|
181
|
+
|
|
182
|
+
getTeamMembers: () => request<TeamMember[]>("GET", "/team/members"),
|
|
183
|
+
|
|
184
|
+
inviteTeamMember: (email, role = "member") =>
|
|
185
|
+
request<{ invited: boolean }>("POST", "/team/invite", { email, role }),
|
|
186
|
+
|
|
187
|
+
postFeedback: (payload) =>
|
|
188
|
+
request<{ recorded: boolean }>("POST", "/feedback", payload),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud client shared types.
|
|
3
|
+
*
|
|
4
|
+
* Used by the cloud HTTP client, auth module, and CLI commands
|
|
5
|
+
* for syncing prompts, team management, and device-flow auth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface CloudConfig {
|
|
11
|
+
/** Base URL of the maina cloud API (e.g. "https://api.maina.dev"). */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** Bearer token for authenticated requests. */
|
|
14
|
+
token?: string;
|
|
15
|
+
/** Request timeout in milliseconds. Default: 10_000. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Maximum retry attempts for transient failures. Default: 3. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Prompts ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface PromptRecord {
|
|
24
|
+
/** Unique prompt identifier. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** File path relative to .maina/prompts/ (e.g. "commit.md"). */
|
|
27
|
+
path: string;
|
|
28
|
+
/** Full prompt content (markdown). */
|
|
29
|
+
content: string;
|
|
30
|
+
/** SHA-256 hash of the content for change detection. */
|
|
31
|
+
hash: string;
|
|
32
|
+
/** ISO-8601 timestamp of last modification. */
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Team ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface TeamInfo {
|
|
39
|
+
/** Team identifier. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Display name. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** Current billing plan. */
|
|
44
|
+
plan: string;
|
|
45
|
+
/** Number of seats used / total. */
|
|
46
|
+
seats: { used: number; total: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TeamMember {
|
|
50
|
+
/** User email. */
|
|
51
|
+
email: string;
|
|
52
|
+
/** Role within the team. */
|
|
53
|
+
role: "owner" | "admin" | "member";
|
|
54
|
+
/** ISO-8601 join date. */
|
|
55
|
+
joinedAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Device-Code OAuth ───────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface DeviceCodeResponse {
|
|
61
|
+
/** The code the user enters on the verification page. */
|
|
62
|
+
userCode: string;
|
|
63
|
+
/** The device code used to poll for token completion. */
|
|
64
|
+
deviceCode: string;
|
|
65
|
+
/** URL the user should visit. */
|
|
66
|
+
verificationUri: string;
|
|
67
|
+
/** Polling interval in seconds. */
|
|
68
|
+
interval: number;
|
|
69
|
+
/** Seconds until the device code expires. */
|
|
70
|
+
expiresIn: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TokenResponse {
|
|
74
|
+
/** Bearer access token. */
|
|
75
|
+
accessToken: string;
|
|
76
|
+
/** Refresh token (if applicable). */
|
|
77
|
+
refreshToken?: string;
|
|
78
|
+
/** Seconds until the access token expires. */
|
|
79
|
+
expiresIn: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── API Envelope ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface ApiResponse<T> {
|
|
85
|
+
/** Response payload (present on success). */
|
|
86
|
+
data?: T;
|
|
87
|
+
/** Error message (present on failure). */
|
|
88
|
+
error?: string;
|
|
89
|
+
/** Optional metadata. */
|
|
90
|
+
meta?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Feedback ────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface CloudFeedbackPayload {
|
|
96
|
+
/** Prompt hash the feedback refers to. */
|
|
97
|
+
promptHash: string;
|
|
98
|
+
/** Command that generated the output. */
|
|
99
|
+
command: string;
|
|
100
|
+
/** Whether the user accepted the output. */
|
|
101
|
+
accepted: boolean;
|
|
102
|
+
/** ISO-8601 timestamp. */
|
|
103
|
+
timestamp: string;
|
|
104
|
+
/** Optional context about the feedback. */
|
|
105
|
+
context?: string;
|
|
106
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for post-workflow RL trace analysis.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that analyzeWorkflowTrace() reads workflow context,
|
|
5
|
+
* correlates with feedback data, and generates prompt improvements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
describe("analyzeWorkflowTrace", () => {
|
|
14
|
+
let mainaDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mainaDir = join(tmpdir(), `maina-trace-test-${Date.now()}`);
|
|
18
|
+
mkdirSync(join(mainaDir, "workflow"), { recursive: true });
|
|
19
|
+
mkdirSync(join(mainaDir, "prompts"), { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
rmSync(mainaDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should parse workflow context into trace steps", async () => {
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(mainaDir, "workflow", "current.md"),
|
|
29
|
+
[
|
|
30
|
+
"# Workflow: feature/test",
|
|
31
|
+
"",
|
|
32
|
+
"## plan (2026-04-05T10:00:00.000Z)",
|
|
33
|
+
"Feature scaffolded.",
|
|
34
|
+
"",
|
|
35
|
+
"## commit (2026-04-05T10:05:00.000Z)",
|
|
36
|
+
"Verified: 8 tools, 0 findings. Committed.",
|
|
37
|
+
].join("\n"),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const { analyzeWorkflowTrace } = await import("../trace-analysis");
|
|
41
|
+
const result = await analyzeWorkflowTrace(mainaDir);
|
|
42
|
+
|
|
43
|
+
expect(result.steps).toHaveLength(2);
|
|
44
|
+
expect(result.steps[0]?.command).toBe("plan");
|
|
45
|
+
expect(result.steps[1]?.command).toBe("commit");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should return empty improvements when no workflow exists", async () => {
|
|
49
|
+
rmSync(join(mainaDir, "workflow"), { recursive: true, force: true });
|
|
50
|
+
|
|
51
|
+
const { analyzeWorkflowTrace } = await import("../trace-analysis");
|
|
52
|
+
const result = await analyzeWorkflowTrace(mainaDir);
|
|
53
|
+
|
|
54
|
+
expect(result.steps).toEqual([]);
|
|
55
|
+
expect(result.improvements).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should generate improvements from trace patterns", async () => {
|
|
59
|
+
writeFileSync(
|
|
60
|
+
join(mainaDir, "workflow", "current.md"),
|
|
61
|
+
[
|
|
62
|
+
"# Workflow: feature/test",
|
|
63
|
+
"",
|
|
64
|
+
"## commit (2026-04-05T10:00:00.000Z)",
|
|
65
|
+
"Verified: 8 tools, 2 findings. Committed.",
|
|
66
|
+
"",
|
|
67
|
+
"## commit (2026-04-05T10:05:00.000Z)",
|
|
68
|
+
"Verified: 8 tools, 3 findings. Committed.",
|
|
69
|
+
"",
|
|
70
|
+
"## commit (2026-04-05T10:10:00.000Z)",
|
|
71
|
+
"Verified: 8 tools, 0 findings. Committed.",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const { analyzeWorkflowTrace } = await import("../trace-analysis");
|
|
76
|
+
const result = await analyzeWorkflowTrace(mainaDir);
|
|
77
|
+
|
|
78
|
+
expect(result.steps).toHaveLength(3);
|
|
79
|
+
// Should detect that early commits had findings, suggesting improvement
|
|
80
|
+
expect(typeof result.summary).toBe("string");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should return TraceResult with correct shape", async () => {
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(mainaDir, "workflow", "current.md"),
|
|
86
|
+
"# Workflow: test\n",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const { analyzeWorkflowTrace } = await import("../trace-analysis");
|
|
90
|
+
const result = await analyzeWorkflowTrace(mainaDir);
|
|
91
|
+
|
|
92
|
+
expect(result).toHaveProperty("steps");
|
|
93
|
+
expect(result).toHaveProperty("improvements");
|
|
94
|
+
expect(result).toHaveProperty("summary");
|
|
95
|
+
expect(Array.isArray(result.steps)).toBe(true);
|
|
96
|
+
expect(Array.isArray(result.improvements)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-workflow RL Trace Analysis — analyzes completed workflow traces
|
|
3
|
+
* to propose prompt improvements.
|
|
4
|
+
*
|
|
5
|
+
* After a full workflow completes (brainstorm → ... → pr):
|
|
6
|
+
* 1. Collects the full trace from workflow context
|
|
7
|
+
* 2. Analyzes: which steps had issues? How did findings trend?
|
|
8
|
+
* 3. Proposes prompt improvements based on patterns
|
|
9
|
+
* 4. Feeds into maina learn automatically
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface TraceStep {
|
|
18
|
+
command: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
findingsCount?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PromptImprovement {
|
|
25
|
+
promptFile: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
suggestion: string;
|
|
28
|
+
confidence: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TraceResult {
|
|
32
|
+
steps: TraceStep[];
|
|
33
|
+
improvements: PromptImprovement[];
|
|
34
|
+
summary: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Trace Parsing ───────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the workflow context file into structured trace steps.
|
|
41
|
+
*/
|
|
42
|
+
function parseWorkflowContext(content: string): TraceStep[] {
|
|
43
|
+
const steps: TraceStep[] = [];
|
|
44
|
+
const stepPattern =
|
|
45
|
+
/^## (\w+) \((\d{4}-\d{2}-\d{2}T[\d:.]+Z)\)\s*\n([\s\S]*?)(?=\n## |\n*$)/gm;
|
|
46
|
+
|
|
47
|
+
for (const match of content.matchAll(stepPattern)) {
|
|
48
|
+
const command = match[1] ?? "";
|
|
49
|
+
const timestamp = match[2] ?? "";
|
|
50
|
+
const summary = (match[3] ?? "").trim();
|
|
51
|
+
|
|
52
|
+
// Extract findings count if present
|
|
53
|
+
const findingsMatch = summary.match(/(\d+)\s+findings?/);
|
|
54
|
+
const findingsCount = findingsMatch
|
|
55
|
+
? Number.parseInt(findingsMatch[1] ?? "0", 10)
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
steps.push({ command, timestamp, summary, findingsCount });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return steps;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Analysis ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Analyze trace steps for patterns that suggest prompt improvements.
|
|
68
|
+
*/
|
|
69
|
+
function analyzePatterns(steps: TraceStep[]): PromptImprovement[] {
|
|
70
|
+
const improvements: PromptImprovement[] = [];
|
|
71
|
+
|
|
72
|
+
// Pattern 1: Multiple commits with findings before a clean one
|
|
73
|
+
const commitSteps = steps.filter(
|
|
74
|
+
(s) => s.command === "commit" && s.findingsCount !== undefined,
|
|
75
|
+
);
|
|
76
|
+
const dirtyCommits = commitSteps.filter(
|
|
77
|
+
(s) => s.findingsCount !== undefined && s.findingsCount > 0,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (dirtyCommits.length >= 2) {
|
|
81
|
+
improvements.push({
|
|
82
|
+
promptFile: "prompts/review.md",
|
|
83
|
+
reason: `${dirtyCommits.length} commits had verification findings before clean pass — review prompt may need to catch these patterns earlier.`,
|
|
84
|
+
suggestion:
|
|
85
|
+
"Add examples of common finding patterns to the review prompt so AI catches them in the first pass.",
|
|
86
|
+
confidence: 0.6,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Pattern 2: Workflow has no review step
|
|
91
|
+
const hasReview = steps.some((s) => s.command === "review");
|
|
92
|
+
if (steps.length >= 3 && !hasReview) {
|
|
93
|
+
improvements.push({
|
|
94
|
+
promptFile: "prompts/commit.md",
|
|
95
|
+
reason:
|
|
96
|
+
"Workflow completed without a review step — commit prompt could remind about review.",
|
|
97
|
+
suggestion:
|
|
98
|
+
"Add a reminder to run maina review before committing when changes are substantial.",
|
|
99
|
+
confidence: 0.4,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return improvements;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generate a human-readable summary of the trace analysis.
|
|
108
|
+
*/
|
|
109
|
+
function generateSummary(
|
|
110
|
+
steps: TraceStep[],
|
|
111
|
+
improvements: PromptImprovement[],
|
|
112
|
+
): string {
|
|
113
|
+
if (steps.length === 0) return "No workflow trace found.";
|
|
114
|
+
|
|
115
|
+
const commands = steps.map((s) => s.command).join(" → ");
|
|
116
|
+
const totalFindings = steps
|
|
117
|
+
.filter((s) => s.findingsCount !== undefined)
|
|
118
|
+
.reduce((sum, s) => sum + (s.findingsCount ?? 0), 0);
|
|
119
|
+
|
|
120
|
+
let summary = `Workflow: ${commands} (${steps.length} steps, ${totalFindings} total findings)`;
|
|
121
|
+
|
|
122
|
+
if (improvements.length > 0) {
|
|
123
|
+
summary += `\n${improvements.length} improvement(s) suggested.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return summary;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Main ────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Analyze the current workflow trace and generate improvement proposals.
|
|
133
|
+
*
|
|
134
|
+
* This runs after maina pr completes. It reads the workflow context,
|
|
135
|
+
* correlates with feedback data, and proposes prompt improvements
|
|
136
|
+
* that are automatically fed into maina learn.
|
|
137
|
+
*/
|
|
138
|
+
export async function analyzeWorkflowTrace(
|
|
139
|
+
mainaDir: string,
|
|
140
|
+
): Promise<TraceResult> {
|
|
141
|
+
const workflowFile = join(mainaDir, "workflow", "current.md");
|
|
142
|
+
|
|
143
|
+
if (!existsSync(workflowFile)) {
|
|
144
|
+
return { steps: [], improvements: [], summary: "No workflow trace found." };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const content = readFileSync(workflowFile, "utf-8");
|
|
148
|
+
const steps = parseWorkflowContext(content);
|
|
149
|
+
const improvements = analyzePatterns(steps);
|
|
150
|
+
const summary = generateSummary(steps, improvements);
|
|
151
|
+
|
|
152
|
+
return { steps, improvements, summary };
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,26 @@ export {
|
|
|
50
50
|
type CacheStats,
|
|
51
51
|
createCacheManager,
|
|
52
52
|
} from "./cache/manager";
|
|
53
|
+
// Cloud
|
|
54
|
+
export {
|
|
55
|
+
type AuthConfig,
|
|
56
|
+
clearAuthConfig,
|
|
57
|
+
loadAuthConfig,
|
|
58
|
+
pollForToken,
|
|
59
|
+
saveAuthConfig,
|
|
60
|
+
startDeviceFlow,
|
|
61
|
+
} from "./cloud/auth";
|
|
62
|
+
export { type CloudClient, createCloudClient } from "./cloud/client";
|
|
63
|
+
export type {
|
|
64
|
+
ApiResponse,
|
|
65
|
+
CloudConfig,
|
|
66
|
+
CloudFeedbackPayload,
|
|
67
|
+
DeviceCodeResponse,
|
|
68
|
+
PromptRecord,
|
|
69
|
+
TeamInfo,
|
|
70
|
+
TeamMember,
|
|
71
|
+
TokenResponse,
|
|
72
|
+
} from "./cloud/types";
|
|
53
73
|
// Config
|
|
54
74
|
export { getApiKey, isHostMode, shouldDelegateToHost } from "./config/index";
|
|
55
75
|
export { calculateTokens } from "./context/budget";
|
|
@@ -144,6 +164,12 @@ export {
|
|
|
144
164
|
type RulePreference,
|
|
145
165
|
savePreferences,
|
|
146
166
|
} from "./feedback/preferences";
|
|
167
|
+
export {
|
|
168
|
+
analyzeWorkflowTrace,
|
|
169
|
+
type PromptImprovement,
|
|
170
|
+
type TraceResult,
|
|
171
|
+
type TraceStep,
|
|
172
|
+
} from "./feedback/trace-analysis";
|
|
147
173
|
// Git
|
|
148
174
|
export {
|
|
149
175
|
type Commit,
|
|
@@ -268,6 +294,10 @@ export {
|
|
|
268
294
|
resolveReferencedFunctions,
|
|
269
295
|
runAIReview,
|
|
270
296
|
} from "./verify/ai-review";
|
|
297
|
+
export {
|
|
298
|
+
type ConsistencyResult,
|
|
299
|
+
checkConsistency,
|
|
300
|
+
} from "./verify/consistency";
|
|
271
301
|
// Verify — Coverage
|
|
272
302
|
export {
|
|
273
303
|
type CoverageOptions,
|
|
@@ -298,6 +328,13 @@ export {
|
|
|
298
328
|
hashFinding,
|
|
299
329
|
parseFixResponse,
|
|
300
330
|
} from "./verify/fix";
|
|
331
|
+
// Verify — Lighthouse
|
|
332
|
+
export {
|
|
333
|
+
type LighthouseOptions,
|
|
334
|
+
type LighthouseResult,
|
|
335
|
+
parseLighthouseJson,
|
|
336
|
+
runLighthouse,
|
|
337
|
+
} from "./verify/lighthouse";
|
|
301
338
|
// Verify — Mutation
|
|
302
339
|
export {
|
|
303
340
|
type MutationOptions,
|
|
@@ -343,6 +380,8 @@ export {
|
|
|
343
380
|
type SyntaxGuardResult,
|
|
344
381
|
syntaxGuard,
|
|
345
382
|
} from "./verify/syntax-guard";
|
|
383
|
+
// Verify — Typecheck + Consistency (built-in checks)
|
|
384
|
+
export { runTypecheck, type TypecheckResult } from "./verify/typecheck";
|
|
346
385
|
// Verify — Visual
|
|
347
386
|
export {
|
|
348
387
|
captureScreenshot,
|
|
@@ -357,6 +396,13 @@ export {
|
|
|
357
396
|
type VisualDiffResult,
|
|
358
397
|
type VisualVerifyResult,
|
|
359
398
|
} from "./verify/visual";
|
|
399
|
+
// Verify — ZAP DAST
|
|
400
|
+
export {
|
|
401
|
+
parseZapJson,
|
|
402
|
+
runZap,
|
|
403
|
+
type ZapOptions,
|
|
404
|
+
type ZapResult,
|
|
405
|
+
} from "./verify/zap";
|
|
360
406
|
// Workflow
|
|
361
407
|
export {
|
|
362
408
|
appendWorkflowStep,
|
|
@@ -225,4 +225,55 @@ describe("bootstrap", () => {
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
test("auto-configures biome.json when no linter detected", async () => {
|
|
230
|
+
// Project with no linter in dependencies
|
|
231
|
+
writeFileSync(
|
|
232
|
+
join(tmpDir, "package.json"),
|
|
233
|
+
JSON.stringify({ dependencies: {} }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const result = await bootstrap(tmpDir);
|
|
237
|
+
expect(result.ok).toBe(true);
|
|
238
|
+
if (result.ok) {
|
|
239
|
+
const biomePath = join(tmpDir, "biome.json");
|
|
240
|
+
expect(existsSync(biomePath)).toBe(true);
|
|
241
|
+
|
|
242
|
+
const biomeConfig = JSON.parse(readFileSync(biomePath, "utf-8"));
|
|
243
|
+
expect(biomeConfig.linter.enabled).toBe(true);
|
|
244
|
+
expect(biomeConfig.linter.rules.recommended).toBe(true);
|
|
245
|
+
expect(biomeConfig.formatter.enabled).toBe(true);
|
|
246
|
+
|
|
247
|
+
expect(result.value.created).toContain("biome.json");
|
|
248
|
+
expect(result.value.detectedStack.linter).toBe("biome");
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("does not overwrite existing biome.json", async () => {
|
|
253
|
+
writeFileSync(
|
|
254
|
+
join(tmpDir, "package.json"),
|
|
255
|
+
JSON.stringify({ dependencies: {} }),
|
|
256
|
+
);
|
|
257
|
+
writeFileSync(join(tmpDir, "biome.json"), '{"custom": true}');
|
|
258
|
+
|
|
259
|
+
const result = await bootstrap(tmpDir);
|
|
260
|
+
expect(result.ok).toBe(true);
|
|
261
|
+
|
|
262
|
+
const content = readFileSync(join(tmpDir, "biome.json"), "utf-8");
|
|
263
|
+
expect(JSON.parse(content)).toEqual({ custom: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("skips biome.json when linter already detected", async () => {
|
|
267
|
+
writeFileSync(
|
|
268
|
+
join(tmpDir, "package.json"),
|
|
269
|
+
JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const result = await bootstrap(tmpDir);
|
|
273
|
+
expect(result.ok).toBe(true);
|
|
274
|
+
if (result.ok) {
|
|
275
|
+
expect(existsSync(join(tmpDir, "biome.json"))).toBe(false);
|
|
276
|
+
expect(result.value.detectedStack.linter).toBe("eslint");
|
|
277
|
+
}
|
|
278
|
+
});
|
|
228
279
|
});
|