@basaltkit/ai 0.11.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.
Files changed (74) hide show
  1. package/README.md +4 -3
  2. package/dist/analyze/run.d.ts +19 -0
  3. package/dist/analyze/run.js +58 -0
  4. package/dist/commands.d.ts +19 -0
  5. package/dist/commands.js +272 -0
  6. package/dist/context/project.d.ts +78 -0
  7. package/dist/context/project.js +202 -0
  8. package/dist/doctor/fixes.d.ts +31 -0
  9. package/dist/doctor/fixes.js +82 -0
  10. package/dist/doctor/rules.d.ts +7 -0
  11. package/dist/doctor/rules.js +230 -0
  12. package/dist/doctor/run.d.ts +9 -0
  13. package/dist/doctor/run.js +16 -0
  14. package/dist/doctor/types.d.ts +27 -0
  15. package/dist/doctor/types.js +3 -0
  16. package/dist/index.d.ts +35 -791
  17. package/dist/index.js +40 -2389
  18. package/dist/make/fields.d.ts +35 -0
  19. package/dist/make/fields.js +139 -0
  20. package/dist/make/make.d.ts +14 -0
  21. package/dist/make/make.js +316 -0
  22. package/dist/make/openapi.d.ts +9 -0
  23. package/dist/make/openapi.js +36 -0
  24. package/dist/make/permissions.d.ts +13 -0
  25. package/dist/make/permissions.js +38 -0
  26. package/dist/make/relations.d.ts +23 -0
  27. package/dist/make/relations.js +62 -0
  28. package/dist/make/render.d.ts +4 -0
  29. package/dist/make/render.js +66 -0
  30. package/dist/make/repository.d.ts +16 -0
  31. package/dist/make/repository.js +176 -0
  32. package/dist/make/schema.d.ts +22 -0
  33. package/dist/make/schema.js +54 -0
  34. package/dist/make/types.d.ts +77 -0
  35. package/dist/make/types.js +1 -0
  36. package/dist/make/verify.d.ts +12 -0
  37. package/dist/make/verify.js +19 -0
  38. package/dist/make/wire.d.ts +23 -0
  39. package/dist/make/wire.js +76 -0
  40. package/dist/plan/context.d.ts +8 -0
  41. package/dist/plan/context.js +39 -0
  42. package/dist/plan/knowledge.d.ts +9 -0
  43. package/dist/plan/knowledge.js +50 -0
  44. package/dist/plan/plan.d.ts +15 -0
  45. package/dist/plan/plan.js +146 -0
  46. package/dist/plan/render.d.ts +4 -0
  47. package/dist/plan/render.js +55 -0
  48. package/dist/plan/types.d.ts +45 -0
  49. package/dist/plan/types.js +1 -0
  50. package/dist/provider/anthropic.d.ts +26 -0
  51. package/dist/provider/anthropic.js +72 -0
  52. package/dist/provider/factory.d.ts +27 -0
  53. package/dist/provider/factory.js +58 -0
  54. package/dist/provider/http.d.ts +24 -0
  55. package/dist/provider/http.js +36 -0
  56. package/dist/provider/ollama.d.ts +22 -0
  57. package/dist/provider/ollama.js +42 -0
  58. package/dist/provider/openai.d.ts +48 -0
  59. package/dist/provider/openai.js +94 -0
  60. package/dist/provider/sse.d.ts +28 -0
  61. package/dist/provider/sse.js +57 -0
  62. package/dist/provider/types.d.ts +49 -0
  63. package/dist/provider/types.js +9 -0
  64. package/dist/render.d.ts +10 -0
  65. package/dist/render.js +70 -0
  66. package/dist/review/knowledge.d.ts +6 -0
  67. package/dist/review/knowledge.js +25 -0
  68. package/dist/review/render.d.ts +4 -0
  69. package/dist/review/render.js +12 -0
  70. package/dist/review/review.d.ts +17 -0
  71. package/dist/review/review.js +80 -0
  72. package/dist/review/types.d.ts +12 -0
  73. package/dist/review/types.js +1 -0
  74. package/package.json +10 -11
