@multileaf/ai-workflow 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - Batched semantic indexing: embedding requests now use the Ollama batch endpoint (`POST /api/embed`) with up to 64 chunks per request instead of one request per chunk, and lockfiles and generated vendor manifests (`package-lock.json`, `yarn.lock`, `go.sum`, and others) are excluded from the index by default (`semantic.excludeLockfiles` overrides).
12
+ - Optional decision-model re-ranking for `aiw semantic query --rerank`: when a decision model is enabled, each retrieved candidate is scored 0-1 for query relevance and the output is reordered with a reported ranking influence; cosine recall without a decision model is unchanged.
13
+ - Semantic interpreter scoping during `aiw scan`: when semantic mapping is enabled and an index exists, the AI interpreter receives deterministic key files plus the top semantically relevant files for a profile-derived query instead of the alphabetically ordered file list, reducing token usage.
14
+ - Built-in OpenAI-compatible `AiProvider` configured through an `ai` section in `.aiw/settings.yml` (for example Ollama `gpt-oss:20b`), so the scoped interpreter works without a host-injected provider; secrets redaction, token ceilings, and scoped-context validation apply unchanged, and missing configuration keeps scans deterministic and offline.
15
+ - Advisory decision-model ranking for `aiw recommend`: when a decision model is enabled, recommendations are ordered by relevance to the confirmed profile and serialized with `decision_relevance` / `decision_confidence`; evidence states never change.
16
+ - Telemetry consent during interactive `aiw install`, asked after the optional model questions with an explicit default of No, stating the exact event payload (command name and outcome only); the decision is recorded in `.aiw/settings.yml` and reversible through `aiw telemetry enable/disable`. Non-interactive installs skip the prompt.
17
+ - Local telemetry buffer: when telemetry is enabled and no host client is present, events accumulate in a rotated `.aiw/telemetry-buffer.jsonl` (retention cap of 200 events); `aiw telemetry status --verbose` displays buffered events and `aiw telemetry disable` purges the buffer. No network transport was added.
18
+ - Local model detection during interactive install: the installer probes the configured Ollama endpoint (`/api/tags`), lists the embedding and decision models actually available, and records the user's selection in `.aiw/settings.yml`; when no server or models are found it continues with defaults.
19
+
20
+ ### Changed
21
+
22
+ - GitHub Actions now use Node 24-native `actions/checkout@v5` and `actions/setup-node@v5`, and CI runs on Node 24.
7
23
  ## [0.4.1] - 2026-10-08
8
24
 
9
25
  ### Fixed
@@ -48,4 +48,15 @@ export declare function parseDecisionConfig(text: string): DecisionConfig;
48
48
  export declare function loadDecisionConfig(fs: FileSystem, aiwPath: string): DecisionConfig | undefined;
49
49
  export declare function parseDecisionResponse(text: string): DecisionResponse;
50
50
  export declare function createOllamaDecisionProvider(config: DecisionConfig): DecisionProvider;
51
+ export type RecommendationRankingCandidate = {
52
+ id: string;
53
+ rationale: string;
54
+ evidence: string[];
55
+ };
56
+ export declare function buildRecommendationRankingRequest(profileSummary: string, candidates: RecommendationRankingCandidate[]): DecisionRequest;
57
+ export type SemanticRerankCandidate = {
58
+ id: string;
59
+ text: string;
60
+ };
61
+ export declare function buildSemanticRerankRequest(query: string, candidates: SemanticRerankCandidate[]): DecisionRequest;
51
62
  export declare function buildEvidenceTriageRequest(artifact: string): DecisionRequest;
package/dist/decision.js CHANGED
@@ -96,6 +96,31 @@ export function createOllamaDecisionProvider(config) {
96
96
  },
97
97
  };
98
98
  }
