@basaltkit/ai 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/ai
2
8
 
3
9
  AI-native developer experience for Basalt. A provider-agnostic engine plus a
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Framework-free read-only surface: project detection, analysis, diagnostics
3
+ * (with in-memory fix previews) and the BasaltKit architectural knowledge —
4
+ * WITHOUT the `basalt ai` CLI command wiring, which imports `@basaltkit/cli`
5
+ * (→ `@basaltkit/core`). The main barrel (`@basaltkit/ai`) re-exports `aiCommands`
6
+ * and therefore transitively loads the framework runtime; this subpath does not.
7
+ *
8
+ * It exists so out-of-process, dev-only consumers — notably the `@basaltkit/ai-mcp`
9
+ * bridge — can use analyze/doctor/context without pulling `@basaltkit/core` or
10
+ * `@basaltkit/http` into their dependency graph. Everything here is also exported
11
+ * from the main barrel; this is a boundary-preserving subset, not a new API.
12
+ */
13
+ export { detectProject, nodeReader, memoryReader, type AppFileInfo, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
14
+ export { analyze, type AnalysisReport } from './analyze/run.js';
15
+ export { hasErrors, runDoctor } from './doctor/run.js';
16
+ export { DEFAULT_RULES } from './doctor/rules.js';
17
+ export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
18
+ export { fixableIds, planFix, type FileEdit, type FixOutcome, type FixStatus, } from './doctor/fixes.js';
19
+ export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Framework-free read-only surface: project detection, analysis, diagnostics
3
+ * (with in-memory fix previews) and the BasaltKit architectural knowledge —
4
+ * WITHOUT the `basalt ai` CLI command wiring, which imports `@basaltkit/cli`
5
+ * (→ `@basaltkit/core`). The main barrel (`@basaltkit/ai`) re-exports `aiCommands`
6
+ * and therefore transitively loads the framework runtime; this subpath does not.
7
+ *
8
+ * It exists so out-of-process, dev-only consumers — notably the `@basaltkit/ai-mcp`
9
+ * bridge — can use analyze/doctor/context without pulling `@basaltkit/core` or
10
+ * `@basaltkit/http` into their dependency graph. Everything here is also exported
11
+ * from the main barrel; this is a boundary-preserving subset, not a new API.
12
+ */
13
+ // Project context — stack/schema/config detection.
14
+ export { detectProject, nodeReader, memoryReader, } from './context/project.js';
15
+ // Analysis report.
16
+ export { analyze } from './analyze/run.js';
17
+ // Diagnostics engine.
18
+ export { hasErrors, runDoctor } from './doctor/run.js';
19
+ export { DEFAULT_RULES } from './doctor/rules.js';
20
+ export { defineRule, } from './doctor/types.js';
21
+ // Fix previews — computed in memory; `planFix` never writes to disk.
22
+ export { fixableIds, planFix, } from './doctor/fixes.js';
23
+ // The framework conventions the planner is grounded in.
24
+ export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
@@ -0,0 +1,43 @@
1
+ import { abortError, isAbortError, throwIfAborted } from './provider/abort.js';
2
+ import type { AIProvider, GenerateOptions } from './provider/types.js';
3
+ /**
4
+ * A progress event from a long-running workflow step. `chunk`/`text` carry
5
+ * streamed model output (streaming generations); `message` carries a coarse
6
+ * status (e.g. a `make` phase). All fields optional so a consumer maps whatever
7
+ * is present onto its own progress surface (e.g. MCP `notifications/progress`).
8
+ */
9
+ export interface WorkflowProgress {
10
+ /** A short human-readable status. */
11
+ message?: string;
12
+ /** The newest streamed text fragment. */
13
+ chunk?: string;
14
+ /** All model text received so far. */
15
+ text?: string;
16
+ }
17
+ export type OnProgress = (progress: WorkflowProgress) => void;
18
+ /** Cross-cutting options threaded through the workflow engine. */
19
+ export interface WorkflowRunOptions {
20
+ /** Abort the operation. Forwarded to the provider's fetch and raced at this layer. */
21
+ signal?: AbortSignal;
22
+ /** Receive progress as it happens. When set, streaming providers stream. */
23
+ onProgress?: OnProgress;
24
+ }
25
+ /**
26
+ * Run a provider completion. With `onProgress`, consume `provider.stream` and
27
+ * emit each fragment (real token streaming where the provider supports it; a
28
+ * single final chunk otherwise). Without it, the one-shot `provider.generate`
29
+ * path is used unchanged.
30
+ */
31
+ export declare function generateText(provider: AIProvider, options: GenerateOptions, onProgress?: OnProgress): Promise<string>;
32
+ /**
33
+ * Race a task against an abort signal so cancellation is prompt even if the
34
+ * underlying provider doesn't honour the signal itself.
35
+ */
36
+ export declare function withAbort<T>(signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>;
37
+ /**
38
+ * Provider generation with optional streaming (`onProgress`) and cancellation
39
+ * (`signal`) — the single seam the workflow steps call so signal + progress are
40
+ * threaded consistently.
41
+ */
42
+ export declare function runGeneration(provider: AIProvider, options: GenerateOptions, run?: WorkflowRunOptions): Promise<string>;
43
+ export { abortError, isAbortError, throwIfAborted };
@@ -0,0 +1,46 @@
1
+ import { abortError, isAbortError, throwIfAborted } from './provider/abort.js';
2
+ /**
3
+ * Run a provider completion. With `onProgress`, consume `provider.stream` and
4
+ * emit each fragment (real token streaming where the provider supports it; a
5
+ * single final chunk otherwise). Without it, the one-shot `provider.generate`
6
+ * path is used unchanged.
7
+ */
8
+ export async function generateText(provider, options, onProgress) {
9
+ if (!onProgress)
10
+ return provider.generate(options);
11
+ let text = '';
12
+ for await (const chunk of provider.stream(options)) {
13
+ if (!chunk)
14
+ continue;
15
+ text += chunk;
16
+ onProgress({ chunk, text });
17
+ }
18
+ return text;
19
+ }
20
+ /**
21
+ * Race a task against an abort signal so cancellation is prompt even if the
22
+ * underlying provider doesn't honour the signal itself.
23
+ */
24
+ export async function withAbort(signal, run) {
25
+ if (!signal)
26
+ return run();
27
+ throwIfAborted(signal);
28
+ return new Promise((resolve, reject) => {
29
+ const onAbort = () => reject(abortError());
30
+ signal.addEventListener('abort', onAbort, { once: true });
31
+ void Promise.resolve()
32
+ .then(run)
33
+ .then(resolve, reject)
34
+ .finally(() => signal.removeEventListener('abort', onAbort));
35
+ });
36
+ }
37
+ /**
38
+ * Provider generation with optional streaming (`onProgress`) and cancellation
39
+ * (`signal`) — the single seam the workflow steps call so signal + progress are
40
+ * threaded consistently.
41
+ */
42
+ export function runGeneration(provider, options, run = {}) {
43
+ const genOptions = run.signal ? { ...options, signal: run.signal } : options;
44
+ return withAbort(run.signal, () => generateText(provider, genOptions, run.onProgress));
45
+ }
46
+ export { abortError, isAbortError, throwIfAborted };
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export { OllamaProvider, type OllamaProviderOptions } from './provider/ollama.js
4
4
  export { OpenAICompatibleProvider, type OpenAICompatibleProviderOptions, } from './provider/openai.js';
5
5
  export { fetchWithRetry, type RetryOptions } from './provider/http.js';
6
6
  export { globalSseFetch, parseSseContent, type SseFetch, type SseResponse } from './provider/sse.js';
7
- export { createProvider, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
7
+ export { createProvider, providerEnvFrom, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
8
+ export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, type OnProgress, type WorkflowProgress, type WorkflowRunOptions, } from './generate.js';
8
9
  export { detectProject, nodeReader, memoryReader, type AppFileInfo, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
9
10
  export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
10
11
  export { DEFAULT_RULES } from './doctor/rules.js';
@@ -26,10 +27,11 @@ export { injectOpenApiMeta } from './make/openapi.js';
26
27
  export { externalRelationTargets, foreignKey, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './make/relations.js';
27
28
  export { injectAuditPlugin, injectAuditService, injectPermissionGuards } from './make/wire.js';
28
29
  export { extractModelBlock, mergeModelsIntoSchema, runPrismaPush, type ModelBlock, } from './make/schema.js';
29
- export type { MakeOptions, MakeResult, Migration, ResourceBuild, ReviewItem, ReviewResult, ReviewStatus, SchemaMerge, } from './make/types.js';
30
+ export type { MakeOptions, MakeResult, MakePreview, FilePreview, Migration, ResourceBuild, ReviewItem, ReviewResult, ReviewStatus, SchemaMerge, } from './make/types.js';
30
31
  export { buildReviewContext, parseReview, reviewImplementation, type ReviewOptions } from './review/review.js';
31
32
  export { REVIEW_KNOWLEDGE } from './review/knowledge.js';
32
33
  export { renderAgentReview } from './review/render.js';
33
34
  export type { AgentReview, ReviewIssue } from './review/types.js';
34
35
  export { renderAnalysis, renderDoctor, type LineWriter } from './render.js';
35
36
  export { aiCommands, type AiCommandsOptions } from './commands.js';
37
+ export { PLAN_SCHEMA_VERSION, MAKE_SCHEMA_VERSION, toJsonSchema, ArchitecturePlanSchema, MakeResultSchema, AnalysisReportSchema, ProjectContextSchema, AgentReviewSchema, DiagnosticSchema, DetectedStackSchema, } from './schema/index.js';
package/dist/index.js CHANGED
@@ -5,7 +5,9 @@ export { OllamaProvider } from './provider/ollama.js';
5
5
  export { OpenAICompatibleProvider, } from './provider/openai.js';
6
6
  export { fetchWithRetry } from './provider/http.js';
7
7
  export { globalSseFetch, parseSseContent } from './provider/sse.js';
8
- export { createProvider, providerEnvFromProcess, } from './provider/factory.js';
8
+ export { createProvider, providerEnvFrom, providerEnvFromProcess, } from './provider/factory.js';
9
+ // Workflow progress + cancellation (spec §D.1(2)).
10
+ export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, } from './generate.js';
9
11
  // Project context — stack/schema/config detection (spec §18, Context Engineering).
10
12
  export { detectProject, nodeReader, memoryReader, } from './context/project.js';
11
13
  // Diagnostics engine (spec §6, Doctor).
@@ -38,3 +40,6 @@ export { renderAgentReview } from './review/render.js';
38
40
  // Rendering + CLI wiring.
39
41
  export { renderAnalysis, renderDoctor } from './render.js';
40
42
  export { aiCommands } from './commands.js';
43
+ // Runtime schemas + JSON Schema derivation for the public data contracts (also
44
+ // available at the `@basaltkit/ai/schema` subpath).
45
+ export { PLAN_SCHEMA_VERSION, MAKE_SCHEMA_VERSION, toJsonSchema, ArchitecturePlanSchema, MakeResultSchema, AnalysisReportSchema, ProjectContextSchema, AgentReviewSchema, DiagnosticSchema, DetectedStackSchema, } from './schema/index.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A minimal, dependency-free unified-diff generator for the safe-make preview.
3
+ * Line-based LCS with configurable context — enough to show an agent exactly
4
+ * what a write would change (empty `oldText` → an all-additions "new file" diff).
5
+ */
6
+ /**
7
+ * Produce a unified diff of `oldText` → `newText` labelled with `path`.
8
+ * Returns '' when the texts are identical.
9
+ */
10
+ export declare function unifiedDiff(oldText: string, newText: string, path: string, context?: number): string;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * A minimal, dependency-free unified-diff generator for the safe-make preview.
3
+ * Line-based LCS with configurable context — enough to show an agent exactly
4
+ * what a write would change (empty `oldText` → an all-additions "new file" diff).
5
+ */
6
+ /** Compute the length table of the longest common subsequence of two line arrays. */
7
+ function lcsLengths(a, b) {
8
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
9
+ for (let i = a.length - 1; i >= 0; i--) {
10
+ for (let j = b.length - 1; j >= 0; j--) {
11
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
12
+ }
13
+ }
14
+ return table;
15
+ }
16
+ /** Backtrack the LCS table into an edit script over lines. */
17
+ function editScript(a, b) {
18
+ const table = lcsLengths(a, b);
19
+ const ops = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < a.length && j < b.length) {
23
+ if (a[i] === b[j]) {
24
+ ops.push({ kind: 'eq', line: a[i] });
25
+ i++;
26
+ j++;
27
+ }
28
+ else if (table[i + 1][j] >= table[i][j + 1]) {
29
+ ops.push({ kind: 'del', line: a[i] });
30
+ i++;
31
+ }
32
+ else {
33
+ ops.push({ kind: 'add', line: b[j] });
34
+ j++;
35
+ }
36
+ }
37
+ while (i < a.length)
38
+ ops.push({ kind: 'del', line: a[i++] });
39
+ while (j < b.length)
40
+ ops.push({ kind: 'add', line: b[j++] });
41
+ return ops;
42
+ }
43
+ /** Split into lines without a trailing empty element for a final newline. */
44
+ function toLines(text) {
45
+ if (text === '')
46
+ return [];
47
+ const lines = text.split('\n');
48
+ if (lines.at(-1) === '')
49
+ lines.pop();
50
+ return lines;
51
+ }
52
+ /**
53
+ * Produce a unified diff of `oldText` → `newText` labelled with `path`.
54
+ * Returns '' when the texts are identical.
55
+ */
56
+ export function unifiedDiff(oldText, newText, path, context = 3) {
57
+ if (oldText === newText)
58
+ return '';
59
+ const a = toLines(oldText);
60
+ const b = toLines(newText);
61
+ const ops = editScript(a, b);
62
+ const hunks = [];
63
+ let aLine = 0;
64
+ let bLine = 0;
65
+ let current = null;
66
+ let trailingEq = 0;
67
+ const flushIfIdle = () => {
68
+ if (current && trailingEq > context) {
69
+ // trim the surplus trailing context off the current hunk
70
+ const surplus = trailingEq - context;
71
+ current.lines.splice(current.lines.length - surplus);
72
+ current.aCount -= surplus;
73
+ current.bCount -= surplus;
74
+ hunks.push(current);
75
+ current = null;
76
+ trailingEq = 0;
77
+ }
78
+ };
79
+ for (const op of ops) {
80
+ if (op.kind === 'eq') {
81
+ if (current) {
82
+ current.lines.push(` ${op.line}`);
83
+ current.aCount++;
84
+ current.bCount++;
85
+ trailingEq++;
86
+ flushIfIdle();
87
+ }
88
+ aLine++;
89
+ bLine++;
90
+ }
91
+ else {
92
+ if (!current) {
93
+ const lead = Math.min(context, /* leading context available */ Math.min(aLine, bLine));
94
+ current = { aStart: aLine - lead + 1, bStart: bLine - lead + 1, lines: [], aCount: lead, bCount: lead };
95
+ // pull the leading context lines back in
96
+ const leadLines = a.slice(aLine - lead, aLine).map((l) => ` ${l}`);
97
+ current.lines.push(...leadLines);
98
+ }
99
+ trailingEq = 0;
100
+ if (op.kind === 'del') {
101
+ current.lines.push(`-${op.line}`);
102
+ current.aCount++;
103
+ aLine++;
104
+ }
105
+ else {
106
+ current.lines.push(`+${op.line}`);
107
+ current.bCount++;
108
+ bLine++;
109
+ }
110
+ }
111
+ }
112
+ if (current)
113
+ hunks.push(current);
114
+ const out = [`--- a/${path}`, `+++ b/${path}`];
115
+ for (const h of hunks) {
116
+ out.push(`@@ -${h.aStart},${h.aCount} +${h.bStart},${h.bCount} @@`);
117
+ out.push(...h.lines);
118
+ }
119
+ return out.join('\n');
120
+ }
package/dist/make/make.js CHANGED
@@ -1,4 +1,9 @@
1
- import { FileExistsError, generateResource, names, registerResourceInApp, writeGenerated, } from '@basaltkit/generator';
1
+ import { FileExistsError, generateResource, names, registerResourceInApp, writeGenerated, } from '@basaltkit/generator/resource';
2
+ import { MAKE_SCHEMA_VERSION } from '../schema/index.js';
3
+ import { throwIfAborted } from '../generate.js';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { unifiedDiff } from './diff.js';
2
7
  import { domainFields, injectPrismaFields, injectZodFields } from './fields.js';
3
8
  import { externalRelationTargets, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './relations.js';
4
9
  import { injectOpenApiMeta } from './openapi.js';
@@ -21,10 +26,13 @@ export async function runMake(ctx, plan, options = {}) {
21
26
  if (entities.length === 0) {
22
27
  throw new Error('ai:make — the plan has no entity to generate.');
23
28
  }
29
+ throwIfAborted(options.signal);
24
30
  const baseDir = options.baseDir ?? process.cwd();
25
31
  const prismaDefault = ctx.stack.orm === 'prisma';
26
32
  const resources = [];
27
33
  for (const entity of entities) {
34
+ throwIfAborted(options.signal);
35
+ options.onProgress?.({ message: `Building ${entity.name}…` });
28
36
  const gen = findGeneratorCommand(plan, entity.name);
29
37
  const prisma = options.prisma ?? gen?.prisma ?? prismaDefault;
30
38
  const softDelete = options.softDelete ?? gen?.softDelete ?? false;
@@ -69,7 +77,11 @@ export async function runMake(ctx, plan, options = {}) {
69
77
  const { schema, migration } = await mergeSchema(resources, baseDir, options);
70
78
  const followUps = buildFollowUps(plan, resources, schema, migration);
71
79
  const review = reviewBuild(ctx, plan, resources, schema, migration);
80
+ // Safe preview (dry-run only): stat every target and attach a unified diff, so a
81
+ // caller sees exactly what an apply would create/overwrite before anything is written.
82
+ const preview = options.dryRun ? await buildPreview(resources, baseDir) : undefined;
72
83
  return {
84
+ schemaVersion: MAKE_SCHEMA_VERSION,
73
85
  request: plan.request,
74
86
  dryRun: options.dryRun === true,
75
87
  resources,
@@ -77,6 +89,7 @@ export async function runMake(ctx, plan, options = {}) {
77
89
  ...(migration ? { migration } : {}),
78
90
  followUps,
79
91
  review,
92
+ ...(preview ? { preview } : {}),
80
93
  };
81
94
  }
82
95
  async function mergeSchema(resources, baseDir, options) {
@@ -308,6 +321,29 @@ function hasTenantId(resource) {
308
321
  return false;
309
322
  return /^[ \t]*tenantId[ \t]+/m.test(model.content);
310
323
  }
324
+ async function readFileOrNull(target) {
325
+ try {
326
+ return await readFile(target, 'utf8');
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ /** Stat every generated file and build a per-file plan (create|overwrite) with a unified diff. */
333
+ async function buildPreview(resources, baseDir) {
334
+ const perFile = [];
335
+ const clashes = [];
336
+ for (const resource of resources) {
337
+ for (const file of resource.files) {
338
+ const existing = await readFileOrNull(join(baseDir, file.path));
339
+ const action = existing !== null ? 'overwrite' : 'create';
340
+ if (existing !== null)
341
+ clashes.push(file.path);
342
+ perFile.push({ path: file.path, action, diff: unifiedDiff(existing ?? '', file.content, file.path) });
343
+ }
344
+ }
345
+ return { perFile, clashes };
346
+ }
311
347
  function kebab(name) {
312
348
  return name
313
349
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -1,4 +1,4 @@
1
- import { names } from '@basaltkit/generator';
1
+ import { names } from '@basaltkit/generator/resource';
2
2
  /** Human summary for a generated CRUD route, from its method + url. */
3
3
  function summaryFor(method, url, singular, plural) {
4
4
  switch (method.toUpperCase()) {
@@ -1,4 +1,4 @@
1
- import { names } from '@basaltkit/generator';
1
+ import { names } from '@basaltkit/generator/resource';
2
2
  /**
3
3
  * The RBAC permissions for a resource — the exact `<plural>.<action>` strings its
4
4
  * route guards check on (F5 derives guards from the entity name, so declaring
@@ -1,4 +1,4 @@
1
- import { names } from '@basaltkit/generator';
1
+ import { names } from '@basaltkit/generator/resource';
2
2
  /**
3
3
  * Real Prisma relations (Fase 6). A belongs-to relation on an entity generates:
4
4
  * a `<name>Id` FK column, a `<name> <Model> @relation(...)` field, and — on the
@@ -1,4 +1,4 @@
1
- import { names } from '@basaltkit/generator';
1
+ import { names } from '@basaltkit/generator/resource';
2
2
  import { canonicalType, domainFields } from './fields.js';
3
3
  /** TypeScript type of a Prisma row column for the mapper's parameter. */
4
4
  function rowType(t) {
@@ -1,4 +1,5 @@
1
- import type { GeneratedFile } from '@basaltkit/generator';
1
+ import type { GeneratedFile } from '@basaltkit/generator/resource';
2
+ import type { OnProgress } from '../generate.js';
2
3
  export interface MakeOptions {
3
4
  /** Skip writing to disk — return what would be generated. */
4
5
  dryRun?: boolean;
@@ -14,6 +15,10 @@ export interface MakeOptions {
14
15
  schemaPath?: string;
15
16
  /** After merging models, run `prisma db push` (creates tables + regenerates the client). */
16
17
  migrate?: boolean;
18
+ /** Abort the operation (checked at each resource boundary). */
19
+ signal?: AbortSignal;
20
+ /** Receive coarse progress as each resource is built. */
21
+ onProgress?: OnProgress;
17
22
  }
18
23
  /** How the generated models were merged into prisma/schema.prisma. */
19
24
  export interface SchemaMerge {
@@ -52,6 +57,20 @@ export interface ResourceBuild {
52
57
  /** Why a step didn't complete (e.g. a file clash). */
53
58
  note?: string;
54
59
  }
60
+ /** One file the build would write, with the change it would make. */
61
+ export interface FilePreview {
62
+ path: string;
63
+ /** `create` = the file does not exist; `overwrite` = it exists and would be replaced. */
64
+ action: 'create' | 'overwrite';
65
+ /** Unified diff of the change (empty old side for a new file). */
66
+ diff: string;
67
+ }
68
+ /** A dry-run preview: exactly what would be written, and which files clash. */
69
+ export interface MakePreview {
70
+ perFile: FilePreview[];
71
+ /** Paths that already exist and would be overwritten (an apply needs `force`). */
72
+ clashes: string[];
73
+ }
55
74
  export type ReviewStatus = 'pass' | 'warn' | 'fail';
56
75
  export interface ReviewItem {
57
76
  label: string;
@@ -64,6 +83,11 @@ export interface ReviewResult {
64
83
  ok: boolean;
65
84
  }
66
85
  export interface MakeResult {
86
+ /**
87
+ * Serialization contract version. `runMake` always sets it; optional on the
88
+ * type so older, unversioned results still satisfy the interface.
89
+ */
90
+ schemaVersion?: number;
67
91
  request: string;
68
92
  dryRun: boolean;
69
93
  resources: ResourceBuild[];
@@ -74,4 +98,6 @@ export interface MakeResult {
74
98
  /** Manual follow-ups the scaffold can't do yet. */
75
99
  followUps: string[];
76
100
  review: ReviewResult;
101
+ /** Dry-run only: the exact per-file plan (clash flags + diffs). Absent on apply. */
102
+ preview?: MakePreview;
77
103
  }
package/dist/make/wire.js CHANGED
@@ -1,4 +1,4 @@
1
- import { names } from '@basaltkit/generator';
1
+ import { names } from '@basaltkit/generator/resource';
2
2
  function insertAfterLastImport(content, line) {
3
3
  const imports = [...content.matchAll(/^import .*$/gm)];
4
4
  const last = imports.at(-1);
@@ -1,9 +1,14 @@
1
1
  import type { ProjectContext } from '../context/project.js';
2
2
  import type { AIProvider } from '../provider/types.js';
3
+ import { type OnProgress } from '../generate.js';
3
4
  import type { ArchitecturePlan } from './types.js';
4
5
  export interface CreatePlanOptions {
5
6
  temperature?: number;
6
7
  maxTokens?: number;
8
+ /** Abort the request. */
9
+ signal?: AbortSignal;
10
+ /** Receive streamed progress. When set, a streaming provider streams. */
11
+ onProgress?: OnProgress;
7
12
  }
8
13
  /**
9
14
  * Turn a natural-language request into a grounded {@link ArchitecturePlan}. The
package/dist/plan/plan.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ArchitecturePlanSchema } from '../schema/index.js';
2
+ import { runGeneration } from '../generate.js';
1
3
  import { buildPlanContext } from './context.js';
2
4
  import { BASALT_KNOWLEDGE } from './knowledge.js';
3
5
  const STEP_KINDS = [
@@ -18,7 +20,12 @@ const STEP_KINDS = [
18
20
  * it produces a plan, it changes nothing.
19
21
  */
20
22
  export async function createPlan(provider, ctx, request, options = {}) {
21
- const raw = await provider.generate({
23
+ const run = {};
24
+ if (options.signal)
25
+ run.signal = options.signal;
26
+ if (options.onProgress)
27
+ run.onProgress = options.onProgress;
28
+ const raw = await runGeneration(provider, {
22
29
  messages: [
23
30
  { role: 'system', content: BASALT_KNOWLEDGE },
24
31
  {
@@ -28,7 +35,7 @@ export async function createPlan(provider, ctx, request, options = {}) {
28
35
  ],
29
36
  temperature: options.temperature ?? 0,
30
37
  maxTokens: options.maxTokens ?? 4096,
31
- });
38
+ }, run);
32
39
  return parsePlan(raw, request);
33
40
  }
34
41
  /** Parse + normalize the model's JSON into a validated plan. Tolerant of fences and missing arrays. */
@@ -41,7 +48,11 @@ export function parsePlan(raw, request) {
41
48
  catch {
42
49
  throw new Error(`ai:plan — the model did not return valid JSON. Got: ${raw.slice(0, 160)}…`);
43
50
  }
44
- return {
51
+ // Coerce first (tolerant of fences, missing arrays and loose shapes), then
52
+ // validate the coerced object against the schema. `schemaVersion` defaults in
53
+ // when absent; an incoming version (a plan round-tripped from a client) is kept.
54
+ const coerced = {
55
+ ...(typeof obj['schemaVersion'] === 'number' ? { schemaVersion: obj['schemaVersion'] } : {}),
45
56
  request,
46
57
  summary: typeof obj['summary'] === 'string' ? obj['summary'] : '',
47
58
  entities: normalizeEntities(obj['entities']),
@@ -51,6 +62,10 @@ export function parsePlan(raw, request) {
51
62
  tenantScoped: obj['tenantScoped'] === true,
52
63
  warnings: stringArray(obj['warnings']),
53
64
  };
65
+ // The zod output is runtime-identical to ArchitecturePlan (optional keys are
66
+ // omitted, never `undefined`); the cast only bridges the `| undefined` that
67
+ // `z.infer` adds to optionals under `exactOptionalPropertyTypes`.
68
+ return ArchitecturePlanSchema.parse(coerced);
54
69
  }
55
70
  /** Strip markdown fences and isolate the outermost JSON object. */
56
71
  function extractJson(raw) {
@@ -32,6 +32,12 @@ export interface PlanStep {
32
32
  files?: string[];
33
33
  }
34
34
  export interface ArchitecturePlan {
35
+ /**
36
+ * Serialization contract version. Producers (`createPlan`/`parsePlan`) always
37
+ * set it; it is optional on the type so hand-constructed plans and older,
38
+ * unversioned plans round-tripped from a client still satisfy the interface.
39
+ */
40
+ schemaVersion?: number;
35
41
  request: string;
36
42
  summary: string;
37
43
  entities: PlanEntity[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Cancellation helpers shared by the provider layer and the workflow engine.
3
+ * Kept dependency-free so both the low-level fetch and the high-level workflows
4
+ * speak the same abort vocabulary.
5
+ */
6
+ /** An `AbortError` with the conventional name, for signal-based cancellation. */
7
+ export declare function abortError(message?: string): Error;
8
+ /** Throw an {@link abortError} if the signal is already aborted. */
9
+ export declare function throwIfAborted(signal?: AbortSignal): void;
10
+ /** True when an error is an abort (from a cancelled fetch or {@link abortError}). */
11
+ export declare function isAbortError(error: unknown): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Cancellation helpers shared by the provider layer and the workflow engine.
3
+ * Kept dependency-free so both the low-level fetch and the high-level workflows
4
+ * speak the same abort vocabulary.
5
+ */
6
+ /** An `AbortError` with the conventional name, for signal-based cancellation. */
7
+ export function abortError(message = 'The operation was aborted') {
8
+ const error = new Error(message);
9
+ error.name = 'AbortError';
10
+ return error;
11
+ }
12
+ /** Throw an {@link abortError} if the signal is already aborted. */
13
+ export function throwIfAborted(signal) {
14
+ if (signal?.aborted)
15
+ throw abortError();
16
+ }
17
+ /** True when an error is an abort (from a cancelled fetch or {@link abortError}). */
18
+ export function isAbortError(error) {
19
+ return error instanceof Error && error.name === 'AbortError';
20
+ }
@@ -40,6 +40,7 @@ export class AnthropicProvider {
40
40
  'x-api-key': this.apiKey,
41
41
  'anthropic-version': API_VERSION,
42
42
  },
43
+ ...(options.signal ? { signal: options.signal } : {}),
43
44
  body: JSON.stringify({
44
45
  model: this.model,
45
46
  max_tokens: options.maxTokens ?? 4096,
@@ -14,6 +14,8 @@ export interface CreateProviderOptions {
14
14
  /** Override the injected fetch (tests). */
15
15
  fetch?: FetchLike;
16
16
  }
17
+ /** Read the provider-relevant vars off an env record into a typed {@link ProviderEnv}. */
18
+ export declare function providerEnvFrom(env: Record<string, string | undefined>): ProviderEnv;
17
19
  /** Read the provider-relevant vars off `process.env` into a typed {@link ProviderEnv}. */
18
20
  export declare function providerEnvFromProcess(): ProviderEnv;
19
21
  /**