@@ -0,0 +1,48 @@
1
+ import { type SseFetch } from './sse.js';
2
+ import { type AIProvider, type FetchLike, type GenerateOptions } from './types.js';
3
+ export interface OpenAICompatibleProviderOptions {
4
+ /** API key → `Authorization: Bearer <key>`. Required. */
5
+ apiKey: string;
6
+ /** Model id, e.g. `'gpt-4o-mini'` or `'us.anthropic.claude-sonnet-4-5-20250929-v1:0'`. */
7
+ model?: string;
8
+ /**
9
+ * API base URL up to and including `/v1`. `/chat/completions` is appended.
10
+ * Defaults to the public OpenAI API; point it at any compatible gateway, e.g.
11
+ * `https://ai.go4ai.io/v1`.
12
+ */
13
+ baseUrl?: string;
14
+ /**
15
+ * Stream the completion via SSE (default `true`). Streaming avoids the spurious
16
+ * 500 some gateways return when buffering a long non-streaming response. Set
17
+ * `false` for a gateway that doesn't support streaming.
18
+ */
19
+ stream?: boolean;
20
+ /** Injected non-streaming fetch. Defaults to the global `fetch`. */
21
+ fetch?: FetchLike;
22
+ /** Injected streaming fetch. Defaults to one built on the global `fetch`. */
23
+ sseFetch?: SseFetch;
24
+ }
25
+ /**
26
+ * Provider for any OpenAI-compatible Chat Completions endpoint (OpenAI itself,
27
+ * Azure-style gateways, LiteLLM, OpenRouter, go4ai, …). Unlike Anthropic's API,
28
+ * system messages stay inline in the `messages` array.
29
+ *
30
+ * Streams by default — the robust path for large generations behind a gateway.
31
+ */
32
+ export declare class OpenAICompatibleProvider implements AIProvider {
33
+ readonly name = "openai";
34
+ readonly model: string;
35
+ private readonly apiKey;
36
+ private readonly baseUrl;
37
+ private readonly streaming;
38
+ private readonly fetchImpl;
39
+ private readonly sseFetchImpl;
40
+ constructor(options: OpenAICompatibleProviderOptions);
41
+ generate(options: GenerateOptions): Promise<string>;
42
+ stream(options: GenerateOptions): AsyncIterable<string>;
43
+ private get endpoint();
44
+ private headers;
45
+ private requestBody;
46
+ private viaStream;
47
+ private viaJson;
48
+ }
@@ -0,0 +1,94 @@
1
+ import { fetchWithRetry } from './http.js';
2
+ import { globalSseFetch, parseSseContent } from './sse.js';
3
+ import { singleChunkStream, } from './types.js';
4
+ const DEFAULT_MODEL = 'gpt-4o-mini';
5
+ const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
6
+ /**
7
+ * Provider for any OpenAI-compatible Chat Completions endpoint (OpenAI itself,
8
+ * Azure-style gateways, LiteLLM, OpenRouter, go4ai, …). Unlike Anthropic's API,
9
+ * system messages stay inline in the `messages` array.
10
+ *
11
+ * Streams by default — the robust path for large generations behind a gateway.
12
+ */
13
+ export class OpenAICompatibleProvider {
14
+ name = 'openai';
15
+ model;
16
+ apiKey;
17
+ baseUrl;
18
+ streaming;
19
+ fetchImpl;
20
+ sseFetchImpl;
21
+ constructor(options) {
22
+ if (!options.apiKey) {
23
+ throw new Error('OpenAICompatibleProvider: apiKey is required (set AI_API_KEY)');
24
+ }
25
+ this.apiKey = options.apiKey;
26
+ this.model = options.model ?? DEFAULT_MODEL;
27
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
28
+ this.streaming = options.stream ?? true;
29
+ this.fetchImpl = options.fetch ?? globalThis.fetch ?? missingFetch;
30
+ this.sseFetchImpl = options.sseFetch ?? globalSseFetch;
31
+ }
32
+ async generate(options) {
33
+ if (!this.streaming)
34
+ return this.viaJson(options);
35
+ let out = '';
36
+ for await (const delta of this.viaStream(options))
37
+ out += delta;
38
+ return out;
39
+ }
40
+ stream(options) {
41
+ if (!this.streaming)
42
+ return singleChunkStream(() => this.viaJson(options));
43
+ return this.viaStream(options);
44
+ }
45
+ get endpoint() {
46
+ return this.baseUrl + '/chat/completions';
47
+ }
48
+ headers() {
49
+ return { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` };
50
+ }
51
+ requestBody(options, stream) {
52
+ return JSON.stringify({
53
+ model: this.model,
54
+ messages: options.messages.map((m) => ({ role: m.role, content: m.content })),
55
+ max_tokens: options.maxTokens ?? 4096,
56
+ ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
57
+ ...(stream ? { stream: true } : {}),
58
+ });
59
+ }
60
+ async *viaStream(options) {
61
+ const res = await this.sseFetchImpl(this.endpoint, {
62
+ method: 'POST',
63
+ headers: this.headers(),
64
+ body: this.requestBody(options, true),
65
+ });
66
+ if (!res.ok) {
67
+ throw new Error(`OpenAICompatibleProvider: ${res.status} — ${safeError(await res.text())}`);
68
+ }
69
+ yield* parseSseContent(res.chunks);
70
+ }
71
+ async viaJson(options) {
72
+ const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.endpoint, {
73
+ method: 'POST',
74
+ headers: this.headers(),
75
+ body: this.requestBody(options, false),
76
+ });
77
+ if (!ok) {
78
+ const detail = safeError(body);
79
+ throw new Error(`OpenAICompatibleProvider: ${status}${detail ? ` — ${detail}` : ''}`);
80
+ }
81
+ return JSON.parse(body).choices?.[0]?.message?.content ?? '';
82
+ }
83
+ }
84
+ const missingFetch = () => {
85
+ throw new Error('OpenAICompatibleProvider: no fetch available — pass options.fetch');
86
+ };
87
+ function safeError(text) {
88
+ try {
89
+ return JSON.parse(text).error?.message ?? text.slice(0, 200);
90
+ }
91
+ catch {
92
+ return text.slice(0, 200);
93
+ }
94
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Minimal Server-Sent-Events support for OpenAI-compatible streaming.
3
+ *
4
+ * Some gateways (e.g. go4ai) return a spurious 500 when they buffer a long
5
+ * non-streaming completion, even though the model generated it (and charged for
6
+ * it). Streaming sidesteps that: the response is delivered in chunks, so there's
7
+ * no large buffered body to choke on.
8
+ */
9
+ export interface SseResponse {
10
+ ok: boolean;
11
+ status: number;
12
+ /** Full body — used for the error message on a non-2xx. */
13
+ text(): Promise<string>;
14
+ /** Decoded text pieces of the streaming body. */
15
+ chunks: AsyncIterable<string>;
16
+ }
17
+ export type SseFetch = (url: string, init: {
18
+ method?: string;
19
+ headers?: Record<string, string>;
20
+ body?: string;
21
+ }) => Promise<SseResponse>;
22
+ /** Default {@link SseFetch} backed by the global `fetch` + its streaming body. */
23
+ export declare const globalSseFetch: SseFetch;
24
+ /**
25
+ * Parse an OpenAI-style SSE stream, yielding each `choices[0].delta.content`
26
+ * piece. Tolerant of keep-alive lines, partial frames and the `[DONE]` sentinel.
27
+ */
28
+ export declare function parseSseContent(chunks: AsyncIterable<string>): AsyncIterable<string>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Minimal Server-Sent-Events support for OpenAI-compatible streaming.
3
+ *
4
+ * Some gateways (e.g. go4ai) return a spurious 500 when they buffer a long
5
+ * non-streaming completion, even though the model generated it (and charged for
6
+ * it). Streaming sidesteps that: the response is delivered in chunks, so there's
7
+ * no large buffered body to choke on.
8
+ */
9
+ /** Default {@link SseFetch} backed by the global `fetch` + its streaming body. */
10
+ export const globalSseFetch = async (url, init) => {
11
+ const f = globalThis.fetch;
12
+ if (!f)
13
+ throw new Error('globalSseFetch: no global fetch available');
14
+ const res = (await f(url, {
15
+ method: init.method,
16
+ headers: init.headers,
17
+ body: init.body,
18
+ }));
19
+ const stream = res.body;
20
+ const decoder = new TextDecoder();
21
+ async function* chunks() {
22
+ if (!stream)
23
+ return;
24
+ for await (const part of stream)
25
+ yield decoder.decode(part, { stream: true });
26
+ }
27
+ return { ok: res.ok, status: res.status, text: () => res.text(), chunks: chunks() };
28
+ };
29
+ /**
30
+ * Parse an OpenAI-style SSE stream, yielding each `choices[0].delta.content`
31
+ * piece. Tolerant of keep-alive lines, partial frames and the `[DONE]` sentinel.
32
+ */
33
+ export async function* parseSseContent(chunks) {
34
+ let buffer = '';
35
+ for await (const chunk of chunks) {
36
+ buffer += chunk;
37
+ const lines = buffer.split('\n');
38
+ buffer = lines.pop() ?? '';
39
+ for (const line of lines) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed.startsWith('data:'))
42
+ continue;
43
+ const payload = trimmed.slice(5).trim();
44
+ if (payload === '' || payload === '[DONE]')
45
+ continue;
46
+ try {
47
+ const delta = JSON.parse(payload).choices?.[0]
48
+ ?.delta?.content;
49
+ if (delta)
50
+ yield delta;
51
+ }
52
+ catch {
53
+ // keep-alive or split frame — ignore and continue
54
+ }
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Provider-agnostic AI surface.
3
+ *
4
+ * The agent never talks to a vendor SDK directly — every piece of it depends on
5
+ * this interface. Swapping Anthropic ↔ Ollama ↔ an OpenAI-compatible endpoint is
6
+ * therefore an `AI_PROVIDER` config change, not a code change (spec §17).
7
+ */
8
+ export interface AIMessage {
9
+ role: 'system' | 'user' | 'assistant';
10
+ content: string;
11
+ }
12
+ export interface GenerateOptions {
13
+ messages: AIMessage[];
14
+ /** Sampling temperature (0 = deterministic). */
15
+ temperature?: number;
16
+ /** Hard cap on output tokens. */
17
+ maxTokens?: number;
18
+ }
19
+ export interface AIProvider {
20
+ /** Stable id, e.g. `'anthropic'`. */
21
+ readonly name: string;
22
+ /** Concrete model id in use, e.g. `'claude-sonnet-5'`. */
23
+ readonly model: string;
24
+ /** One-shot completion — returns the assistant's full text. */
25
+ generate(options: GenerateOptions): Promise<string>;
26
+ /** Streaming completion — yields chunks as they arrive. */
27
+ stream(options: GenerateOptions): AsyncIterable<string>;
28
+ }
29
+ /**
30
+ * Minimal fetch surface — the global `fetch` (Node 18+) is assignable, and it's
31
+ * injectable so providers can be unit-tested without a network. Mirrors the shape
32
+ * used by `@basaltkit/search-elasticsearch` to avoid a DOM lib dependency.
33
+ */
34
+ export type FetchLike = (url: string, init?: {
35
+ method?: string;
36
+ headers?: Record<string, string>;
37
+ body?: string;
38
+ }) => Promise<{
39
+ ok: boolean;
40
+ status: number;
41
+ text(): Promise<string>;
42
+ }>;
43
+ /**
44
+ * Default `stream()` implementation for providers without real token streaming
45
+ * yet: run the one-shot completion and yield it as a single chunk. Real
46
+ * server-sent-event streaming lands in a later phase; this keeps the
47
+ * {@link AIProvider} contract honest today.
48
+ */
49
+ export declare function singleChunkStream(run: () => Promise<string>): AsyncIterable<string>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Default `stream()` implementation for providers without real token streaming
3
+ * yet: run the one-shot completion and yield it as a single chunk. Real
4
+ * server-sent-event streaming lands in a later phase; this keeps the
5
+ * {@link AIProvider} contract honest today.
6
+ */
7
+ export async function* singleChunkStream(run) {
8
+ yield await run();
9
+ }
@@ -0,0 +1,10 @@
1
+ import type { AnalysisReport } from './analyze/run.js';
2
+ import type { Diagnostic } from './doctor/types.js';
3
+ /** The single output method the CLI needs — matches `CommandIo.log`. */
4
+ export interface LineWriter {
5
+ log(message: string): void;
6
+ }
7
+ /** Render the `ai:analyze` report in the spec's `✓ … detected` style. */
8
+ export declare function renderAnalysis(report: AnalysisReport, io: LineWriter): void;
9
+ /** Render the `ai:doctor` diagnostics in the spec's `AI DIAGNOSTICS` style. */
10
+ export declare function renderDoctor(diagnostics: Diagnostic[], io: LineWriter): void;
package/dist/render.js ADDED
@@ -0,0 +1,70 @@
1
+ const MARK = { error: '✗', warning: '⚠', info: 'ℹ' };
2
+ /** Render the `ai:analyze` report in the spec's `✓ … detected` style. */
3
+ export function renderAnalysis(report, io) {
4
+ io.log('Analyzing project...');
5
+ io.log('');
6
+ if (report.capabilities.length === 0) {
7
+ io.log(' No Basalt capabilities detected — is this a Basalt app? (looked for src/app.ts)');
8
+ }
9
+ else {
10
+ for (const line of report.capabilities)
11
+ io.log(`✓ ${line}`);
12
+ }
13
+ if (report.models.length > 0) {
14
+ io.log('');
15
+ io.log(`Data model (${report.models.length} model${report.models.length === 1 ? '' : 's'}):`);
16
+ for (const name of report.models) {
17
+ const scoped = report.tenantScopedModels.includes(name);
18
+ io.log(` • ${name}${scoped ? ' (tenant-scoped)' : ''}`);
19
+ }
20
+ }
21
+ io.log('');
22
+ if (report.diagnostics.length === 0) {
23
+ io.log('✓ No issues found.');
24
+ }
25
+ else {
26
+ const errors = report.diagnostics.filter((d) => d.severity === 'error').length;
27
+ const warnings = report.diagnostics.filter((d) => d.severity === 'warning').length;
28
+ const infos = report.diagnostics.filter((d) => d.severity === 'info').length;
29
+ io.log(`Diagnostics: ${errors} error(s), ${warnings} warning(s), ${infos} info.`);
30
+ io.log('Run `basalt ai:doctor` for details and fixes.');
31
+ }
32
+ }
33
+ /** Render the `ai:doctor` diagnostics in the spec's `AI DIAGNOSTICS` style. */
34
+ export function renderDoctor(diagnostics, io) {
35
+ io.log('AI DIAGNOSTICS');
36
+ io.log('');
37
+ if (diagnostics.length === 0) {
38
+ io.log('✓ Everything looks healthy.');
39
+ return;
40
+ }
41
+ for (const d of diagnostics) {
42
+ io.log(`${MARK[d.severity]} ${d.title} [${d.category}]`);
43
+ io.log('');
44
+ io.log(' Detected:');
45
+ io.log(` ${d.detected}`);
46
+ io.log('');
47
+ io.log(' Recommended:');
48
+ io.log(` ${d.recommended}`);
49
+ io.log('');
50
+ io.log(' Reason:');
51
+ io.log(` ${d.reason}`);
52
+ if (d.fix) {
53
+ io.log('');
54
+ io.log(' Fix:');
55
+ for (const line of d.fix.split('\n'))
56
+ io.log(` ${line}`);
57
+ }
58
+ if (d.docs) {
59
+ io.log('');
60
+ io.log(` Docs: https://basaltkit.dev${d.docs}`);
61
+ }
62
+ io.log('');
63
+ io.log(' ' + '─'.repeat(60));
64
+ io.log('');
65
+ }
66
+ const errors = diagnostics.filter((d) => d.severity === 'error').length;
67
+ const warnings = diagnostics.filter((d) => d.severity === 'warning').length;
68
+ io.log(`${diagnostics.length} finding(s): ${errors} error(s), ${warnings} warning(s), ` +
69
+ `${diagnostics.length - errors - warnings} info.`);
70
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The Review agent's rubric (spec §20). It reviews the *generated code* against
3
+ * Basalt's conventions and the original request, and returns issues by dimension.
4
+ * It does not rewrite code — it judges it.
5
+ */
6
+ export declare const REVIEW_KNOWLEDGE = "You are the Review agent for Basalt, a SaaS framework (Fastify + Prisma + Zod). Given a feature request, its plan, the deterministic review results and the generated code, decide whether the implementation is correct and on-convention. You do NOT rewrite code \u2014 you review it, precisely.\n\nSCOPE: `ai:make` generates a BACKEND resource vertical only \u2014 Prisma model, Zod schema, typed routes, service, repository, permissions and a test. It does NOT generate web/UI, React, frontend pages, background jobs, emails or infrastructure; other tools do. Review ONLY the backend vertical, on its own terms. If the request also asks for something outside this scope (a web page, a component, a job\u2026), mention it as a \"warning\" at most \u2014 NEVER an \"error\" \u2014 since `ai:make` is not the tool that produces it.\n\nCheck these dimensions and only raise an issue you can point to in the code:\n\n- tenancy: a tenant-scoped model MUST carry a tenantId column, and every query in its repository MUST be scoped by tenantId (create stamps it; list/find/update/delete filter by it). Raise an ERROR if a tenant-scoped resource can leak or write across tenants.\n- security: no hardcoded secrets; request bodies validated by Zod; no obvious injection.\n- rbac: routes carry `meta: { can: '<resource>.<action>' }` guards, and the permissions are declared. ERROR if a mutating route is unguarded when RBAC is enabled.\n- validation: every route has a Zod schema; the repository maps Prisma rows to the API type (no Date leaking as a Date, no raw row returned).\n- audit: state changes record audit events when audit is enabled (warning if not).\n- tests: a test file exists.\n- fit: the generated backend matches the request's DATA MODEL \u2014 the right entities, fields, relations and tenancy. Do NOT raise an \"error\" for artifacts outside the backend vertical (web UI, frontend, jobs); those are out of scope.\n\nReturn ONLY a single JSON object (no prose, no markdown fences):\n{\n \"summary\": string, // one or two sentences\n \"issues\": [ { \"dimension\": string, \"severity\": \"error\" | \"warning\", \"message\": string } ]\n}\nRaise \"error\" only for a real, blocking convention violation you can cite; use \"warning\" for improvements. An empty issues array means the implementation is approved.";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The Review agent's rubric (spec §20). It reviews the *generated code* against
3
+ * Basalt's conventions and the original request, and returns issues by dimension.
4
+ * It does not rewrite code — it judges it.
5
+ */
6
+ export const REVIEW_KNOWLEDGE = `You are the Review agent for Basalt, a SaaS framework (Fastify + Prisma + Zod). Given a feature request, its plan, the deterministic review results and the generated code, decide whether the implementation is correct and on-convention. You do NOT rewrite code — you review it, precisely.
7
+
8
+ SCOPE: \`ai:make\` generates a BACKEND resource vertical only — Prisma model, Zod schema, typed routes, service, repository, permissions and a test. It does NOT generate web/UI, React, frontend pages, background jobs, emails or infrastructure; other tools do. Review ONLY the backend vertical, on its own terms. If the request also asks for something outside this scope (a web page, a component, a job…), mention it as a "warning" at most — NEVER an "error" — since \`ai:make\` is not the tool that produces it.
9
+
10
+ Check these dimensions and only raise an issue you can point to in the code:
11
+
12
+ - tenancy: a tenant-scoped model MUST carry a tenantId column, and every query in its repository MUST be scoped by tenantId (create stamps it; list/find/update/delete filter by it). Raise an ERROR if a tenant-scoped resource can leak or write across tenants.
13
+ - security: no hardcoded secrets; request bodies validated by Zod; no obvious injection.
14
+ - rbac: routes carry \`meta: { can: '<resource>.<action>' }\` guards, and the permissions are declared. ERROR if a mutating route is unguarded when RBAC is enabled.
15
+ - validation: every route has a Zod schema; the repository maps Prisma rows to the API type (no Date leaking as a Date, no raw row returned).
16
+ - audit: state changes record audit events when audit is enabled (warning if not).
17
+ - tests: a test file exists.
18
+ - fit: the generated backend matches the request's DATA MODEL — the right entities, fields, relations and tenancy. Do NOT raise an "error" for artifacts outside the backend vertical (web UI, frontend, jobs); those are out of scope.
19
+
20
+ Return ONLY a single JSON object (no prose, no markdown fences):
21
+ {
22
+ "summary": string, // one or two sentences
23
+ "issues": [ { "dimension": string, "severity": "error" | "warning", "message": string } ]
24
+ }
25
+ Raise "error" only for a real, blocking convention violation you can cite; use "warning" for improvements. An empty issues array means the implementation is approved.`;
@@ -0,0 +1,4 @@
1
+ import type { LineWriter } from '../render.js';
2
+ import type { AgentReview } from './types.js';
3
+ /** Render the Review agent's verdict in the spec's `Implementation rejected` style. */
4
+ export declare function renderAgentReview(review: AgentReview, io: LineWriter): void;
@@ -0,0 +1,12 @@
1
+ /** Render the Review agent's verdict in the spec's `Implementation rejected` style. */
2
+ export function renderAgentReview(review, io) {
3
+ io.log(review.approved ? '✓ Review agent: approved' : '✗ Review agent: changes requested');
4
+ if (review.summary)
5
+ io.log(` ${review.summary}`);
6
+ if (review.issues.length > 0) {
7
+ io.log(' Issues:');
8
+ for (const issue of review.issues) {
9
+ io.log(` ${issue.severity === 'error' ? '✗' : '⚠'} [${issue.dimension}] ${issue.message}`);
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,17 @@
1
+ import type { MakeResult } from '../make/types.js';
2
+ import type { ArchitecturePlan } from '../plan/types.js';
3
+ import type { AIProvider } from '../provider/types.js';
4
+ import type { AgentReview } from './types.js';
5
+ export interface ReviewOptions {
6
+ temperature?: number;
7
+ maxTokens?: number;
8
+ }
9
+ /**
10
+ * Compact, relevant-only context for the Review agent: the request, the plan, the
11
+ * deterministic review, and the generated code that carries behaviour.
12
+ */
13
+ export declare function buildReviewContext(plan: ArchitecturePlan, result: MakeResult): string;
14
+ /** Ask the Review agent to critique the generated code. Read-only — it judges, it doesn't edit. */
15
+ export declare function reviewImplementation(provider: AIProvider, plan: ArchitecturePlan, result: MakeResult, options?: ReviewOptions): Promise<AgentReview>;
16
+ /** Parse the verdict. `approved` is derived from the issues, not trusted from the model. */
17
+ export declare function parseReview(raw: string): AgentReview;
@@ -0,0 +1,80 @@
1
+ import { REVIEW_KNOWLEDGE } from './knowledge.js';
2
+ /** Files worth sending to the reviewer — the ones that carry the behaviour. */
3
+ const REVIEWABLE = /\.prisma$|\.(schema|routes|service|repository|permissions)\.ts$/;
4
+ /**
5
+ * Compact, relevant-only context for the Review agent: the request, the plan, the
6
+ * deterministic review, and the generated code that carries behaviour.
7
+ */
8
+ export function buildReviewContext(plan, result) {
9
+ const lines = [
10
+ `REQUEST: ${plan.request}`,
11
+ '',
12
+ 'PLAN:',
13
+ ` summary: ${plan.summary}`,
14
+ ` entities: ${plan.entities.map((e) => `${e.name}${e.tenantScoped ? ' (tenant-scoped)' : ''}`).join(', ')}`,
15
+ ];
16
+ if (plan.permissions.length > 0)
17
+ lines.push(` permissions: ${plan.permissions.join(', ')}`);
18
+ if (plan.auditEvents.length > 0)
19
+ lines.push(` auditEvents: ${plan.auditEvents.join(', ')}`);
20
+ lines.push('', 'DETERMINISTIC REVIEW:');
21
+ for (const item of result.review.items)
22
+ lines.push(` [${item.status}] ${item.label}: ${item.detail}`);
23
+ lines.push('', 'GENERATED CODE:');
24
+ for (const resource of result.resources) {
25
+ for (const file of resource.files) {
26
+ if (REVIEWABLE.test(file.path))
27
+ lines.push(`--- ${file.path} ---`, file.content);
28
+ }
29
+ }
30
+ return lines.join('\n');
31
+ }
32
+ /** Ask the Review agent to critique the generated code. Read-only — it judges, it doesn't edit. */
33
+ export async function reviewImplementation(provider, plan, result, options = {}) {
34
+ const raw = await provider.generate({
35
+ messages: [
36
+ { role: 'system', content: REVIEW_KNOWLEDGE },
37
+ { role: 'user', content: `${buildReviewContext(plan, result)}\n\nReturn the verdict as a single JSON object.` },
38
+ ],
39
+ temperature: options.temperature ?? 0,
40
+ maxTokens: options.maxTokens ?? 2048,
41
+ });
42
+ return parseReview(raw);
43
+ }
44
+ /** Parse the verdict. `approved` is derived from the issues, not trusted from the model. */
45
+ export function parseReview(raw) {
46
+ const json = extractJson(raw);
47
+ let obj;
48
+ try {
49
+ obj = JSON.parse(json);
50
+ }
51
+ catch {
52
+ throw new Error(`ai review — the model did not return valid JSON. Got: ${raw.slice(0, 160)}…`);
53
+ }
54
+ const issues = normalizeIssues(obj['issues']);
55
+ return {
56
+ approved: !issues.some((i) => i.severity === 'error'),
57
+ summary: typeof obj['summary'] === 'string' ? obj['summary'] : '',
58
+ issues,
59
+ };
60
+ }
61
+ function normalizeIssues(value) {
62
+ if (!Array.isArray(value))
63
+ return [];
64
+ return value
65
+ .filter((i) => typeof i === 'object' && i !== null)
66
+ .map((i) => ({
67
+ dimension: typeof i['dimension'] === 'string' ? i['dimension'] : 'general',
68
+ severity: i['severity'] === 'error' ? 'error' : 'warning',
69
+ message: typeof i['message'] === 'string' ? i['message'] : '',
70
+ }))
71
+ .filter((i) => i.message !== '');
72
+ }
73
+ function extractJson(raw) {
74
+ const withoutFence = raw.replace(/```(?:json)?/gi, '').trim();
75
+ const start = withoutFence.indexOf('{');
76
+ const end = withoutFence.lastIndexOf('}');
77
+ if (start === -1 || end === -1 || end < start)
78
+ return withoutFence;
79
+ return withoutFence.slice(start, end + 1);
80
+ }
@@ -0,0 +1,12 @@
1
+ export interface ReviewIssue {
2
+ /** e.g. `tenancy`, `security`, `rbac`, `validation`, `tests`, `fit`. */
3
+ dimension: string;
4
+ severity: 'error' | 'warning';
5
+ message: string;
6
+ }
7
+ export interface AgentReview {
8
+ /** Derived: true only when no issue is error-severity. */
9
+ approved: boolean;
10
+ summary: string;
11
+ issues: ReviewIssue[];
12
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/ai",
3
- "version": "0.11.0",
3
+ "version": "1.0.0",
4
4
  "description": "AI-native developer experience for Basalt: a provider-agnostic engine plus `basalt ai` CLI that understands the project's stack, analyzes it, and diagnoses issues — following the framework's own conventions, not inventing new ones.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,14 +14,13 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/cli": "^1.2.0",
18
- "@basaltkit/generator": "^1.1.1"
17
+ "@basaltkit/generator": "^1.1.1",
18
+ "@basaltkit/cli": "^1.2.0"
19
19
  },
20
20
  "devDependencies": {
21
- "@types/node": "^22.15.0",
22
- "tsup": "^8.4.0",
23
- "typescript": "^5.8.0",
24
- "vitest": "^3.1.0",
21
+ "@types/node": "^26.3.0",
22
+ "typescript": "^7.0.2",
23
+ "vitest": "^4.1.11",
25
24
  "@basaltkit/tsconfig": "^0.24.0"
26
25
  },
27
26
  "publishConfig": {
@@ -29,11 +28,11 @@
29
28
  },
30
29
  "repository": {
31
30
  "type": "git",
32
- "url": "git+https://github.com/Zebedeu/basalt.git",
31
+ "url": "git+https://github.com/basaltkit/basalt.git",
33
32
  "directory": "packages/ai"
34
33
  },
35
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/ai#readme",
36
- "bugs": "https://github.com/Zebedeu/basalt/issues",
34
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/ai#readme",
35
+ "bugs": "https://github.com/basaltkit/basalt/issues",
37
36
  "keywords": [
38
37
  "basalt",
39
38
  "ai",
@@ -43,7 +42,7 @@
43
42
  "typescript"
44
43
  ],
45
44
  "scripts": {
46
- "build": "tsup src/index.ts --format esm --dts --clean",
45
+ "build": "tsc -p tsconfig.build.json",
47
46
  "test": "vitest run",
48
47
  "typecheck": "tsc --noEmit"
49
48
  }