99
+ export function buildRecommendationRankingRequest(profileSummary, candidates) {
100
+ const questions = {};
101
+ for (const candidate of candidates) {
102
+ questions[candidate.id] = {
103
+ type: "score",
104
+ instructions: `Confirmed project profile: ${profileSummary}.\nRate from 0 to 1 how relevant this capability recommendation is to that profile.\n\nRecommendation ${candidate.id}: ${candidate.rationale}\nEvidence: ${candidate.evidence.join(", ")}`,
105
+ criteria: [
106
+ "0 = irrelevant to the confirmed profile",
107
+ "1 = highly relevant to the confirmed profile",
108
+ ],
109
+ };
110
+ }
111
+ return { state: `Capability recommendation ranking for: ${profileSummary}`, questions };
112
+ }
113
+ export function buildSemanticRerankRequest(query, candidates) {
114
+ const questions = {};
115
+ for (const candidate of candidates) {
116
+ questions[candidate.id] = {
117
+ type: "score",
118
+ instructions: `Rate from 0 to 1 how well this retrieval candidate answers the user query "${query}".\n\nCandidate:\n${candidate.text}`,
119
+ criteria: ["0 = irrelevant to the query", "1 = directly and completely answers the query"],
120
+ };
121
+ }
122
+ return { state: `Semantic retrieval rerank for query: ${query}`, questions };
123
+ }
99
124
  export function buildEvidenceTriageRequest(artifact) {
100
125
  return {
101
126
  state: artifact,
@@ -1,5 +1,6 @@
1
1
  import type { EvidenceFact } from "./evidence.js";
2
2
  import type { ProjectProfile } from "./profile.js";
3
+ import type { FileSystem } from "./types.js";
3
4
  export type InterpretationRequest = {
4
5
  profile: ProjectProfile;
5
6
  files: string[];
@@ -19,5 +20,20 @@ export type AiProvider = {
19
20
  generate(request: AiProviderRequest): Promise<string>;
20
21
  };
21
22
  export type ContextReader = (absolutePath: string) => Promise<string>;
22
- export declare function createInterpretationRequest(profile: ProjectProfile, files: string[], maxTokens?: number): InterpretationRequest;
23
+ export type AiProviderConfig = {
24
+ enabled: boolean;
25
+ model: string;
26
+ baseUrl: string;
27
+ apiKeyEnv?: string;
28
+ };
29
+ export declare const DEFAULT_AI_PROVIDER_CONFIG: AiProviderConfig;
30
+ export declare function parseAiProviderMapping(raw: unknown): AiProviderConfig;
31
+ export declare function createInterpretationRequest(profile: ProjectProfile, files: string[], maxTokens?: number, priorityFiles?: string[]): InterpretationRequest;
32
+ export declare const INTERPRETER_KEY_FILE_NAMES: string[];
33
+ export declare function selectInterpreterKeyFiles(files: string[]): string[];
34
+ export declare function buildInterpreterQuery(profile: ProjectProfile): string;
35
+ type AiFetchLike = (url: string, init?: RequestInit) => Promise<Response>;
36
+ export declare function loadAiProviderConfig(fs: FileSystem, aiwPath: string): AiProviderConfig | undefined;
37
+ export declare function createOpenAiCompatibleProvider(config: AiProviderConfig, fetchImpl?: AiFetchLike, env?: Readonly<Record<string, string | undefined>>): AiProvider;
23
38
  export declare function createProjectInterpreter(root: string, provider: AiProvider, contextReader?: ContextReader): Interpreter;
39
+ export {};
@@ -1,5 +1,32 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
+ import { loadSettings } from "./settings.js";
4
+ export const DEFAULT_AI_PROVIDER_CONFIG = {
5
+ enabled: false,
6
+ model: "gpt-oss:20b",
7
+ baseUrl: "http://localhost:11434",
8
+ };
9
+ export function parseAiProviderMapping(raw) {
10
+ if (!raw || typeof raw !== "object")
11
+ throw new Error("ai must be a YAML mapping");
12
+ const record = raw;
13
+ const enabled = record.enabled;
14
+ const model = record.model;
15
+ const baseUrl = record.baseUrl;
16
+ const apiKeyEnv = record.apiKeyEnv;
17
+ if (typeof enabled !== "boolean")
18
+ throw new Error("ai.enabled must be a boolean");
19
+ if (!enabled)
20
+ return { ...DEFAULT_AI_PROVIDER_CONFIG, enabled: false };
21
+ if (typeof model !== "string" || model.trim() === "")
22
+ throw new Error("ai.model must be a non-empty string when the AI provider is enabled");
23
+ if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
24
+ throw new Error("ai.baseUrl must be an http(s) URL when the AI provider is enabled");
25
+ if (apiKeyEnv !== undefined &&
26
+ (typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
27
+ throw new Error("ai.apiKeyEnv must be an uppercase environment variable name");
28
+ return { enabled: true, model, baseUrl, ...(apiKeyEnv ? { apiKeyEnv } : {}) };
29
+ }
3
30
  const EXCLUDED_SEGMENT = /(^|\/)(\.git|node_modules|vendor|\.venv|venv|dist|build|coverage|out|target|\.next|\.nuxt|\.output)(\/|$)/i;
4
31
  const EXCLUDED_AIW = /(^|\/)\.aiw\/(generated|checkpoints)(\/|$)/i;
5
32
  const SENSITIVE = /(^|\/)\.env(?:\.[^/]*)?$|\.(pem|key|p12|pfx|secret)$/i;
@@ -58,19 +85,62 @@ function isBinary(content) {
58
85
  (code >= 14 && code <= 31));
59
86
  });
60
87
  }
61
- export function createInterpretationRequest(profile, files, maxTokens = 4000) {
88
+ export function createInterpretationRequest(profile, files, maxTokens = 4000, priorityFiles) {
62
89
  if (!Number.isInteger(maxTokens) || maxTokens <= 0)
63
90
  throw new Error("maxTokens must be a positive integer");
64
- const selectedFiles = [
65
- ...new Set(files.filter(safeFile).map((file) => file.replaceAll("\\", "/"))),
66
- ].sort();
91
+ const safe = [...new Set(files.filter(safeFile).map((file) => file.replaceAll("\\", "/")))];
92
+ const priority = priorityFiles?.map((file) => file.replaceAll("\\", "/"));
93
+ const ordered = priority
94
+ ? [
95
+ ...new Set(priority.filter((file) => safe.includes(file))),
96
+ ...safe.filter((file) => !priority.includes(file)).sort(),
97
+ ]
98
+ : [...safe].sort();
67
99
  return {
68
100
  profile,
69
- files: selectedFiles,
101
+ files: ordered,
70
102
  maxTokens,
71
- estimatedTokens: Math.min(maxTokens, JSON.stringify(profile).length + selectedFiles.join("\n").length),
103
+ estimatedTokens: Math.min(maxTokens, JSON.stringify(profile).length + ordered.join("\n").length),
72
104
  };
73
105
  }
106
+ export const INTERPRETER_KEY_FILE_NAMES = [
107
+ "readme.md",
108
+ "contributing.md",
109
+ "architecture.md",
110
+ "package.json",
111
+ "pyproject.toml",
112
+ "requirements.txt",
113
+ "go.mod",
114
+ "cargo.toml",
115
+ "pom.xml",
116
+ "build.gradle",
117
+ "build.gradle.kts",
118
+ "composer.json",
119
+ "gemfile",
120
+ "deno.json",
121
+ "mix.exs",
122
+ ];
123
+ export function selectInterpreterKeyFiles(files) {
124
+ const byBasename = new Map();
125
+ for (const file of files) {
126
+ const normalized = file.replaceAll("\\", "/");
127
+ const basename = normalized.split("/").pop()?.toLowerCase() ?? "";
128
+ if (basename && !byBasename.has(basename))
129
+ byBasename.set(basename, normalized);
130
+ }
131
+ return INTERPRETER_KEY_FILE_NAMES.flatMap((name) => {
132
+ const match = byBasename.get(name);
133
+ return match ? [match] : [];
134
+ });
135
+ }
136
+ export function buildInterpreterQuery(profile) {
137
+ const parts = [
138
+ profile.runtime.languages.join(", "),
139
+ profile.frameworks.join(", "),
140
+ profile.packageManager,
141
+ ].filter((part) => part && part !== "unknown");
142
+ return `Project conventions, architecture, and coding standards${parts.length > 0 ? ` for ${parts.join(" with ")}` : ""}`;
143
+ }
74
144
  function parseFacts(output, allowedFiles) {
75
145
  let value;
76
146
  try {
@@ -109,6 +179,46 @@ function parseFacts(output, allowedFiles) {
109
179
  };
110
180
  });
111
181
  }
182
+ export function loadAiProviderConfig(fs, aiwPath) {
183
+ const settings = loadSettings(fs, aiwPath);
184
+ if (settings === undefined || !("ai" in settings))
185
+ return undefined;
186
+ return parseAiProviderMapping(settings.ai);
187
+ }
188
+ export function createOpenAiCompatibleProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
189
+ return {
190
+ async generate(request) {
191
+ const headers = { "content-type": "application/json" };
192
+ if (config.apiKeyEnv) {
193
+ const key = env[config.apiKeyEnv];
194
+ if (key)
195
+ headers.authorization = `Bearer ${key}`;
196
+ }
197
+ const response = await fetchImpl(`${config.baseUrl}/v1/chat/completions`, {
198
+ method: "POST",
199
+ headers,
200
+ body: JSON.stringify({
201
+ model: config.model,
202
+ messages: [
203
+ { role: "system", content: request.system },
204
+ { role: "user", content: request.prompt },
205
+ ],
206
+ max_tokens: request.maxTokens,
207
+ ...(request.responseFormat === "json"
208
+ ? { response_format: { type: "json_object" } }
209
+ : {}),
210
+ }),
211
+ });
212
+ if (!response.ok)
213
+ throw new Error(`AI provider request failed for model ${config.model}: ${await response.text()}`);
214
+ const body = (await response.json());
215
+ const content = body.choices?.[0]?.message?.content;
216
+ if (typeof content !== "string")
217
+ throw new Error(`AI provider ${config.model} returned no message content`);
218
+ return content;
219
+ },
220
+ };
221
+ }
112
222
  export function createProjectInterpreter(root, provider, contextReader = async (path) => new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path))) {
113
223
  const projectRoot = resolve(root);
114
224
  return {
@@ -16,5 +16,20 @@ export type CapabilityRecommendation = {
16
16
  requires?: string[];
17
17
  };
18
18
  export declare function recommendCapabilities(profile: ProjectProfile): CapabilityRecommendation[];
19
+ export type RecommendationRanking = {
20
+ id: string;
21
+ relevance: number;
22
+ confidence?: number;
23
+ };
24
+ export type RankedRecommendations = {
25
+ items: CapabilityRecommendation[];
26
+ ranking: RecommendationRanking[];
27
+ changedPositions: number;
28
+ };
29
+ export type DecisionRanker = (profile: ProjectProfile, recommendations: CapabilityRecommendation[]) => Promise<Record<string, {
30
+ relevance?: number;
31
+ confidence?: number;
32
+ }>>;
33
+ export declare function rankRecommendations(items: CapabilityRecommendation[], profile: ProjectProfile, ranker: DecisionRanker): Promise<RankedRecommendations>;
19
34
  export declare function resourcesForCapabilities(ids: string[]): RecommendedResource[];
20
- export declare function serializeRecommendations(items: CapabilityRecommendation[], selected?: string[]): string;
35
+ export declare function serializeRecommendations(items: CapabilityRecommendation[], selected?: string[], ranking?: RecommendationRanking[]): string;
@@ -241,6 +241,32 @@ export function recommendCapabilities(profile) {
241
241
  : [];
242
242
  });
243
243
  }
244
+ export async function rankRecommendations(items, profile, ranker) {
245
+ if (items.length === 0)
246
+ return { items: [], ranking: [], changedPositions: 0 };
247
+ const answers = await ranker(profile, items);
248
+ const ranking = [];
249
+ const relevanceById = new Map();
250
+ for (const item of items) {
251
+ const answer = answers[item.id];
252
+ const relevance = answer?.relevance !== undefined && Number.isFinite(answer.relevance)
253
+ ? answer.relevance
254
+ : undefined;
255
+ relevanceById.set(item.id, relevance);
256
+ if (relevance !== undefined)
257
+ ranking.push({
258
+ id: item.id,
259
+ relevance,
260
+ ...(answer?.confidence !== undefined ? { confidence: answer.confidence } : {}),
261
+ });
262
+ }
263
+ const ordered = [...items].sort((a, b) => (relevanceById.get(b.id) ?? -1) - (relevanceById.get(a.id) ?? -1));
264
+ let changedPositions = 0;
265
+ for (let index = 0; index < ordered.length; index += 1)
266
+ if (ordered[index].id !== items[index].id)
267
+ changedPositions += 1;
268
+ return { items: ordered, ranking, changedPositions };
269
+ }
244
270
  export function resourcesForCapabilities(ids) {
245
271
  const normalizedIds = ids.map((id) => (id === "vitest-testing" ? "verification" : id));
246
272
  const selectedCapabilities = new Set(CATALOG.filter(({ id }) => normalizedIds.includes(id)).map(({ id }) => id));
@@ -252,9 +278,14 @@ export function resourcesForCapabilities(ids) {
252
278
  ...new Map(resources.map((resource) => [`${resource.type}/${resource.id}`, resource])).values(),
253
279
  ];
254
280
  }
255
- export function serializeRecommendations(items, selected = []) {
281
+ export function serializeRecommendations(items, selected = [], ranking = []) {
282
+ const rankingById = new Map(ranking.map((entry) => [entry.id, entry]));
256
283
  return `schema: 1\nrecommendations:\n${items
257
- .map((item) => ` - id: ${item.id}\n provider: ${item.provider}\n confidence: ${item.confidence}\n selected: ${selected.includes(item.id)}\n requires: [${(item.requires ?? []).join(", ")}]\n resources: [${item.resources.map(({ type, id }) => `${type}/${id}`).join(", ")}]\n selected_resources: [${item.resources
284
+ .map((item) => ` - id: ${item.id}\n provider: ${item.provider}\n confidence: ${item.confidence}${rankingById.has(item.id)
285
+ ? `\n decision_relevance: ${rankingById.get(item.id)?.relevance}${rankingById.get(item.id)?.confidence !== undefined
286
+ ? `\n decision_confidence: ${rankingById.get(item.id)?.confidence}`
287
+ : ""}`
288
+ : ""}\n selected: ${selected.includes(item.id)}\n requires: [${(item.requires ?? []).join(", ")}]\n resources: [${item.resources.map(({ type, id }) => `${type}/${id}`).join(", ")}]\n selected_resources: [${item.resources
258
289
  .filter(({ type, id }) => selected.includes(`${type}/${id}`))
259
290
  .map(({ type, id }) => `${type}/${id}`)
260
291
  .join(", ")}]\n evidence: [${item.evidence.map((evidence) => `"${evidence}"`).join(", ")}]\n rationale: ${item.rationale}`)
@@ -5,6 +5,7 @@ export type SemanticConfig = {
5
5
  baseUrl: string;
6
6
  chunkChars: number;
7
7
  topK: number;
8
+ excludeLockfiles: boolean;
8
9
  };
9
10
  export type SemanticIndexChunk = {
10
11
  file: string;
@@ -21,6 +22,17 @@ export type SemanticMatch = {
21
22
  score: number;
22
23
  excerpt: string;
23
24
  };
25
+ export type RerankedMatch = SemanticMatch & {
26
+ rerankScore?: number;
27
+ };
28
+ export type RerankOutcome = {
29
+ matches: RerankedMatch[];
30
+ changedPositions: number;
31
+ };
32
+ export type DecisionReranker = (query: string, candidates: {
33
+ id: string;
34
+ text: string;
35
+ }[]) => Promise<Record<string, number | undefined>>;
24
36
  export type EmbeddingProvider = {
25
37
  available(): Promise<boolean>;
26
38
  embed(texts: string[]): Promise<number[][]>;
@@ -28,14 +40,19 @@ export type EmbeddingProvider = {
28
40
  export declare const SEMANTIC_CONFIG_PATH = "semantic.yml";
29
41
  export declare const SEMANTIC_INDEX_PATH = "semantic/index.json";
30
42
  export declare const DEFAULT_SEMANTIC_CONFIG: SemanticConfig;
43
+ export declare const SEMANTIC_LOCKFILE_NAMES: Set<string>;
44
+ export declare function isExcludedIndexFile(file: string): boolean;
31
45
  export declare function parseSemanticMapping(raw: unknown): SemanticConfig;
32
46
  export declare function serializeSemanticConfig(config: SemanticConfig): string;
33
47
  export declare function loadSemanticConfig(fs: FileSystem, aiwPath: string): SemanticConfig | undefined;
34
48
  export declare function parseSemanticConfig(text: string): SemanticConfig;
35
- export declare function createOllamaEmbeddingProvider(config: SemanticConfig): EmbeddingProvider;
49
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
50
+ export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike): EmbeddingProvider;
36
51
  export declare function chunkText(content: string, chunkChars: number): string[];
37
52
  export declare function cosineSimilarity(a: number[], b: number[]): number;
38
53
  export declare function buildSemanticIndex(files: string[], readContent: (file: string) => Promise<string | undefined>, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticIndex>;
39
54
  export declare function serializeSemanticIndex(index: SemanticIndex): string;
40
55
  export declare function parseSemanticIndex(text: string): SemanticIndex;
41
56
  export declare function querySemanticIndex(index: SemanticIndex, query: string, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticMatch[]>;
57
+ export declare function rerankSemanticMatches(matches: SemanticMatch[], query: string, reranker: DecisionReranker): Promise<RerankOutcome>;
58
+ export {};
package/dist/semantic.js CHANGED
@@ -8,7 +8,28 @@ export const DEFAULT_SEMANTIC_CONFIG = {
8
8
  baseUrl: "http://localhost:11434",
9
9
  chunkChars: 1200,
10
10
  topK: 8,
11
+ excludeLockfiles: true,
11
12
  };
13
+ export const SEMANTIC_LOCKFILE_NAMES = new Set([
14
+ "bun.lock",
15
+ "bun.lockb",
16
+ "cargo.lock",
17
+ "composer.lock",
18
+ "deno.lock",
19
+ "flake.lock",
20
+ "gemfile.lock",
21
+ "go.sum",
22
+ "npm-shrinkwrap.json",
23
+ "package-lock.json",
24
+ "pipfile.lock",
25
+ "pnpm-lock.yaml",
26
+ "poetry.lock",
27
+ "uv.lock",
28
+ "yarn.lock",
29
+ ]);
30
+ export function isExcludedIndexFile(file) {
31
+ return SEMANTIC_LOCKFILE_NAMES.has(file.split("/").pop()?.toLowerCase() ?? "");
32
+ }
12
33
  export function parseSemanticMapping(raw) {
13
34
  if (!raw || typeof raw !== "object")
14
35
  throw new Error("semantic must be a YAML mapping");
@@ -18,10 +39,14 @@ export function parseSemanticMapping(raw) {
18
39
  const baseUrl = raw2.baseUrl;
19
40
  const chunkChars = raw2.chunkChars;
20
41
  const topK = raw2.topK;
42
+ const rawExclude = raw2.excludeLockfiles;
21
43
  if (typeof enabled !== "boolean")
22
44
  throw new Error("semantic.enabled must be a boolean");
45
+ const excludeLockfiles = rawExclude === undefined ? true : rawExclude;
23
46
  if (!enabled)
24
- return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false };
47
+ return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false, excludeLockfiles };
48
+ if (rawExclude !== undefined && typeof rawExclude !== "boolean")
49
+ throw new Error("semantic.excludeLockfiles must be a boolean");
25
50
  if (typeof model !== "string" || model.trim() === "")
26
51
  throw new Error("semantic.model must be a non-empty string when semantic mapping is enabled");
27
52
  if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
@@ -32,10 +57,17 @@ export function parseSemanticMapping(raw) {
32
57
  throw new Error("semantic.chunkChars must be an integer of at least 200");
33
58
  if (typeof parsedTopK !== "number" || !Number.isInteger(parsedTopK) || parsedTopK < 1)
34
59
  throw new Error("semantic.topK must be a positive integer");
35
- return { enabled: true, model, baseUrl, chunkChars: parsedChunk, topK: parsedTopK };
60
+ return {
61
+ enabled: true,
62
+ model,
63
+ baseUrl,
64
+ chunkChars: parsedChunk,
65
+ topK: parsedTopK,
66
+ excludeLockfiles,
67
+ };
36
68
  }
37
69
  export function serializeSemanticConfig(config) {
38
- return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n`;
70
+ return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n excludeLockfiles: ${config.excludeLockfiles}\n`;
39
71
  }
40
72
  export function loadSemanticConfig(fs, aiwPath) {
41
73
  const settings = loadSettings(fs, aiwPath);
@@ -56,8 +88,8 @@ function parseSemanticDocument(text) {
56
88
  export function parseSemanticConfig(text) {
57
89
  return parseSemanticDocument(text);
58
90
  }
59
- export function createOllamaEmbeddingProvider(config) {
60
- const fetchImpl = (url, init) => fetch(url, init);
91
+ const EMBED_BATCH_SIZE = 64;
92
+ export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init)) {
61
93
  return {
62
94
  async available() {
63
95
  try {
@@ -76,18 +108,21 @@ export function createOllamaEmbeddingProvider(config) {
76
108
  },
77
109
  async embed(texts) {
78
110
  const vectors = [];
79
- for (const text of texts) {
80
- const response = await fetchImpl(`${config.baseUrl}/api/embeddings`, {
111
+ for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
112
+ const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
113
+ const response = await fetchImpl(`${config.baseUrl}/api/embed`, {
81
114
  method: "POST",
82
115
  headers: { "content-type": "application/json" },
83
- body: JSON.stringify({ model: config.model, prompt: text }),
116
+ body: JSON.stringify({ model: config.model, input: batch }),
84
117
  });
85
118
  if (!response.ok)
86
119
  throw new Error(`Embedding request failed for model ${config.model}`);
87
120
  const body = (await response.json());
88
- if (!Array.isArray(body.embedding) || body.embedding.some((v) => typeof v !== "number"))
89
- throw new Error(`Embedding model ${config.model} returned an invalid vector`);
90
- vectors.push(body.embedding);
121
+ if (!Array.isArray(body.embeddings) ||
122
+ body.embeddings.length !== batch.length ||
123
+ body.embeddings.some((vector) => !Array.isArray(vector) || vector.some((v) => typeof v !== "number")))
124
+ throw new Error(`Embedding model ${config.model} returned invalid vectors`);
125
+ vectors.push(...body.embeddings);
91
126
  }
92
127
  return vectors;
93
128
  },
@@ -127,6 +162,8 @@ export function cosineSimilarity(a, b) {
127
162
  export async function buildSemanticIndex(files, readContent, provider, config) {
128
163
  const entries = [];
129
164
  for (const file of files) {
165
+ if (config.excludeLockfiles && isExcludedIndexFile(file))
166
+ continue;
130
167
  const content = await readContent(file);
131
168
  if (content === undefined)
132
169
  continue;
@@ -173,3 +210,24 @@ export async function querySemanticIndex(index, query, provider, config) {
173
210
  .sort((a, b) => b.score - a.score)
174
211
  .slice(0, config.topK);
175
212
  }
213
+ export async function rerankSemanticMatches(matches, query, reranker) {
214
+ if (matches.length === 0)
215
+ return { matches: [], changedPositions: 0 };
216
+ const candidates = matches.map((match, index) => ({
217
+ id: `match_${index}`,
218
+ text: `${match.file}\n${match.excerpt}`,
219
+ }));
220
+ const scores = await reranker(query, candidates);
221
+ const reranked = matches.map((match, index) => {
222
+ const rerankScore = scores[`match_${index}`];
223
+ return Number.isFinite(rerankScore)
224
+ ? { ...match, rerankScore: rerankScore }
225
+ : { ...match };
226
+ });
227
+ reranked.sort((a, b) => (b.rerankScore ?? b.score) - (a.rerankScore ?? a.score));
228
+ let changedPositions = 0;
229
+ for (let index = 0; index < reranked.length; index += 1)
230
+ if (reranked[index].file !== matches[index].file)
231
+ changedPositions += 1;
232
+ return { matches: reranked, changedPositions };
233
+ }
@@ -1,12 +1,14 @@
1
1
  import type { ProjectProfile } from "./profile.js";
2
+ import type { EmbeddingProvider } from "./semantic.js";
2
3
  import type { ProjectScan } from "./scanner.js";
3
4
  import type { CapabilityRecommendation } from "./recommendations.js";
4
- import type { Interpreter } from "./interpreter.js";
5
+ import type { Interpreter, AiProvider } from "./interpreter.js";
5
6
  import type { CommandExecutor } from "./vercel-skills.js";
6
7
  import type { CommandResult, FileSystem } from "./types.js";
7
8
  import type { PackageSourceLoader } from "./providers.js";
8
9
  import type { PrivateRegistryClient } from "./team-configuration.js";
9
10
  import type { PolicyTracking } from "./policy-gate.js";
11
+ import type { DecisionProvider } from "./decision.js";
10
12
  import type { TelemetryClient } from "./telemetry.js";
11
13
  import type { DashboardHandle } from "./dashboard.js";
12
14
  import type { AgentOrchestrator } from "./orchestration.js";
@@ -35,6 +37,9 @@ export interface WorkflowDependencies {
35
37
  start(root: string, port: number): Promise<DashboardHandle>;
36
38
  };
37
39
  orchestrator?: AgentOrchestrator;
40
+ embeddingProvider?: EmbeddingProvider;
41
+ aiProvider?: AiProvider;
42
+ decisionProvider?: DecisionProvider;
38
43
  }
39
44
  export interface WorkflowService {
40
45
  execute(args: string[], root: string, fs: FileSystem): Promise<CommandResult>;
@@ -14,10 +14,18 @@ export interface TelemetryClient {
14
14
  record(event: TelemetryEvent): Promise<void>;
15
15
  }
16
16
  export declare const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig;
17
+ export declare const TELEMETRY_CONSENT_PROMPT = "Enable anonymous telemetry? Each event contains only the command name and its outcome (success or failure) - never arguments, file paths, or file contents. [y/N] ";
18
+ export declare function offerTelemetryConsent(fs: FileSystem, aiwPath: string, answer: (question: string) => Promise<string>): Promise<boolean>;
17
19
  export declare function parseTelemetryMapping(raw: unknown): TelemetryConfig;
18
20
  export declare function serializeTelemetryMapping(config: TelemetryConfig): unknown;
19
21
  export declare function loadTelemetryConfig(fs: FileSystem, aiwPath: string): TelemetryConfig;
20
22
  export declare function parseTelemetryConfig(content: string): TelemetryConfig;
21
23
  export declare function serializeTelemetryConfig(config: TelemetryConfig): string;
22
24
  export declare function createTelemetryEvent(command: string | undefined, result: CommandResult, config: TelemetryConfig): TelemetryEvent | undefined;
25
+ export declare const TELEMETRY_BUFFER_FILE = "telemetry-buffer.jsonl";
26
+ export declare const TELEMETRY_BUFFER_CAP = 200;
27
+ export declare function telemetryBufferPath(aiwPath: string): string;
28
+ export declare function readTelemetryBuffer(fs: FileSystem, aiwPath: string): TelemetryEvent[];
29
+ export declare function appendTelemetryBuffer(fs: FileSystem, aiwPath: string, event: TelemetryEvent): void;
30
+ export declare function purgeTelemetryBuffer(fs: FileSystem, aiwPath: string): void;
23
31
  export declare function recordConfiguredTelemetry(fs: FileSystem, aiwPath: string, client: TelemetryClient | undefined, command: string | undefined, result: CommandResult): Promise<void>;
package/dist/telemetry.js CHANGED
@@ -1,10 +1,25 @@
1
- import { loadSettings } from "./settings.js";
1
+ import { loadSettings, saveSettingsSection } from "./settings.js";
2
2
  export const DEFAULT_TELEMETRY_CONFIG = {
3
3
  schema: 1,
4
4
  enabled: false,
5
5
  includeCommand: true,
6
6
  includeOutcome: true,
7
7
  };
8
+ export const TELEMETRY_CONSENT_PROMPT = "Enable anonymous telemetry? Each event contains only the command name and its outcome (success or failure) - never arguments, file paths, or file contents. [y/N] ";
9
+ export async function offerTelemetryConsent(fs, aiwPath, answer) {
10
+ const settings = loadSettings(fs, aiwPath);
11
+ if (settings !== undefined && "telemetry" in settings)
12
+ return loadTelemetryConfig(fs, aiwPath).enabled;
13
+ const response = (await answer(TELEMETRY_CONSENT_PROMPT)).trim().toLowerCase();
14
+ const enabled = response === "y" || response === "yes";
15
+ saveSettingsSection(fs, aiwPath, "telemetry", serializeTelemetryMapping({
16
+ schema: 1,
17
+ enabled,
18
+ includeCommand: true,
19
+ includeOutcome: true,
20
+ }));
21
+ return enabled;
22
+ }
8
23
  const TELEMETRY_COMMANDS = new Set([
9
24
  "adr",
10
25
  "audit-package",
@@ -110,9 +125,54 @@ export function createTelemetryEvent(command, result, config) {
110
125
  ...(config.includeOutcome ? { outcome: result.exitCode === 0 ? "success" : "failure" } : {}),
111
126
  };
112
127
  }
128
+ export const TELEMETRY_BUFFER_FILE = "telemetry-buffer.jsonl";
129
+ export const TELEMETRY_BUFFER_CAP = 200;
130
+ export function telemetryBufferPath(aiwPath) {
131
+ return `${aiwPath}/${TELEMETRY_BUFFER_FILE}`;
132
+ }
133
+ export function readTelemetryBuffer(fs, aiwPath) {
134
+ const path = telemetryBufferPath(aiwPath);
135
+ if (!fs.exists(path))
136
+ return [];
137
+ return fs
138
+ .read(path)
139
+ .split(/\r?\n/)
140
+ .filter((line) => line.trim().length > 0)
141
+ .map((line) => {
142
+ const parsed = JSON.parse(line);
143
+ if (parsed.schema !== 1)
144
+ throw new Error("Invalid buffered telemetry event");
145
+ return parsed;
146
+ });
147
+ }
148
+ export function appendTelemetryBuffer(fs, aiwPath, event) {
149
+ const buffered = [...readTelemetryBuffer(fs, aiwPath), event];
150
+ const rotated = buffered.slice(Math.max(0, buffered.length - TELEMETRY_BUFFER_CAP));
151
+ fs.write(telemetryBufferPath(aiwPath), `${rotated.map((item) => JSON.stringify(item)).join("\n")}\n`);
152
+ }
153
+ export function purgeTelemetryBuffer(fs, aiwPath) {
154
+ const path = telemetryBufferPath(aiwPath);
155
+ if (fs.exists(path) && fs.remove)
156
+ fs.remove(path);
157
+ }
113
158
  export async function recordConfiguredTelemetry(fs, aiwPath, client, command, result) {
114
- if (!client)
159
+ if (!client) {
160
+ try {
161
+ const config = loadTelemetryConfig(fs, aiwPath);
162
+ const settings = loadSettings(fs, aiwPath);
163
+ const configured = (settings !== undefined && "telemetry" in settings) ||
164
+ fs.exists(`${aiwPath}/telemetry.yml`);
165
+ if (configured) {
166
+ const event = createTelemetryEvent(command, result, config);
167
+ if (event)
168
+ appendTelemetryBuffer(fs, aiwPath, event);
169
+ }
170
+ }
171
+ catch {
172
+ // Telemetry must never affect the requested workflow operation.
173
+ }
115
174
  return;
175
+ }
116
176
  try {
117
177
  const config = loadTelemetryConfig(fs, aiwPath);
118
178
  const settings = loadSettings(fs, aiwPath);
@@ -3,4 +3,5 @@ import type { WorkflowDependencies } from "./services.js";
3
3
  export type WorkflowServices = WorkflowDependencies;
4
4
  export declare function runCommand(args: string[], root: string, fs: FileSystem, services?: WorkflowServices): Promise<CommandResult>;
5
5
  export type ConfirmQuestion = (question: string) => Promise<string>;
6
- export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion): Promise<string[]>;
6
+ export type ModelProbe = (baseUrl: string) => Promise<string[] | undefined>;
7
+ export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion, listModels?: ModelProbe): Promise<string[]>;
package/dist/workflow.js CHANGED
@@ -7,8 +7,8 @@ import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomical
7
7
  import { parseProjectProfile, profileProject } from "./profile.js";
8
8
  import { applyOverrides } from "./confirmation.js";
9
9
  import { mergeFacts } from "./intelligence.js";
10
- import { createInterpretationRequest } from "./interpreter.js";
11
- import { recommendCapabilities, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
10
+ import { createInterpretationRequest, createOpenAiCompatibleProvider, createProjectInterpreter, buildInterpreterQuery, loadAiProviderConfig, selectInterpreterKeyFiles, } from "./interpreter.js";
11
+ import { recommendCapabilities, rankRecommendations, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
12
12
  import { selectRecommendations } from "./selection.js";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { stdin, stdout } from "node:process";
@@ -21,8 +21,8 @@ import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
21
21
  import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
22
22
  import { scanProject } from "./scanner.js";
23
23
  import { readFactOverrides, saveSettingOverrides, saveSettingsSection, loadSettings, } from "./settings.js";
24
- import { buildEvidenceTriageRequest, createOllamaDecisionProvider, DEFAULT_DECISION_CONFIG, loadDecisionConfig, } from "./decision.js";
25
- import { buildSemanticIndex, createOllamaEmbeddingProvider, DEFAULT_SEMANTIC_CONFIG, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
24
+ import { buildEvidenceTriageRequest, buildRecommendationRankingRequest, buildSemanticRerankRequest, createOllamaDecisionProvider, DEFAULT_DECISION_CONFIG, loadDecisionConfig, } from "./decision.js";
25
+ import { buildSemanticIndex, createOllamaEmbeddingProvider, DEFAULT_SEMANTIC_CONFIG, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, rerankSemanticMatches, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
26
26
  import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
27
27
  import { planPackageUpdate } from "./package-updates.js";
28
28
  import { searchRegistry, serializeRegistry } from "./registry.js";
@@ -42,7 +42,7 @@ import { evaluateQualityGate } from "./quality-gates.js";
42
42
  import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
43
43
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
44
44
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
45
- import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, parseTelemetryConfig, serializeTelemetryMapping, } from "./telemetry.js";
45
+ import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
46
46
  import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
47
47
  import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
48
48
  export async function runCommand(args, root, fs, services = {}) {
@@ -331,10 +331,12 @@ export async function runCommand(args, root, fs, services = {}) {
331
331
  pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
332
332
  writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
333
333
  let optionalModels = [];
334
+ let telemetryEnabled;
334
335
  if (stdin.isTTY) {
335
336
  const terminal = createInterface({ input: stdin, output: stdout });
336
337
  try {
337
338
  optionalModels = await configureOptionalModels(fs, aiw, (question) => terminal.question(question));
339
+ telemetryEnabled = await offerTelemetryConsent(fs, aiw, (question) => terminal.question(question));
338
340
  }
339
341
  finally {
340
342
  terminal.close();
@@ -345,34 +347,49 @@ export async function runCommand(args, root, fs, services = {}) {
345
347
  output: `AI Workflow initialized for target: ${target} with only the ai-init skill. Run /ai-init to scan the project and choose which resources to install.` +
346
348
  (optionalModels.length
347
349
  ? ` Optional models enabled: ${optionalModels.join(", ")}. They activate once the models are available.`
350
+ : "") +
351
+ (telemetryEnabled !== undefined
352
+ ? telemetryEnabled
353
+ ? " Telemetry enabled (command name and outcome only); disable anytime with `aiw telemetry disable`."
354
+ : " Telemetry disabled; enable anytime with `aiw telemetry enable`."
348
355
  : ""),
349
356
  };
350
357
  }
351
358
  if (command === "telemetry") {
352
359
  if (!fs.exists(join(aiw, "manifest.yml")))
353
360
  return { exitCode: 1, error: "Run `aiw install` first." };
354
- const usage = "Usage: aiw telemetry <status|enable|disable> [--commands=include|exclude] [--outcomes=include|exclude]";
361
+ const usage = "Usage: aiw telemetry <status|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
355
362
  const action = args[1];
356
363
  if (!action)
357
364
  return { exitCode: 1, error: usage };
358
365
  const telemetryConfig = loadTelemetryConfig(fs, aiw);
366
+ const actionArgs = args.slice(2).filter((arg) => arg !== "--verbose");
367
+ const verbose = args.includes("--verbose");
359
368
  if (action === "status") {
360
- if (args.length !== 2)
369
+ if (actionArgs.length !== 0)
361
370
  return { exitCode: 1, error: usage };
362
371
  const config = telemetryConfig;
372
+ const buffered = verbose ? readTelemetryBuffer(fs, aiw) : undefined;
363
373
  return {
364
374
  exitCode: 0,
365
- output: `Telemetry is ${config.enabled ? "enabled" : "disabled"}. Command collection: ${config.includeCommand ? "included" : "excluded"}. Outcome collection: ${config.includeOutcome ? "included" : "excluded"}.`,
375
+ output: `Telemetry is ${config.enabled ? "enabled" : "disabled"}. Command collection: ${config.includeCommand ? "included" : "excluded"}. Outcome collection: ${config.includeOutcome ? "included" : "excluded"}.` +
376
+ (buffered !== undefined
377
+ ? `\nLocal buffer: ${buffered.length} event${buffered.length === 1 ? "" : "s"} (cap ${TELEMETRY_BUFFER_CAP}, .aiw/${TELEMETRY_BUFFER_FILE}).` +
378
+ (buffered.length > 0
379
+ ? `\n${buffered.map((event) => `- ${JSON.stringify(event)}`).join("\n")}`
380
+ : "")
381
+ : ""),
366
382
  };
367
383
  }
368
384
  if (action === "disable") {
369
- if (args.length !== 2)
385
+ if (actionArgs.length !== 0)
370
386
  return { exitCode: 1, error: usage };
387
+ purgeTelemetryBuffer(fs, aiw);
371
388
  saveSettingsSection(fs, aiw, "telemetry", serializeTelemetryMapping(DEFAULT_TELEMETRY_CONFIG));
372
389
  return { exitCode: 0, output: "Telemetry disabled." };
373
390
  }
374
391
  if (action === "enable") {
375
- const options = args.slice(2);
392
+ const options = args.slice(2).filter((arg) => arg !== "--verbose");
376
393
  if (options.some((option) => !/^--(?:commands|outcomes)=(?:include|exclude)$/.test(option)) ||
377
394
  new Set(options.map((option) => option.slice(0, option.indexOf("=")))).size !==
378
395
  options.length)
@@ -424,9 +441,39 @@ export async function runCommand(args, root, fs, services = {}) {
424
441
  if (!fs.exists(join(aiw, "manifest.yml")))
425
442
  return { exitCode: 1, error: "Run `aiw install` first." };
426
443
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
427
- if (services.interpreter) {
444
+ let interpreter = services.interpreter;
445
+ if (!interpreter) {
446
+ const aiConfig = loadAiProviderConfig(fs, aiw);
447
+ if (aiConfig?.enabled)
448
+ interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
449
+ }
450
+ if (interpreter) {
428
451
  const scan = await (services.scanner?.scan(root) ?? scanProject(root));
429
- const inferred = await services.interpreter.interpret(createInterpretationRequest(profile, scan.files));
452
+ let scopedFiles = scan.files;
453
+ let priorityFiles;
454
+ const contextConfig = loadSemanticConfig(fs, aiw);
455
+ const contextIndexPath = join(aiw, SEMANTIC_INDEX_PATH);
456
+ if (contextConfig?.enabled && fs.exists(contextIndexPath)) {
457
+ try {
458
+ const index = parseSemanticIndex(fs.read(contextIndexPath));
459
+ if (index.model === contextConfig.model && index.chunks.length > 0) {
460
+ const embedding = services.embeddingProvider ?? createOllamaEmbeddingProvider(contextConfig);
461
+ if (await embedding.available()) {
462
+ const matches = await querySemanticIndex(index, buildInterpreterQuery(profile), embedding, contextConfig);
463
+ priorityFiles = [
464
+ ...selectInterpreterKeyFiles(scan.files),
465
+ ...matches.map((match) => match.file),
466
+ ];
467
+ const allowed = new Set(priorityFiles);
468
+ scopedFiles = scan.files.filter((file) => allowed.has(file));
469
+ }
470
+ }
471
+ }
472
+ catch {
473
+ // An unreadable index keeps the scan deterministic with alphabetical scoping.
474
+ }
475
+ }
476
+ const inferred = await interpreter.interpret(createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles));
430
477
  profile.facts = mergeFacts(profile.facts, inferred);
431
478
  }
432
479
  const overrides = readFactOverrides(fs, aiw);
@@ -442,7 +489,7 @@ export async function runCommand(args, root, fs, services = {}) {
442
489
  fs.write(join(aiw, "profile.yml"), `schema: 1\nstatus: scanned\nfacts:\n${evidence}\nruntime:\n languages: [${profile.runtime.languages.join(", ")}]\nframeworks: [${profile.frameworks.join(", ")}]\npackage_manager: ${profile.packageManager}\nquality:\n linter: ${profile.quality.linter?.name ?? "unknown"}\n linter_command: ${profile.quality.linter?.command ?? "unknown"}\n formatter: ${profile.quality.formatter?.name ?? "unknown"}\n formatter_command: ${profile.quality.formatter?.command ?? "unknown"}\n typecheck: ${profile.quality.typecheck?.name ?? "unknown"}\n typecheck_command: ${profile.quality.typecheck?.command ?? "unknown"}\ntesting: ${profile.testing?.name ?? "unknown"}\ntesting_command: ${profile.testing?.command ?? "unknown"}\nci: [${profile.ci.join(", ")}]\nworkspaces: [${profile.workspaces.join(", ")}]\nproject_modules: ${JSON.stringify(profile.modules ?? [])}\npolicies:\n artifact_language: en\n`);
443
490
  const semanticConfig = loadSemanticConfig(fs, aiw);
444
491
  if (semanticConfig?.enabled) {
445
- const provider = createOllamaEmbeddingProvider(semanticConfig);
492
+ const provider = services.embeddingProvider ?? createOllamaEmbeddingProvider(semanticConfig);
446
493
  if (!(await provider.available()))
447
494
  return {
448
495
  exitCode: 1,
@@ -511,7 +558,7 @@ export async function runCommand(args, root, fs, services = {}) {
511
558
  return { exitCode: 1, error: usage };
512
559
  }
513
560
  if (command === "semantic") {
514
- const usage = "Usage: aiw semantic query --text=<query> | aiw semantic status";
561
+ const usage = "Usage: aiw semantic query --text=<query> [--rerank] | aiw semantic status";
515
562
  const action = args[1];
516
563
  if (action === "status") {
517
564
  const config = loadSemanticConfig(fs, aiw);
@@ -530,7 +577,9 @@ export async function runCommand(args, root, fs, services = {}) {
530
577
  };
531
578
  }
532
579
  if (action === "query") {
533
- const parsed = parseNamedOptions(args.slice(2), ["text"]);
580
+ const queryArgs = args.slice(2).filter((arg) => arg !== "--rerank");
581
+ const rerankRequested = args.slice(2).includes("--rerank");
582
+ const parsed = parseNamedOptions(queryArgs, ["text"]);
534
583
  if (!parsed?.text)
535
584
  return { exitCode: 1, error: usage };
536
585
  const config = loadSemanticConfig(fs, aiw);
@@ -549,13 +598,38 @@ export async function runCommand(args, root, fs, services = {}) {
549
598
  error: `Semantic model ${config.model} is not available at ${config.baseUrl}.`,
550
599
  };
551
600
  const matches = await querySemanticIndex(parseSemanticIndex(fs.read(indexPath)), parsed.text, provider, config);
601
+ let ranked = matches.map((match) => ({ ...match }));
602
+ let influence;
603
+ if (rerankRequested) {
604
+ const decisionConfig = loadDecisionConfig(fs, aiw);
605
+ if (!decisionConfig?.enabled)
606
+ return {
607
+ exitCode: 1,
608
+ error: "Decision model is disabled. Set decision.enabled=true in .aiw/settings.yml to use --rerank.",
609
+ };
610
+ const decisionProvider = createOllamaDecisionProvider(decisionConfig);
611
+ if (!(await decisionProvider.available()))
612
+ return {
613
+ exitCode: 1,
614
+ error: `Decision model ${decisionConfig.model} is not available at ${decisionConfig.baseUrl}.`,
615
+ };
616
+ const outcome = await rerankSemanticMatches(matches, parsed.text, async (query, candidates) => {
617
+ const response = await decisionProvider.decide(buildSemanticRerankRequest(query, candidates));
618
+ const scores = {};
619
+ for (const [id, answer] of Object.entries(response.answers))
620
+ scores[id] = typeof answer.score === "number" ? answer.score : undefined;
621
+ return scores;
622
+ });
623
+ ranked = outcome.matches;
624
+ influence = `rerank influence: ${outcome.changedPositions}/${outcome.matches.length} positions reordered (advisory; cosine recall unchanged)`;
625
+ }
552
626
  return {
553
627
  exitCode: 0,
554
- output: matches.length === 0
628
+ output: ranked.length === 0
555
629
  ? "No semantic matches."
556
- : matches
557
- .map((match) => `- ${match.file} (score ${match.score.toFixed(3)})\n ${match.excerpt}`)
558
- .join("\n"),
630
+ : `${ranked
631
+ .map((match) => `- ${match.file} (score ${match.score.toFixed(3)}${match.rerankScore !== undefined ? `, rerank ${match.rerankScore.toFixed(3)}` : ""})\n ${match.excerpt}`)
632
+ .join("\n")}${influence ? `\n${influence}` : ""}`,
559
633
  };
560
634
  }
561
635
  return { exitCode: 1, error: usage };
@@ -922,6 +996,33 @@ export async function runCommand(args, root, fs, services = {}) {
922
996
  return { exitCode: 1, error: "Run `aiw install` first." };
923
997
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
924
998
  const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
999
+ let ordered = recommendations;
1000
+ let ranked;
1001
+ const rankingConfig = loadDecisionConfig(fs, aiw);
1002
+ if (rankingConfig?.enabled) {
1003
+ const decisionProvider = services.decisionProvider ?? createOllamaDecisionProvider(rankingConfig);
1004
+ if (!(await decisionProvider.available()))
1005
+ return {
1006
+ exitCode: 1,
1007
+ error: `Decision model ${rankingConfig.model} is not available at ${rankingConfig.baseUrl}.`,
1008
+ };
1009
+ ranked = await rankRecommendations(recommendations, profile, async (scoped, recs) => {
1010
+ const response = await decisionProvider.decide(buildRecommendationRankingRequest(profileSummaryForRanking(scoped), recs));
1011
+ const answers = {};
1012
+ for (const [id, answer] of Object.entries(response.answers))
1013
+ answers[id] =
1014
+ typeof answer.score === "number"
1015
+ ? {
1016
+ relevance: answer.score,
1017
+ ...(typeof answer.confidence === "number"
1018
+ ? { confidence: answer.confidence }
1019
+ : {}),
1020
+ }
1021
+ : {};
1022
+ return answers;
1023
+ });
1024
+ ordered = ranked.items;
1025
+ }
925
1026
  const selectedOption = args.find((arg) => arg.startsWith("--select="));
926
1027
  const selectedArg = selectedOption?.slice("--select=".length) ?? "";
927
1028
  let selection;
@@ -931,15 +1032,18 @@ export async function runCommand(args, root, fs, services = {}) {
931
1032
  selection = [];
932
1033
  else {
933
1034
  const terminal = createInterface({ input: stdin, output: stdout });
934
- selection = await selectRecommendations(recommendations, (question) => terminal.question(question));
1035
+ selection = await selectRecommendations(ordered, (question) => terminal.question(question));
935
1036
  terminal.close();
936
1037
  }
937
- fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(recommendations, selection));
1038
+ fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(ordered, selection, ranked?.ranking));
1039
+ const rankingNote = ranked
1040
+ ? ` Advisory decision-model ranking applied (${ranked.changedPositions} positions reordered); evidence states are unchanged.`
1041
+ : "";
938
1042
  return {
939
1043
  exitCode: 0,
940
- output: selectedOption === undefined && !stdin.isTTY
941
- ? `${recommendations.length} recommendations generated without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id.`
942
- : `${recommendations.length} recommendations generated.`,
1044
+ output: `${ordered.length} recommendations generated${selectedOption === undefined && !stdin.isTTY
1045
+ ? " without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id."
1046
+ : "."}${rankingNote}`,
943
1047
  };
944
1048
  }
945
1049
  if (command === "skills") {
@@ -1795,35 +1899,81 @@ function approvalArtifactPath(aiw, stage) {
1795
1899
  };
1796
1900
  return resolve(aiw, paths[stage]);
1797
1901
  }
1798
- export async function configureOptionalModels(fs, aiwPath, answer) {
1902
+ async function defaultModelProbe(baseUrl) {
1903
+ try {
1904
+ const response = await fetch(`${baseUrl}/api/tags`);
1905
+ if (!response.ok)
1906
+ return undefined;
1907
+ const body = (await response.json());
1908
+ const names = [
1909
+ ...new Set((body.models ?? [])
1910
+ .map((entry) => entry.name)
1911
+ .filter((name) => typeof name === "string")),
1912
+ ];
1913
+ return names.length > 0 ? names.sort() : undefined;
1914
+ }
1915
+ catch {
1916
+ return undefined;
1917
+ }
1918
+ }
1919
+ function defaultModelFor(available, fallback) {
1920
+ if (!available || available.length === 0)
1921
+ return fallback;
1922
+ return available.includes(fallback) ? fallback : available[0];
1923
+ }
1924
+ export async function configureOptionalModels(fs, aiwPath, answer, listModels = defaultModelProbe) {
1799
1925
  const enabled = [];
1800
1926
  const settings = loadSettings(fs, aiwPath) ?? {};
1927
+ let availableModels;
1928
+ if (!("semantic" in settings) || !("decision" in settings))
1929
+ availableModels = await listModels(DEFAULT_SEMANTIC_CONFIG.baseUrl);
1801
1930
  if (!("semantic" in settings)) {
1802
- const response = (await answer(`Enable semantic mapping with a local embedding model (default: ${DEFAULT_SEMANTIC_CONFIG.model} at ${DEFAULT_SEMANTIC_CONFIG.baseUrl})? [y/N] `))
1931
+ const defaultEmbedding = defaultModelFor(availableModels, DEFAULT_SEMANTIC_CONFIG.model);
1932
+ const catalog = availableModels && availableModels.length > 0
1933
+ ? ` Available embedding models: ${availableModels.join(", ")}.`
1934
+ : "";
1935
+ const response = (await answer(`Enable semantic mapping with a local embedding model (default: ${defaultEmbedding} at ${DEFAULT_SEMANTIC_CONFIG.baseUrl})?${catalog} [y/N] `))
1803
1936
  .trim()
1804
1937
  .toLowerCase();
1805
1938
  if (response === "y" || response === "yes") {
1939
+ let model = defaultEmbedding;
1940
+ if (availableModels && availableModels.length > 0) {
1941
+ const selection = (await answer(`Use which embedding model? [${defaultEmbedding}] `)).trim();
1942
+ if (selection.length > 0 && availableModels.includes(selection))
1943
+ model = selection;
1944
+ }
1806
1945
  saveSettingsSection(fs, aiwPath, "semantic", {
1807
1946
  enabled: true,
1808
- model: DEFAULT_SEMANTIC_CONFIG.model,
1947
+ model,
1809
1948
  baseUrl: DEFAULT_SEMANTIC_CONFIG.baseUrl,
1810
1949
  chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
1811
1950
  topK: DEFAULT_SEMANTIC_CONFIG.topK,
1951
+ excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
1812
1952
  });
1813
- enabled.push(`semantic (${DEFAULT_SEMANTIC_CONFIG.model})`);
1953
+ enabled.push(`semantic (${model})`);
1814
1954
  }
1815
1955
  }
1816
1956
  if (!("decision" in settings)) {
1817
- const response = (await answer(`Enable advisory decision triage with a local decision model (default: ${DEFAULT_DECISION_CONFIG.model} at ${DEFAULT_DECISION_CONFIG.baseUrl})? [y/N] `))
1957
+ const defaultDecision = defaultModelFor(availableModels, DEFAULT_DECISION_CONFIG.model);
1958
+ const catalog = availableModels && availableModels.length > 0
1959
+ ? ` Available decision models: ${availableModels.join(", ")}.`
1960
+ : "";
1961
+ const response = (await answer(`Enable advisory decision triage with a local decision model (default: ${defaultDecision} at ${DEFAULT_DECISION_CONFIG.baseUrl})?${catalog} [y/N] `))
1818
1962
  .trim()
1819
1963
  .toLowerCase();
1820
1964
  if (response === "y" || response === "yes") {
1965
+ let model = defaultDecision;
1966
+ if (availableModels && availableModels.length > 0) {
1967
+ const selection = (await answer(`Use which decision model? [${defaultDecision}] `)).trim();
1968
+ if (selection.length > 0 && availableModels.includes(selection))
1969
+ model = selection;
1970
+ }
1821
1971
  saveSettingsSection(fs, aiwPath, "decision", {
1822
1972
  enabled: true,
1823
- model: DEFAULT_DECISION_CONFIG.model,
1973
+ model,
1824
1974
  baseUrl: DEFAULT_DECISION_CONFIG.baseUrl,
1825
1975
  });
1826
- enabled.push(`decision (${DEFAULT_DECISION_CONFIG.model})`);
1976
+ enabled.push(`decision (${model})`);
1827
1977
  }
1828
1978
  }
1829
1979
  return enabled;
@@ -2193,6 +2343,15 @@ function safeProjectSignal(value) {
2193
2343
  .replace(/\s+/g, " ")
2194
2344
  .slice(0, 180);
2195
2345
  }
2346
+ function profileSummaryForRanking(profile) {
2347
+ return [
2348
+ `languages: ${profile.runtime.languages.join(", ") || "none"}`,
2349
+ `frameworks: ${profile.frameworks.join(", ") || "none"}`,
2350
+ `package-manager: ${profile.packageManager}`,
2351
+ `testing: ${profile.testing?.name ?? "none"}`,
2352
+ `linter: ${profile.quality.linter?.name ?? "none"}`,
2353
+ ].join("; ");
2354
+ }
2196
2355
  function readSelectedRecommendations(fs, aiw) {
2197
2356
  if (!fs.exists(join(aiw, "recommendations.yml")))
2198
2357
  return [];
package/docs/backlog.md CHANGED
@@ -29,8 +29,10 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
29
29
  - [x] **SCAN-006** Implement scoped AI project interpreter. _Acceptance:_ interpreter receives filtered context and produces structured insights requiring confirmation when uncertain.
30
30
  - [x] **SCAN-007** Add interpreter privacy and token guardrails. _Acceptance:_ secrets, dependencies, generated files, Git internals, and checkpoints are excluded and token usage is measured.
31
31
  - [x] **SCAN-004** Add interactive confirmation and overrides. _Acceptance:_ users can accept, reject, or edit inferred facts.
32
+ - [x] **INT-001** Ship a built-in OpenAI-compatible `AiProvider`. _Acceptance:_ an `aiw` settings section configures a local or remote OpenAI-compatible endpoint (for example Ollama `gpt-oss:20b`) so the scoped AI interpreter works without a host-injected provider; secrets redaction, token ceilings, and scoped-context validation still apply; missing configuration keeps scans deterministic and offline.
32
33
  - [x] **REC-001** Build capability recommendation engine. _Acceptance:_ recommendations include rationale, evidence, permissions, conflicts, and confidence.
33
34
  - [x] **REC-002** Add interactive capability selection. _Acceptance:_ users select packages and individual resources before installation.
35
+ - [x] **REC-003** Rank capability recommendations with a decision model. _Acceptance:_ when a decision model is enabled in settings, recommendations are ordered by decision-model relevance to the confirmed profile with confidence and rationale; ranking is advisory and never changes evidence states. See `docs/decision-model-evaluation.md`.
34
36
  - [x] **GEN-001** Generate project-specific rules, agents, hooks, and context. _Acceptance:_ generated content uses confirmed profile data and English artifacts.
35
37
  - [x] **GEN-002** Preserve manual overrides and detect drift. _Acceptance:_ regeneration never silently overwrites user changes.
36
38
 
@@ -70,6 +72,23 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
70
72
 
71
73
  - [x] **SEM-001** Add flag-gated semantic mapping to scan and retrieval. _Acceptance:_ `.aiw/semantic.yml` with `enabled: true` verifies model availability before indexing, builds a versioned chunk index during `aiw scan`, and `aiw semantic query` returns ranked matches; missing or disabled configuration keeps scans fully deterministic and offline; semantic results are retrieval aids, never confirmed facts.
72
74
  - [x] **SEM-002** Integrate a decision model (jev/clef-flash via Ollama `/v1/systemone`) for advisory gate triage. _Acceptance:_ `.aiw/decision.yml` with `enabled: true` verifies model availability before use; `aiw decision triage` returns advisory `evidence_complete` and `gate` decisions with confidence; disabled or missing configuration makes no network request; advisory output never replaces human approval. See `docs/decision-model-evaluation.md`.
75
+ - [x] **SEM-003** Embed chunks in batches. _Acceptance:_ indexing uses the batch embeddings endpoint (for example Ollama `/api/embed`) instead of one request per chunk, measurably reducing scan time on this repository (baseline: ~34s for 824 chunks) with identical index output.
76
+ - [x] **SEM-004** Exclude lockfiles and generated vendor manifests from the semantic index. _Acceptance:_ files such as `package-lock.json` (observed contributing 75 noise chunks) are skipped during indexing by default with an explicit, documented override.
77
+ - [x] **SEM-005** Re-rank semantic recall with a decision model. _Acceptance:_ when both semantic mapping and a decision model are enabled, `aiw semantic query` optionally re-ranks embedding matches with the decision model, reporting the ranking influence; recall without a decision model is unchanged.
78
+ - [x] **SEM-006** Scope AI interpreter context with semantic retrieval. _Acceptance:_ when semantic mapping is enabled and an index exists, the interpreter receives deterministic key files plus the top semantically relevant chunks for the active analysis question instead of alphabetically ordered files up to the token ceiling; measured token usage decreases and inferred-fact quality does not regress against the current baseline; scans without semantic mapping are unchanged.
79
+
80
+ ## Onboarding — P1
81
+
82
+ - [x] **TEL-001** Offer telemetry consent during installation. _Acceptance:_ interactive install asks about telemetry after the model questions with an explicit default of No, states the exact event payload (command name and outcome only, no arguments, paths, or contents), records the decision in `.aiw/settings.yml`, and remains reversible through `aiw telemetry enable/disable`; non-interactive installs skip the prompt; the prompt itself never emits an event.
83
+
84
+ ## Telemetry — P2
85
+
86
+ - [x] **TEL-002** Add an inspectable local telemetry buffer. _Acceptance:_ when telemetry is enabled and a host client is absent, events accumulate in a rotated local buffer under `.aiw/` with a documented retention cap; `aiw telemetry status --verbose` displays buffered events; `aiw telemetry disable` purges the buffer; no network transport is introduced by this item.
87
+
88
+ - [x] **ONB-001** Detect and offer real local models during installation. _Acceptance:_ interactive install probes the configured Ollama endpoint (`/api/tags`), lists the embedding and decision models actually available, and records the user's selection in `.aiw/settings.yml`; when no server or models are found, the installer reports the optional capability and continues without network assumptions; non-interactive installs are unchanged.
89
+
90
+ ## Context and cost — P2 (continued)
91
+
73
92
  - [x] **CTX-001** Build layered project context store.
74
93
  - [x] **CTX-002** Add task-scoped context retrieval and delta loading.
75
94
  - [x] **CTX-003** Add summary cache and freshness invalidation.
@@ -86,6 +105,10 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
86
105
  - [x] **ENT-006** Add web UI for project profile, recommendations, traceability, and migrations. _Acceptance:_ `aiw ui` serves a local-only, responsive and accessible dashboard from packaged project state; profile, recommendations, and requirement evidence are visible; migrations require explicit confirmation and delegate to the existing workflow.
87
106
  - [x] **ENT-007** Add multi-agent orchestration and parallel task execution. _Acceptance:_ strict versioned task graphs support deterministic preview, explicit adapter-backed execution, bounded parallelism, dependency-aware failure isolation, and immutable checkpoint evidence.
88
107
 
108
+ ## Engineering — P2
109
+
110
+ - [x] **ENG-001** Update GitHub Actions to Node 24-native versions. _Acceptance:_ `actions/checkout` and `actions/setup-node` upgraded past v4 so release workflows no longer emit Node.js 20 deprecation warnings; CI and Release workflows pass on `ubuntu-latest` before and after the October 19, 2026 Ubuntu 26 runner migration.
111
+
89
112
  ## Backlog rules
90
113
 
91
114
  - [x] **FND-009** Add self-validation workflow. _Acceptance:_ changes affecting AIW behavior execute against this repository and record generated-output evidence.
@@ -34,7 +34,7 @@ Observed behavior on real plans: an evidence-free plan scored `gate: ready` with
34
34
 
35
35
  ## Future integration points (advisory)
36
36
 
37
- - **Recommendation ranking**: order capability recommendations by decision-model relevance to the confirmed profile.
37
+ - **Recommendation ranking**: order capability recommendations by decision-model relevance to the confirmed profile. _Status:_ implemented 2026-10-08 behind `decision.enabled` — `aiw recommend` sends one `score` question per recommendation (with the confirmed profile summary and the recommendation rationale/evidence) through `/v1/systemone`, orders the output by advisory relevance, and serializes `decision_relevance` / `decision_confidence` per item. The ranking is advisory: evidence, confidence, and selection logic are unchanged, and without a decision model the deterministic order is preserved.
38
38
  - **Semantic re-ranking**: combine embedding recall (`aiw semantic query`) with clef re-ranking for sharper context selection during interpretation.
39
39
 
40
40
  ## Hard boundary (unchanged)
@@ -58,4 +58,16 @@ Before a provider call, the interpreter:
58
58
  - redacts common credential assignments, bearer tokens, and private keys;
59
59
  - rejects inferred evidence that cites any file outside the scoped request.
60
60
 
61
- The workflow accepts an injected interpreter service. A configured interpreter runs during `aiw scan`, and its structured facts are stored in `.aiw/profile.yml` alongside deterministic facts. Facts below the confidence threshold are marked `requires_confirmation: true`. Without a configured provider, scans remain deterministic and do not make a network request.
61
+ The workflow accepts an injected interpreter service. Additionally, a built-in OpenAI-compatible provider can be configured through an `ai` section in `.aiw/settings.yml` so the interpreter works without a host-injected service:
62
+
63
+ ```yaml
64
+ ai:
65
+ enabled: true
66
+ model: gpt-oss:20b # any model served by an OpenAI-compatible endpoint
67
+ baseUrl: http://localhost:11434 # local (Ollama) or remote
68
+ apiKeyEnv: AIW_AI_KEY # optional; bearer token read from this environment variable
69
+ ```
70
+
71
+ The provider posts to `<baseUrl>/v1/chat/completions` with a JSON response format and the interpreter's token ceiling; all existing guardrails (secrets redaction, scoped-context validation, binary and sensitive-file exclusion) apply unchanged because the same interpreter pipeline is used. An injected interpreter service always takes precedence. Without an `ai` section, scans remain deterministic and do not make a network request.
72
+
73
+ A configured interpreter runs during `aiw scan`, and its structured facts are stored in `.aiw/profile.yml` alongside deterministic facts. Facts below the confidence threshold are marked `requires_confirmation: true`.
@@ -1,6 +1,6 @@
1
1
  # Semantic Mapping
2
2
 
3
- AI Workflow can build a semantic index of the scanned project with a local embedding model such as `embeddinggemma-2` served by Ollama. Semantic mapping is opt-in and flag-gated: it activates only when a valid configuration exists, and every scan and flow query re-reads the flag. Configuration lives in the local user settings file `.aiw/settings.yml` under the `semantic` section; the legacy `.aiw/semantic.yml` file is still read when `settings.yml` does not exist. During an interactive `aiw install`, the installer offers to create this configuration with defaults; answering `y` writes it before `/ai-init` runs. Declining, or installing non-interactively, leaves the section absent.
3
+ AI Workflow can build a semantic index of the scanned project with a local embedding model such as `embeddinggemma-2` served by Ollama. Semantic mapping is opt-in and flag-gated: it activates only when a valid configuration exists, and every scan and flow query re-reads the flag. Configuration lives in the local user settings file `.aiw/settings.yml` under the `semantic` section; the legacy `.aiw/semantic.yml` file is still read when `settings.yml` does not exist. During an interactive `aiw install`, the installer probes the configured Ollama endpoint (`GET /api/tags`), lists the models actually available, and offers to create this configuration — the user's selected model is recorded in `.aiw/settings.yml`; when no server or models are found, the installer reports the optional capability and continues with defaults. Answering `n`, or installing non-interactively, leaves the section absent.
4
4
 
5
5
  ## Configuration
6
6
 
@@ -13,6 +13,7 @@ semantic:
13
13
  baseUrl: http://localhost:11434
14
14
  chunkChars: 1200 # optional, minimum 200
15
15
  topK: 8 # optional, results per query
16
+ excludeLockfiles: true # optional, skip lockfiles and generated vendor manifests (default: true)
16
17
  ```
17
18
 
18
19
  - `enabled: false` (or a missing file) disables semantic mapping entirely; scans stay deterministic and make no network request.
@@ -22,18 +23,28 @@ semantic:
22
23
 
23
24
  When semantic mapping is enabled, `aiw scan` additionally:
24
25
 
25
- 1. Reads the scanned files with strict UTF-8 decoding (unreadable files are skipped).
26
+ 1. Reads the scanned files with strict UTF-8 decoding (unreadable files are skipped). Lockfiles and generated vendor manifests (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `go.sum`, `Cargo.lock`, `poetry.lock`, `Gemfile.lock`, `composer.lock`, `deno.lock`, `bun.lock`, `bun.lockb`, `uv.lock`, `Pipfile.lock`, `flake.lock`, `npm-shrinkwrap.json`) are skipped by default because they contribute noise chunks without retrieval value; set `excludeLockfiles: false` to index them explicitly.
26
27
  2. Splits each file into line-aligned chunks of at most `chunkChars` characters.
27
- 3. Embeds each chunk with `POST /api/embeddings`.
28
+ 3. Embeds chunks in batches of up to 64 per request with `POST /api/embed` (Ollama batch embeddings endpoint) instead of one request per chunk.
28
29
  4. Writes the versioned index to `.aiw/semantic/index.json` (`schema: 1`).
29
30
 
30
31
  The index stores the model name; queries against an index built with a different model fail explicitly.
31
32
 
32
33
  ## Queries during the flow
33
34
 
34
- `aiw semantic query --text="<natural language>"` ranks indexed chunks by cosine similarity and returns the top `topK` matches with file, score, and excerpt. `aiw semantic status` reports whether mapping is configured and whether the index is built.
35
+ `aiw semantic query --text="<natural language>" [--rerank]` ranks indexed chunks by cosine similarity and returns the top `topK` matches with file, score, and excerpt. With `--rerank`, and only when a decision model is also enabled and available, the decision model additionally scores each retrieved candidate 0–1 for query relevance and the output is reordered by that advisory score; matches without a decision score keep their cosine order. The command reports the ranking influence (how many positions were reordered) and the per-match `rerank` score; cosine recall itself is unchanged and reranking never creates confirmed facts. `aiw semantic status` reports whether mapping is configured and whether the index is built.
35
36
 
36
37
  ## Boundaries
37
38
 
38
39
  - Semantic results are retrieval aids, not evidence. They never create confirmed project facts and never bypass human approval gates.
39
40
  - The index is derived state; delete and rebuild it with `aiw scan` after structural changes.
41
+
42
+ ## Interpreter scoping
43
+
44
+ When semantic mapping is enabled and a valid index built with the active model exists, `aiw scan` scopes the AI interpreter's context semantically instead of alphabetically:
45
+
46
+ 1. A deterministic set of key files (README, contributing/architecture docs, and primary package manifests such as `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`) is always included first, in a fixed priority order.
47
+ 2. The remaining context is the top semantically relevant files for a deterministic analysis query derived from the detected profile (languages, frameworks, package manager), ranked through the existing embedding index.
48
+ 3. Everything else is excluded, so measured token usage decreases while inferred-fact evidence still points at real scoped files.
49
+
50
+ Without semantic mapping (or when the index is missing, built with another model, or the embedding model is unavailable), the interpreter keeps the previous deterministic alphabetical scoping and scans make no network request for scoping purposes.
package/docs/telemetry.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  AI Workflow telemetry is disabled by default. No event is recorded unless a user explicitly runs `aiw telemetry enable` and a host application provides a telemetry client.
4
4
 
5
+ ## Installation consent
6
+
7
+ During an interactive `aiw install`, after the optional model questions, the installer asks whether to enable anonymous telemetry with an explicit default of **No**. The question states the exact event payload: the command name and its outcome only — never arguments, file paths, or file contents. The answer is recorded in `.aiw/settings.yml` under the `telemetry` section (declining is recorded too, so the question is never repeated), and the decision is reversible at any time with `aiw telemetry enable` / `aiw telemetry disable`. Non-interactive installs skip the prompt entirely and leave the section absent, and the prompt itself never emits a telemetry event.
8
+
5
9
  ## Controls
6
10
 
7
11
  ```bash
@@ -15,6 +19,14 @@ Preferences are stored in the local user settings file `.aiw/settings.yml` under
15
19
 
16
20
  Rejected telemetry administration commands never produce telemetry events, even when a previous valid configuration enabled collection. This keeps malformed consent or privacy changes outside the telemetry stream.
17
21
 
22
+ ## Local buffer
23
+
24
+ When telemetry is enabled but no host application provides a telemetry client, events accumulate in a rotated local buffer at `.aiw/telemetry-buffer.jsonl` (one JSON event per line, retention cap of 200 events — the oldest events are dropped first). This introduces no network transport: the buffer is written only to the local `.aiw/` directory.
25
+
26
+ - `aiw telemetry status --verbose` displays the number of buffered events, the retention cap, and each buffered event verbatim.
27
+ - `aiw telemetry disable` purges the buffer.
28
+ - When a host client is present, events go to the client and the local buffer stays untouched.
29
+
18
30
  ## Data contract
19
31
 
20
32
  An event contains only:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multileaf/ai-workflow",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Portable spec-driven development workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {