@multileaf/ai-workflow 0.5.0 → 0.6.1

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,35 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.1] - 2026-10-13
8
+
9
+ ### Added
10
+
11
+ - Root context rendering: syncing a non-empty resource selection now writes the generated project context to the active target's root file (`AGENTS.md` for Codex/Cursor, `CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI), and `aiw target` migrates it between targets. The file is owned by AI Workflow, listed in the sync preview, and an existing hand-written root context is reported as a conflict instead of being overwritten.
12
+ - Decision-model usage in `aiw recommend` is now recorded in `.aiw/usage.yml` (`recommend.decision`) alongside the scan stages.
13
+
14
+ ### Changed
15
+
16
+ - Release workflows require a single protected-environment approval: the `github-release` job no longer declares the `npm` environment it did not need.
17
+
18
+ ### Added
19
+
20
+ - Root context rendering: syncing a non-empty resource selection now writes the generated project context to the active target's root file (`AGENTS.md` for Codex/Cursor, `CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI), and `aiw target` migrates it between targets. The file is owned by AI Workflow, listed in the sync preview, and an existing hand-written root context is reported as a conflict instead of being overwritten.
21
+
22
+ ## [0.6.0] - 2026-10-13
23
+
24
+ ### Added
25
+
26
+ - Guided provider onboarding during interactive `aiw install`: each capability (semantic embedding, then decision triage) asks whether to configure it, offers Ollama or an external OpenAI-compatible provider (external only when Ollama is unreachable), lists the locally available models (embedding-only filtering for semantic), asks permission to run `ollama pull embeddinggemma-2` when no embedding models exist, collects endpoint URL, model name, and an optional token environment variable for external providers, and validates the setup with a live request before saving — with retry or skip on failure.
27
+ - External provider support: `semantic` and `decision` settings accept `apiKeyEnv` (an uppercase environment variable name); the token is read from the environment at call time and never stored. Semantic requests go to `<baseUrl>/v1/embeddings` with bearer authentication and decision requests send `Authorization` headers.
28
+ - Model usage reporting: every `aiw scan` states which models were actually used — embedding (chunks indexed or retrieval queries, batch request count, estimated tokens) and interpreter (model, facts inferred, scoped files, estimated tokens) — or reports `Models used: none (deterministic scan).`
29
+ - Per-stage token estimates recorded in `.aiw/usage.yml` (`scan.embedding`, `scan.interpreter`) and inspectable with `aiw token-usage`. Token counts are local project state; telemetry never carries them.
30
+
31
+ ### Fixed
32
+
33
+ - Interactive onboarding messages shortened to single-line prompts.
34
+ - Embedding-model classification during onboarding now requires positive evidence (model name contains "embed" or Ollama family "embedding"); the previous heuristic misclassified `gpt-oss:20b` as an embedding model.
35
+
7
36
  ## [0.5.0] - 2026-10-08
8
37
 
9
38
  ### Added
@@ -20,6 +49,7 @@ All notable changes to this project are documented in this file. The format foll
20
49
  ### Changed
21
50
 
22
51
  - GitHub Actions now use Node 24-native `actions/checkout@v5` and `actions/setup-node@v5`, and CI runs on Node 24.
52
+
23
53
  ## [0.4.1] - 2026-10-08
24
54
 
25
55
  ### Fixed
package/README.md CHANGED
@@ -19,7 +19,7 @@ AI-assisted development often relies on implicit prompts, duplicated configurati
19
19
 
20
20
  ## Installation
21
21
 
22
- The published package supports Node.js 18 or newer. Full development checks require Node.js 20.19 or newer for the current lint and test toolchain; CI runs on Node.js 20 and release validation runs on Node.js 24. Node.js 18 remains the declared runtime floor but is not currently in the release validation matrix.
22
+ The published package supports Node.js 18 or newer. Full development checks require Node.js 20.19 or newer for the current lint and test toolchain; CI and release validation run on Node.js 24. Node.js 18 remains the declared runtime floor but is not currently in the release validation matrix.
23
23
 
24
24
  ```bash
25
25
  npx @multileaf/ai-workflow install --target codex
@@ -27,6 +27,8 @@ npx @multileaf/ai-workflow install --target codex
27
27
 
28
28
  Then run `/ai-init` in Codex. The bootstrap scans the repository, explains project-specific recommendations, and waits for the user's selections before installing optional resources.
29
29
 
30
+ During an interactive install, AI Workflow also offers optional local capabilities after the base installation: it probes the configured Ollama endpoint, offers Ollama or an external OpenAI-compatible provider (endpoint URL plus an optional token environment variable), lists the embedding and decision models actually available, can run `ollama pull` for a default embedding model when none exist, and validates the chosen setup with a live request before recording it in `.aiw/settings.yml`. It then asks for telemetry consent with an explicit default of No (see [Privacy and security](#privacy-and-security)). Non-interactive installs skip every prompt.
31
+
30
32
  Supported targets are `codex`, `claude`, `cursor`, `gemini`, `copilot`, and `universal`. Every command uses the same entry point:
31
33
 
32
34
  ```bash
@@ -61,7 +63,7 @@ For a Codex installation, the base layout is:
61
63
 
62
64
  `.aiw/` stores the provider-neutral manifest, profile, lockfile, checkpoints, bootstrap skill, selected resources, and generated working artifacts. ADRs are stored under `.context/adrs/`. The installer intentionally adds only `ai-init` and the base project state.
63
65
 
64
- Run `/ai-init` in the selected coding agent to scan the project and review evidence-based recommendations for skills, rules, agents, hooks, and templates. AI Workflow installs only the resources the user selects, then generates project-specific quality guidance from the detected stack and tools. Existing user files are preserved; conflicts are reported instead of overwritten. Hook resources are rendered according to each adapter's native or compatibility support.
66
+ Run `/ai-init` in the selected coding agent to scan the project and review evidence-based recommendations for skills, rules, agents, hooks, and templates. AI Workflow installs only the resources the user selects, then generates project-specific quality guidance from the detected stack and tools. Syncing a non-empty selection also renders a root context file for the active target — `AGENTS.md` (Codex/Cursor), `CLAUDE.md` (Claude Code), `GEMINI.md` (Gemini CLI) — with the confirmed profile, quality commands, and pointers to `.aiw/` state; `aiw target` migrates it between targets. An existing hand-written root context file is reported as a conflict and never overwritten. Existing user files are preserved; conflicts are reported instead of overwritten. Hook resources are rendered according to each adapter's native or compatibility support.
65
67
 
66
68
  The installer does not modify `.gitignore`. Add `.aiw/generated/` yourself if generated specifications, plans, and artifacts should remain local. Versioned files under `.aiw/checkpoints/` are designed to remain reviewable project history.
67
69
 
@@ -241,6 +243,18 @@ Analysis uses two complementary layers:
241
243
 
242
244
  Detected facts cannot be replaced by conflicting AI output. Inferred facts remain unconfirmed until the user accepts, rejects, or edits them. Context selection excludes known sensitive filenames, binaries, dependencies, Git internals, generated output, and checkpoints, then applies pattern-based redaction. This is a defense-in-depth filter, not a general secret scanner; review eligible text before enabling an external AI provider.
243
245
 
246
+ The interpreter accepts an injected provider from a host application, or a built-in OpenAI-compatible provider configured through an `ai` section in `.aiw/settings.yml` — for example a local Ollama model such as `gpt-oss:20b` — so scans can interpret projects without any host integration. Without the section, scans stay deterministic and offline. See [Project Intelligence](docs/project-intelligence.md).
247
+
248
+ ## Optional local models
249
+
250
+ AI Workflow can use local models served by an OpenAI-compatible endpoint (Ollama by default) for three opt-in capabilities, all gated by `.aiw/settings.yml` sections and disabled by default:
251
+
252
+ - **Semantic mapping** (`semantic` section): `aiw scan` builds a versioned chunk index with batched embedding requests, skipping lockfiles and generated vendor manifests; `aiw semantic query` returns ranked matches and can optionally re-rank them with the decision model (`--rerank`). When an index exists, the AI interpreter's context is scoped to deterministic key files plus the top semantically relevant files, reducing token usage. See [Semantic Mapping](docs/semantic-mapping.md).
253
+ - **Advisory decision triage** (`decision` section): `aiw decision triage` returns advisory evidence-completeness and gate decisions with confidence, and `aiw recommend` orders capability recommendations by decision-model relevance to the confirmed profile. Ranking is advisory and never changes evidence states or bypasses human approval. See [Decision Model Evaluation](docs/decision-model-evaluation.md).
254
+ - **Built-in AI interpretation** (`ai` section): the scoped project interpreter works without a host-injected provider.
255
+
256
+ All model-driven features verify model availability before use and degrade to fully deterministic behavior when unconfigured. Every `aiw scan` reports which models were actually used — embedding (chunks indexed or retrieval queries) and interpreter (facts inferred, estimated tokens) — and records token estimates per stage in `.aiw/usage.yml`, inspectable with `aiw token-usage`. Token accounting is local project state; telemetry never carries token counts or any content.
257
+
244
258
  ## Resource packages and recommendations
245
259
 
246
260
  Resources are distributed as versioned packages that may contain:
@@ -326,10 +340,10 @@ The typed SDK is available from `@multileaf/ai-workflow/plugin-sdk`. It exports
326
340
 
327
341
  ## Privacy and security
328
342
 
329
- Telemetry is disabled by default and requires explicit consent. It records only approved aggregate fields through an injectable transport.
343
+ Telemetry is disabled by default and requires explicit consent — offered once during interactive installation with an explicit default of No. Each event contains only the command name and its outcome; never arguments, file paths, or file contents. When telemetry is enabled but no host transport is configured, events accumulate in a rotated local buffer (`.aiw/telemetry-buffer.jsonl`, 200-event cap) that `aiw telemetry status --verbose` can inspect and `aiw telemetry disable` purges.
330
344
 
331
345
  ```bash
332
- npx @multileaf/ai-workflow telemetry status
346
+ npx @multileaf/ai-workflow telemetry status --verbose
333
347
  npx @multileaf/ai-workflow telemetry enable
334
348
  npx @multileaf/ai-workflow telemetry disable
335
349
  ```
@@ -364,6 +378,9 @@ npm run release:check
364
378
  - [Quality and testing](docs/quality.md)
365
379
  - [Token efficiency](docs/token-efficiency.md)
366
380
  - [Multi-agent orchestration](docs/orchestration.md)
381
+ - [Semantic mapping](docs/semantic-mapping.md)
382
+ - [Decision model evaluation](docs/decision-model-evaluation.md)
383
+ - [Telemetry and privacy](docs/telemetry.md)
367
384
  - [Release process](docs/release-process.md)
368
385
 
369
386
  ## License
@@ -3,6 +3,7 @@ export type DecisionConfig = {
3
3
  enabled: boolean;
4
4
  model: string;
5
5
  baseUrl: string;
6
+ apiKeyEnv?: string;
6
7
  };
7
8
  export type DecisionQuestion = {
8
9
  type: "noul";
@@ -47,7 +48,8 @@ export declare function serializeDecisionConfig(config: DecisionConfig): string;
47
48
  export declare function parseDecisionConfig(text: string): DecisionConfig;
48
49
  export declare function loadDecisionConfig(fs: FileSystem, aiwPath: string): DecisionConfig | undefined;
49
50
  export declare function parseDecisionResponse(text: string): DecisionResponse;
50
- export declare function createOllamaDecisionProvider(config: DecisionConfig): DecisionProvider;
51
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
52
+ export declare function createOllamaDecisionProvider(config: DecisionConfig, fetchImpl?: FetchLike, env?: Readonly<Record<string, string | undefined>>): DecisionProvider;
51
53
  export type RecommendationRankingCandidate = {
52
54
  id: string;
53
55
  rationale: string;
@@ -60,3 +62,4 @@ export type SemanticRerankCandidate = {
60
62
  };
61
63
  export declare function buildSemanticRerankRequest(query: string, candidates: SemanticRerankCandidate[]): DecisionRequest;
62
64
  export declare function buildEvidenceTriageRequest(artifact: string): DecisionRequest;
65
+ export {};
package/dist/decision.js CHANGED
@@ -13,18 +13,22 @@ export function parseDecisionMapping(raw) {
13
13
  const enabled = record.enabled;
14
14
  const model = record.model;
15
15
  const baseUrl = record.baseUrl;
16
+ const apiKeyEnv = record.apiKeyEnv;
16
17
  if (typeof enabled !== "boolean")
17
18
  throw new Error("decision.enabled must be a boolean");
18
19
  if (!enabled)
19
20
  return { ...DEFAULT_DECISION_CONFIG, enabled: false };
21
+ if (apiKeyEnv !== undefined &&
22
+ (typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
23
+ throw new Error("decision.apiKeyEnv must be an uppercase environment variable name");
20
24
  if (typeof model !== "string" || model.trim() === "")
21
25
  throw new Error("decision.model must be a non-empty string when decision mapping is enabled");
22
26
  if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
23
27
  throw new Error("decision.baseUrl must be an http(s) URL when decision mapping is enabled");
24
- return { enabled: true, model, baseUrl };
28
+ return { enabled: true, model, baseUrl, ...(apiKeyEnv ? { apiKeyEnv } : {}) };
25
29
  }
26
30
  export function serializeDecisionConfig(config) {
27
- return `decision:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n`;
31
+ return `decision:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}${config.apiKeyEnv ? `\n apiKeyEnv: ${config.apiKeyEnv}` : ""}\n`;
28
32
  }
29
33
  export function parseDecisionConfig(text) {
30
34
  const document = parseDocument(text);
@@ -60,11 +64,19 @@ export function parseDecisionResponse(text) {
60
64
  result[key] = parseAnswer(value);
61
65
  return { model: parsed.model, answers: result };
62
66
  }
63
- export function createOllamaDecisionProvider(config) {
67
+ export function createOllamaDecisionProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
68
+ const externalToken = config.apiKeyEnv ? env[config.apiKeyEnv] : undefined;
69
+ const authHeaders = (extra = {}) => externalToken ? { ...extra, authorization: `Bearer ${externalToken}` } : extra;
64
70
  return {
65
71
  async available() {
66
72
  try {
67
- const response = await fetch(`${config.baseUrl}/api/tags`);
73
+ if (config.apiKeyEnv !== undefined) {
74
+ const response = await fetchImpl(`${config.baseUrl}/v1/models`, {
75
+ headers: authHeaders(),
76
+ });
77
+ return response.ok;
78
+ }
79
+ const response = await fetchImpl(`${config.baseUrl}/api/tags`);
68
80
  if (!response.ok)
69
81
  return false;
70
82
  const body = (await response.json());
@@ -81,9 +93,9 @@ export function createOllamaDecisionProvider(config) {
81
93
  const questionCount = Object.keys(request.questions).length;
82
94
  if (questionCount === 0 || questionCount > 64)
83
95
  throw new Error("Decision requests must contain between 1 and 64 questions");
84
- const response = await fetch(`${config.baseUrl}/v1/systemone`, {
96
+ const response = await fetchImpl(`${config.baseUrl}/v1/systemone`, {
85
97
  method: "POST",
86
- headers: { "content-type": "application/json" },
98
+ headers: authHeaders({ "content-type": "application/json" }),
87
99
  body: JSON.stringify({
88
100
  model: config.model,
89
101
  state: request.state,
package/dist/generator.js CHANGED
@@ -23,6 +23,7 @@ export function generateProjectResources(profile, selected, write) {
23
23
  write(resolveGeneratedPath("agents/project-context.md"), `# Project Context\n\nThis project uses ${profile.frameworks.map((framework) => (framework === "nextjs" ? "Next.js" : framework)).join(", ")}. Follow the detected project structure and existing framework conventions.\n`);
24
24
  if (selected.length > 0) {
25
25
  write(resolveGeneratedPath("context/project-profile.md"), `# Confirmed Project Profile\n\n- Languages: ${profile.runtime.languages.join(", ") || "none detected"}\n- Frameworks: ${profile.frameworks.join(", ") || "none detected"}\n- Package manager: ${profile.packageManager}\n- All generated content is in English.\n`);
26
+ write(resolveGeneratedPath("context/root-context.md"), `# AI Workflow Project Context\n\nThis repository is managed by AI Workflow (\`.aiw/\` holds the neutral project state).\n\n- Languages: ${profile.runtime.languages.join(", ") || "none detected"}\n- Frameworks: ${profile.frameworks.join(", ") || "none detected"}\n- Package manager: ${profile.packageManager}${linterCommand ? `\n- Lint: \`${linterCommand}\`` : ""}${formatterCommand ? `\n- Format: \`${formatterCommand}\`` : ""}${uniqueTestCommands.map(({ scope, command }) => `\n- Tests (${scope}): \`${command}\``).join("")}\n\nBefore completing changes, run the project quality commands listed above. Follow installed AI Workflow skills, rules, and agents; the confirmed profile lives in \`.aiw/profile.yml\` and workflow evidence in \`.aiw/checkpoints/\`.\n`);
26
27
  write(resolveGeneratedPath("hooks/quality-check.md"), `# Quality Check Hook\n\nRun the available project quality commands before completion.${linterCommand ? `\n\n- Linter: \`${linterCommand}\`` : ""}${formatterCommand ? `\n- Formatter: \`${formatterCommand}\`` : ""}${uniqueTestCommands.map(({ scope, command }) => `\n- ${scope} tests: \`${command}\``).join("")}\n`);
27
28
  }
28
29
  }
@@ -3,7 +3,7 @@ import type { Target } from "./types.js";
3
3
  export type ResourceMigration = {
4
4
  source: string;
5
5
  destination: string;
6
- type: ResourceType;
6
+ type: ResourceType | "context";
7
7
  id: string;
8
8
  neutralSource: boolean;
9
9
  };
package/dist/migration.js CHANGED
@@ -2,6 +2,7 @@ import { parseDocument } from "yaml";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { renderResourcePath } from "./adapter.js";
4
4
  import { RESOURCE_TYPES } from "./package-contract.js";
5
+ const MIGRATABLE_TYPES = [...RESOURCE_TYPES, "context"];
5
6
  export function planNeutralResourceMigration(neutralRoot, relativeFiles, target) {
6
7
  const migrations = [];
7
8
  const diagnostics = [];
@@ -14,9 +15,7 @@ export function planNeutralResourceMigration(neutralRoot, relativeFiles, target)
14
15
  diagnostics.push({ path: relative, reason: "path is not normalized and project-relative" });
15
16
  continue;
16
17
  }
17
- if (extra ||
18
- parts.length !== 3 ||
19
- !RESOURCE_TYPES.includes(candidateType)) {
18
+ if (extra || parts.length !== 3 || !MIGRATABLE_TYPES.includes(candidateType)) {
20
19
  diagnostics.push({ path: relative, reason: "unsupported neutral resource path" });
21
20
  continue;
22
21
  }
@@ -39,7 +38,9 @@ export function planNeutralResourceMigration(neutralRoot, relativeFiles, target)
39
38
  }
40
39
  migrations.push({
41
40
  source: join(neutralRoot, relative),
42
- destination: renderResourcePath(target, type, id),
41
+ destination: type === "context"
42
+ ? renderResourcePath(target, type, id)
43
+ : renderResourcePath(target, type, id),
43
44
  type,
44
45
  id,
45
46
  neutralSource: target === "universal",
@@ -0,0 +1,22 @@
1
+ import type { FileSystem } from "./types.js";
2
+ export type ConfirmQuestion = (question: string) => Promise<string>;
3
+ export type OllamaModel = {
4
+ name: string;
5
+ embedding: boolean;
6
+ };
7
+ export type OnboardingRuntime = {
8
+ listOllamaModels(baseUrl: string): Promise<OllamaModel[] | undefined>;
9
+ pullOllamaModel(model: string): Promise<boolean>;
10
+ validateEmbedding(config: {
11
+ baseUrl: string;
12
+ model: string;
13
+ apiKeyEnv?: string;
14
+ }): Promise<boolean>;
15
+ validateDecision(config: {
16
+ baseUrl: string;
17
+ model: string;
18
+ apiKeyEnv?: string;
19
+ }): Promise<boolean>;
20
+ };
21
+ export declare const defaultOnboardingRuntime: OnboardingRuntime;
22
+ export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion, runtime?: OnboardingRuntime): Promise<string[]>;
@@ -0,0 +1,200 @@
1
+ import { execFile } from "node:child_process";
2
+ import { loadSettings, saveSettingsSection } from "./settings.js";
3
+ import { DEFAULT_SEMANTIC_CONFIG, createOllamaEmbeddingProvider } from "./semantic.js";
4
+ import { DEFAULT_DECISION_CONFIG, createOllamaDecisionProvider } from "./decision.js";
5
+ const OLLAMA_BASE_URL = "http://localhost:11434";
6
+ function isEmbeddingModel(name, families) {
7
+ if (name.toLowerCase().includes("embed"))
8
+ return true;
9
+ return families.some((family) => family.toLowerCase() === "embedding");
10
+ }
11
+ export const defaultOnboardingRuntime = {
12
+ async listOllamaModels(baseUrl) {
13
+ try {
14
+ const response = await fetch(`${baseUrl}/api/tags`);
15
+ if (!response.ok)
16
+ return undefined;
17
+ const body = (await response.json());
18
+ const models = (body.models ?? [])
19
+ .filter((entry) => typeof entry?.name === "string")
20
+ .map((entry) => ({
21
+ name: entry.name,
22
+ embedding: isEmbeddingModel(entry.name, entry.details?.families ?? []),
23
+ }))
24
+ .sort((a, b) => a.name.localeCompare(b.name));
25
+ return models;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ },
31
+ pullOllamaModel(model) {
32
+ return new Promise((resolve) => {
33
+ execFile("ollama", ["pull", model], { timeout: 10 * 60_000 }, (error) => resolve(error === null));
34
+ });
35
+ },
36
+ async validateEmbedding(config) {
37
+ try {
38
+ const provider = createOllamaEmbeddingProvider({
39
+ enabled: true,
40
+ model: config.model,
41
+ baseUrl: config.baseUrl,
42
+ chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
43
+ topK: DEFAULT_SEMANTIC_CONFIG.topK,
44
+ excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
45
+ ...(config.apiKeyEnv ? { apiKeyEnv: config.apiKeyEnv } : {}),
46
+ });
47
+ if (!(await provider.available()))
48
+ return false;
49
+ const vectors = await provider.embed(["aiw onboarding validation"]);
50
+ return vectors.length === 1 && vectors[0].length > 0;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ },
56
+ async validateDecision(config) {
57
+ try {
58
+ const provider = createOllamaDecisionProvider({
59
+ enabled: true,
60
+ model: config.model,
61
+ baseUrl: config.baseUrl,
62
+ ...(config.apiKeyEnv ? { apiKeyEnv: config.apiKeyEnv } : {}),
63
+ });
64
+ return await provider.available();
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ },
70
+ };
71
+ async function askYesNo(answer, question) {
72
+ const response = (await answer(question)).trim().toLowerCase();
73
+ return response === "y" || response === "yes";
74
+ }
75
+ async function pickFromList(answer, label, options, fallback) {
76
+ const menu = options.map((option, index) => `${index + 1}) ${option}`).join("\n");
77
+ const response = (await answer(`${menu}\nSelect ${label} [${fallback}]: `)).trim();
78
+ if (response === "")
79
+ return fallback;
80
+ const numeric = Number.parseInt(response, 10);
81
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= options.length)
82
+ return options[numeric - 1];
83
+ return options.includes(response) ? response : fallback;
84
+ }
85
+ function isValidEnvName(value) {
86
+ return /^[A-Z_][A-Z0-9_]*$/.test(value);
87
+ }
88
+ async function gatherCapabilitySetup(answer, runtime, options) {
89
+ const models = await runtime.listOllamaModels(OLLAMA_BASE_URL);
90
+ const ollamaUp = models !== undefined;
91
+ const provider = ollamaUp
92
+ ? (await pickFromList(answer, "provider", ["ollama", "external"], "ollama")) === "external"
93
+ ? "external"
94
+ : "ollama"
95
+ : (await answer(`Ollama not reachable at ${OLLAMA_BASE_URL}. Use an external provider. [Enter] `),
96
+ "external");
97
+ if (provider === "ollama") {
98
+ let candidates = (models ?? []).filter((model) => options.onlyEmbedding ? model.embedding : true);
99
+ if (candidates.length === 0 && options.onlyEmbedding) {
100
+ const pull = await askYesNo(answer, `No embedding models found. Pull ${options.pullModel}? [y/N] `);
101
+ if (pull) {
102
+ await answer(`Pulling ${options.pullModel}... [Enter] `);
103
+ const pulled = await runtime.pullOllamaModel(options.pullModel);
104
+ if (pulled) {
105
+ const refreshed = await runtime.listOllamaModels(OLLAMA_BASE_URL);
106
+ candidates = (refreshed ?? []).filter((model) => model.embedding);
107
+ }
108
+ else {
109
+ await answer("Pull failed. Enter a model manually. [Enter] ");
110
+ }
111
+ }
112
+ if (candidates.length === 0) {
113
+ const manual = (await answer(`Model name to use [${options.pullModel}]: `)).trim();
114
+ if (manual.length === 0)
115
+ return undefined;
116
+ return { baseUrl: OLLAMA_BASE_URL, model: manual };
117
+ }
118
+ }
119
+ const names = candidates.map((candidate) => candidate.name);
120
+ const fallback = names.includes(options.fallbackModel) ? options.fallbackModel : names[0];
121
+ const model = await pickFromList(answer, `${options.shortLabel} model`, names, fallback);
122
+ return { baseUrl: OLLAMA_BASE_URL, model };
123
+ }
124
+ const endpoint = (await answer(`Endpoint URL: `)).trim();
125
+ if (!/^https?:\/\//i.test(endpoint)) {
126
+ await answer("Invalid URL. [Enter] ");
127
+ return undefined;
128
+ }
129
+ const model = (await answer(`Model name: `)).trim();
130
+ if (model.length === 0) {
131
+ await answer("Model name required. [Enter] ");
132
+ return undefined;
133
+ }
134
+ const rawEnv = (await answer(`Token env var (empty for none): `)).trim();
135
+ if (rawEnv.length > 0 && !isValidEnvName(rawEnv)) {
136
+ await answer("Invalid env var name. [Enter] ");
137
+ return undefined;
138
+ }
139
+ return { baseUrl: endpoint, model, ...(rawEnv ? { apiKeyEnv: rawEnv } : {}) };
140
+ }
141
+ async function configureCapability(fs, aiwPath, answer, runtime, options) {
142
+ const wants = await askYesNo(answer, `Configure ${options.label}? [y/N] `);
143
+ if (!wants)
144
+ return [];
145
+ for (;;) {
146
+ const setup = await gatherCapabilitySetup(answer, runtime, options);
147
+ if (!setup)
148
+ return [];
149
+ const valid = options.section === "semantic"
150
+ ? await runtime.validateEmbedding(setup)
151
+ : await runtime.validateDecision(setup);
152
+ if (valid) {
153
+ saveSettingsSection(fs, aiwPath, options.section, options.serialize(setup));
154
+ return [
155
+ `${options.section} (${setup.model}${setup.apiKeyEnv ? ` via ${setup.baseUrl}` : ""})`,
156
+ ];
157
+ }
158
+ const retry = await askYesNo(answer, `Validation failed for ${setup.model}. Retry? [y/N] (No = skip for now) `);
159
+ if (!retry)
160
+ return [];
161
+ }
162
+ }
163
+ export async function configureOptionalModels(fs, aiwPath, answer, runtime = defaultOnboardingRuntime) {
164
+ const enabled = [];
165
+ const settings = loadSettings(fs, aiwPath) ?? {};
166
+ if (!("semantic" in settings))
167
+ enabled.push(...(await configureCapability(fs, aiwPath, answer, runtime, {
168
+ section: "semantic",
169
+ label: "semantic embedding",
170
+ shortLabel: "embedding",
171
+ onlyEmbedding: true,
172
+ fallbackModel: DEFAULT_SEMANTIC_CONFIG.model,
173
+ pullModel: DEFAULT_SEMANTIC_CONFIG.model,
174
+ serialize: (setup) => ({
175
+ enabled: true,
176
+ model: setup.model,
177
+ baseUrl: setup.baseUrl,
178
+ chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
179
+ topK: DEFAULT_SEMANTIC_CONFIG.topK,
180
+ excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
181
+ ...(setup.apiKeyEnv ? { apiKeyEnv: setup.apiKeyEnv } : {}),
182
+ }),
183
+ })));
184
+ if (!("decision" in settings))
185
+ enabled.push(...(await configureCapability(fs, aiwPath, answer, runtime, {
186
+ section: "decision",
187
+ label: "advisory decision triage",
188
+ shortLabel: "decision",
189
+ onlyEmbedding: false,
190
+ fallbackModel: DEFAULT_DECISION_CONFIG.model,
191
+ pullModel: DEFAULT_DECISION_CONFIG.model,
192
+ serialize: (setup) => ({
193
+ enabled: true,
194
+ model: setup.model,
195
+ baseUrl: setup.baseUrl,
196
+ ...(setup.apiKeyEnv ? { apiKeyEnv: setup.apiKeyEnv } : {}),
197
+ }),
198
+ })));
199
+ return enabled;
200
+ }
@@ -5,7 +5,7 @@ export type OwnedFile = {
5
5
  checksum: string;
6
6
  kind?: "neutral-resource" | "target-resource" | "ai-init" | "supporting-file";
7
7
  target?: Target;
8
- resourceType?: ResourceType;
8
+ resourceType?: ResourceType | "context";
9
9
  resourceId?: string;
10
10
  };
11
11
  export type OwnershipMetadata = Omit<OwnedFile, "path" | "checksum">;
package/dist/ownership.js CHANGED
@@ -49,7 +49,7 @@ export function parseOwnership(content) {
49
49
  if (file.target !== undefined && !TARGETS.includes(file.target))
50
50
  throw new Error(`Ownership inventory target is invalid: ${file.path}`);
51
51
  if (file.resourceType !== undefined &&
52
- !RESOURCE_TYPES.includes(String(file.resourceType)))
52
+ ![...RESOURCE_TYPES, "context"].includes(String(file.resourceType)))
53
53
  throw new Error(`Ownership inventory resource type is invalid: ${file.path}`);
54
54
  if (file.resourceId !== undefined &&
55
55
  (typeof file.resourceId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file.resourceId)))
@@ -6,6 +6,7 @@ export type SemanticConfig = {
6
6
  chunkChars: number;
7
7
  topK: number;
8
8
  excludeLockfiles: boolean;
9
+ apiKeyEnv?: string;
9
10
  };
10
11
  export type SemanticIndexChunk = {
11
12
  file: string;
@@ -47,7 +48,7 @@ export declare function serializeSemanticConfig(config: SemanticConfig): string;
47
48
  export declare function loadSemanticConfig(fs: FileSystem, aiwPath: string): SemanticConfig | undefined;
48
49
  export declare function parseSemanticConfig(text: string): SemanticConfig;
49
50
  type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
50
- export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike): EmbeddingProvider;
51
+ export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike, env?: Readonly<Record<string, string | undefined>>): EmbeddingProvider;
51
52
  export declare function chunkText(content: string, chunkChars: number): string[];
52
53
  export declare function cosineSimilarity(a: number[], b: number[]): number;
53
54
  export declare function buildSemanticIndex(files: string[], readContent: (file: string) => Promise<string | undefined>, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticIndex>;
package/dist/semantic.js CHANGED
@@ -40,6 +40,7 @@ export function parseSemanticMapping(raw) {
40
40
  const chunkChars = raw2.chunkChars;
41
41
  const topK = raw2.topK;
42
42
  const rawExclude = raw2.excludeLockfiles;
43
+ const apiKeyEnv = raw2.apiKeyEnv;
43
44
  if (typeof enabled !== "boolean")
44
45
  throw new Error("semantic.enabled must be a boolean");
45
46
  const excludeLockfiles = rawExclude === undefined ? true : rawExclude;
@@ -47,6 +48,9 @@ export function parseSemanticMapping(raw) {
47
48
  return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false, excludeLockfiles };
48
49
  if (rawExclude !== undefined && typeof rawExclude !== "boolean")
49
50
  throw new Error("semantic.excludeLockfiles must be a boolean");
51
+ if (apiKeyEnv !== undefined &&
52
+ (typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
53
+ throw new Error("semantic.apiKeyEnv must be an uppercase environment variable name");
50
54
  if (typeof model !== "string" || model.trim() === "")
51
55
  throw new Error("semantic.model must be a non-empty string when semantic mapping is enabled");
52
56
  if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
@@ -64,10 +68,11 @@ export function parseSemanticMapping(raw) {
64
68
  chunkChars: parsedChunk,
65
69
  topK: parsedTopK,
66
70
  excludeLockfiles,
71
+ ...(apiKeyEnv ? { apiKeyEnv } : {}),
67
72
  };
68
73
  }
69
74
  export function serializeSemanticConfig(config) {
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`;
75
+ 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}${config.apiKeyEnv ? `\n apiKeyEnv: ${config.apiKeyEnv}` : ""}\n`;
71
76
  }
72
77
  export function loadSemanticConfig(fs, aiwPath) {
73
78
  const settings = loadSettings(fs, aiwPath);
@@ -89,7 +94,44 @@ export function parseSemanticConfig(text) {
89
94
  return parseSemanticDocument(text);
90
95
  }
91
96
  const EMBED_BATCH_SIZE = 64;
92
- export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init)) {
97
+ export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
98
+ const externalToken = config.apiKeyEnv ? env[config.apiKeyEnv] : undefined;
99
+ const isExternal = config.apiKeyEnv !== undefined;
100
+ const authHeaders = (extra = {}) => externalToken ? { ...extra, authorization: `Bearer ${externalToken}` } : extra;
101
+ if (isExternal)
102
+ return {
103
+ async available() {
104
+ try {
105
+ const response = await fetchImpl(`${config.baseUrl}/v1/models`, {
106
+ headers: authHeaders(),
107
+ });
108
+ return response.ok;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ },
114
+ async embed(texts) {
115
+ const vectors = [];
116
+ for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
117
+ const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
118
+ const response = await fetchImpl(`${config.baseUrl}/v1/embeddings`, {
119
+ method: "POST",
120
+ headers: authHeaders({ "content-type": "application/json" }),
121
+ body: JSON.stringify({ model: config.model, input: batch }),
122
+ });
123
+ if (!response.ok)
124
+ throw new Error(`Embedding request failed for model ${config.model}`);
125
+ const body = (await response.json());
126
+ const rows = body.data ?? [];
127
+ if (rows.length !== batch.length ||
128
+ rows.some((row) => !Array.isArray(row.embedding) || row.embedding.some((v) => typeof v !== "number")))
129
+ throw new Error(`Embedding model ${config.model} returned invalid vectors`);
130
+ vectors.push(...rows.map((row) => row.embedding));
131
+ }
132
+ return vectors;
133
+ },
134
+ };
93
135
  return {
94
136
  async available() {
95
137
  try {
@@ -2,6 +2,3 @@ import type { CommandResult, FileSystem } from "./types.js";
2
2
  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
- export type ConfirmQuestion = (question: string) => 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
@@ -1,12 +1,13 @@
1
1
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
4
- import { detectTarget, isTarget, renderAiInit, renderAiInitPath, renderResourcePath, } from "./adapter.js";
4
+ import { detectTarget, isTarget, renderAiInit, describeResource, renderAiInitPath, renderResourcePath, } from "./adapter.js";
5
5
  import { loadBundledResources } from "./bundled-resources.js";
6
6
  import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomically, } from "./files.js";
7
7
  import { parseProjectProfile, profileProject } from "./profile.js";
8
8
  import { applyOverrides } from "./confirmation.js";
9
9
  import { mergeFacts } from "./intelligence.js";
10
+ import { configureOptionalModels } from "./model-onboarding.js";
10
11
  import { createInterpretationRequest, createOpenAiCompatibleProvider, createProjectInterpreter, buildInterpreterQuery, loadAiProviderConfig, selectInterpreterKeyFiles, } from "./interpreter.js";
11
12
  import { recommendCapabilities, rankRecommendations, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
12
13
  import { selectRecommendations } from "./selection.js";
@@ -20,9 +21,9 @@ import { validatePackageContract } from "./package-contract.js";
20
21
  import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
21
22
  import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
22
23
  import { scanProject } from "./scanner.js";
23
- import { readFactOverrides, saveSettingOverrides, saveSettingsSection, loadSettings, } from "./settings.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";
24
+ import { readFactOverrides, saveSettingOverrides, saveSettingsSection } from "./settings.js";
25
+ import { buildEvidenceTriageRequest, buildRecommendationRankingRequest, buildSemanticRerankRequest, createOllamaDecisionProvider, loadDecisionConfig, } from "./decision.js";
26
+ import { buildSemanticIndex, createOllamaEmbeddingProvider, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, rerankSemanticMatches, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
26
27
  import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
27
28
  import { planPackageUpdate } from "./package-updates.js";
28
29
  import { searchRegistry, serializeRegistry } from "./registry.js";
@@ -442,11 +443,17 @@ export async function runCommand(args, root, fs, services = {}) {
442
443
  return { exitCode: 1, error: "Run `aiw install` first." };
443
444
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
444
445
  let interpreter = services.interpreter;
446
+ let interpreterModel;
445
447
  if (!interpreter) {
446
448
  const aiConfig = loadAiProviderConfig(fs, aiw);
447
- if (aiConfig?.enabled)
449
+ if (aiConfig?.enabled) {
448
450
  interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
451
+ interpreterModel = aiConfig.model;
452
+ }
449
453
  }
454
+ else
455
+ interpreterModel = "host-injected";
456
+ const modelUsage = [];
450
457
  if (interpreter) {
451
458
  const scan = await (services.scanner?.scan(root) ?? scanProject(root));
452
459
  let scopedFiles = scan.files;
@@ -466,6 +473,11 @@ export async function runCommand(args, root, fs, services = {}) {
466
473
  ];
467
474
  const allowed = new Set(priorityFiles);
468
475
  scopedFiles = scan.files.filter((file) => allowed.has(file));
476
+ modelUsage.push({
477
+ label: "embedding",
478
+ tokens: Math.ceil(buildInterpreterQuery(profile).length / 4),
479
+ detail: `${contextConfig.model} — scoped interpreter context to ${matches.length} semantic matches`,
480
+ });
469
481
  }
470
482
  }
471
483
  }
@@ -473,8 +485,14 @@ export async function runCommand(args, root, fs, services = {}) {
473
485
  // An unreadable index keeps the scan deterministic with alphabetical scoping.
474
486
  }
475
487
  }
476
- const inferred = await interpreter.interpret(createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles));
488
+ const interpretationRequest = createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles);
489
+ const inferred = await interpreter.interpret(interpretationRequest);
477
490
  profile.facts = mergeFacts(profile.facts, inferred);
491
+ modelUsage.push({
492
+ label: "interpreter",
493
+ tokens: interpretationRequest.estimatedTokens,
494
+ detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files`,
495
+ });
478
496
  }
479
497
  const overrides = readFactOverrides(fs, aiw);
480
498
  const rejectedKeys = new Set(overrides
@@ -507,8 +525,34 @@ export async function runCommand(args, root, fs, services = {}) {
507
525
  }, provider, semanticConfig);
508
526
  fs.mkdir(join(aiw, "semantic"));
509
527
  fs.write(join(aiw, SEMANTIC_INDEX_PATH), serializeSemanticIndex(index));
528
+ const indexedChars = index.chunks.reduce((total, chunk) => total + chunk.text.length, 0);
529
+ modelUsage.push({
530
+ label: "embedding",
531
+ tokens: Math.ceil(indexedChars / 4),
532
+ detail: `${semanticConfig.model} — indexed ${index.chunks.length} chunks in ${Math.ceil(index.chunks.length / 64)} batch requests`,
533
+ });
534
+ }
535
+ if (modelUsage.length > 0) {
536
+ const usagePath = join(aiw, "usage.yml");
537
+ const entries = fs.exists(usagePath) ? parseTokenUsage(fs.read(usagePath)) : [];
538
+ let next = entries;
539
+ for (const usage of modelUsage)
540
+ next = recordTokenUsage(next, {
541
+ stage: `scan.${usage.label}`,
542
+ budget: usage.tokens,
543
+ used: usage.tokens,
544
+ });
545
+ fs.write(usagePath, serializeTokenUsage(next));
510
546
  }
511
- return { exitCode: 0, output: "Project profile generated." };
547
+ return {
548
+ exitCode: 0,
549
+ output: "Project profile generated." +
550
+ (modelUsage.length > 0
551
+ ? `\nModels used:\n${modelUsage
552
+ .map((usage) => `- ${usage.label}: ${usage.detail} (~${usage.tokens.toLocaleString("en-US")} tokens)`)
553
+ .join("\n")}`
554
+ : "\nModels used: none (deterministic scan)."),
555
+ };
512
556
  }
513
557
  if (command === "decision") {
514
558
  const usage = "Usage: aiw decision triage --artifact=<path> | aiw decision status";
@@ -1006,8 +1050,13 @@ export async function runCommand(args, root, fs, services = {}) {
1006
1050
  exitCode: 1,
1007
1051
  error: `Decision model ${rankingConfig.model} is not available at ${rankingConfig.baseUrl}.`,
1008
1052
  };
1053
+ let decisionTokens = 0;
1009
1054
  ranked = await rankRecommendations(recommendations, profile, async (scoped, recs) => {
1010
- const response = await decisionProvider.decide(buildRecommendationRankingRequest(profileSummaryForRanking(scoped), recs));
1055
+ const request = buildRecommendationRankingRequest(profileSummaryForRanking(scoped), recs);
1056
+ decisionTokens += Math.ceil((request.state.length +
1057
+ Object.values(request.questions).reduce((total, question) => total + question.instructions.length, 0)) /
1058
+ 4);
1059
+ const response = await decisionProvider.decide(request);
1011
1060
  const answers = {};
1012
1061
  for (const [id, answer] of Object.entries(response.answers))
1013
1062
  answers[id] =
@@ -1021,6 +1070,13 @@ export async function runCommand(args, root, fs, services = {}) {
1021
1070
  : {};
1022
1071
  return answers;
1023
1072
  });
1073
+ const usagePath = join(aiw, "usage.yml");
1074
+ const entries = fs.exists(usagePath) ? parseTokenUsage(fs.read(usagePath)) : [];
1075
+ fs.write(usagePath, serializeTokenUsage(recordTokenUsage(entries, {
1076
+ stage: "recommend.decision",
1077
+ budget: decisionTokens,
1078
+ used: decisionTokens,
1079
+ })));
1024
1080
  ordered = ranked.items;
1025
1081
  }
1026
1082
  const selectedOption = args.find((arg) => arg.startsWith("--select="));
@@ -1899,85 +1955,6 @@ function approvalArtifactPath(aiw, stage) {
1899
1955
  };
1900
1956
  return resolve(aiw, paths[stage]);
1901
1957
  }
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) {
1925
- const enabled = [];
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);
1930
- if (!("semantic" in settings)) {
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] `))
1936
- .trim()
1937
- .toLowerCase();
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
- }
1945
- saveSettingsSection(fs, aiwPath, "semantic", {
1946
- enabled: true,
1947
- model,
1948
- baseUrl: DEFAULT_SEMANTIC_CONFIG.baseUrl,
1949
- chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
1950
- topK: DEFAULT_SEMANTIC_CONFIG.topK,
1951
- excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
1952
- });
1953
- enabled.push(`semantic (${model})`);
1954
- }
1955
- }
1956
- if (!("decision" in settings)) {
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] `))
1962
- .trim()
1963
- .toLowerCase();
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
- }
1971
- saveSettingsSection(fs, aiwPath, "decision", {
1972
- enabled: true,
1973
- model,
1974
- baseUrl: DEFAULT_DECISION_CONFIG.baseUrl,
1975
- });
1976
- enabled.push(`decision (${model})`);
1977
- }
1978
- }
1979
- return enabled;
1980
- }
1981
1958
  function formatConfidence(confidence) {
1982
1959
  return confidence === undefined ? "unknown" : confidence.toFixed(3);
1983
1960
  }
@@ -2169,6 +2146,25 @@ function syncSelectedResources(fs, root, aiw, profile, selected, apply = false,
2169
2146
  const rule = path.match(/^generated\/rules\/(project-quality|tdd-project-policy)\.md$/);
2170
2147
  if (rule)
2171
2148
  addResource("rules", rule[1], content);
2149
+ const rootContext = path === "generated/context/root-context.md";
2150
+ if (rootContext) {
2151
+ const neutralContextPath = join(root, describeResource("universal", "context", "project").path);
2152
+ addOwned(neutralContextPath, content, {
2153
+ kind: "neutral-resource",
2154
+ target: "universal",
2155
+ resourceType: "context",
2156
+ resourceId: "project",
2157
+ });
2158
+ const targetContextPath = describeResource(target, "context", "project").path;
2159
+ if (targetContextPath !== renderAiInitPath(target)) {
2160
+ addOwned(join(root, targetContextPath), content, {
2161
+ kind: "target-resource",
2162
+ target,
2163
+ resourceType: "context",
2164
+ resourceId: "project",
2165
+ });
2166
+ }
2167
+ }
2172
2168
  });
2173
2169
  const ownershipPath = join(aiw, "ownership.yml");
2174
2170
  if (fs.pathType?.(ownershipPath) !== "file")
package/docs/README.md CHANGED
@@ -13,6 +13,11 @@ AI Workflow is a portable, spec-driven development system for AI coding agents.
13
13
  - [Quality and testing](quality.md)
14
14
  - [Token efficiency](token-efficiency.md)
15
15
  - [Project intelligence](project-intelligence.md)
16
+ - [Semantic mapping](semantic-mapping.md)
17
+ - [Decision model evaluation](decision-model-evaluation.md)
18
+ - [Telemetry and privacy](telemetry.md)
19
+ - [Multi-agent orchestration](orchestration.md)
20
+ - [Plugin authoring](plugin-authoring.md)
16
21
  - [Release process](release-process.md)
17
22
  - [Release backlog](release-backlog.md)
18
23
  - [Backlog](backlog.md)
@@ -27,7 +27,7 @@ Clef Flash answers typed questions over a supplied state in a single non-autoreg
27
27
 
28
28
  The earlier blocker (no exposed endpoint in Ollama 0.40.1) is resolved by the `/v1/systemone` endpoint documented at <https://ollama.com/library/clef-flash>. AI Workflow now ships the advisory layer:
29
29
 
30
- - `src/decision.ts`: `DecisionConfig` (`.aiw/decision.yml`, flag-gated: `enabled` / `model` / `baseUrl`), a `DecisionProvider` with availability verification (`/api/tags`), typed question/response contracts, and `buildEvidenceTriageRequest` for pre-gate triage.
30
+ - `src/decision.ts`: `DecisionConfig` (`.aiw/decision.yml`, flag-gated: `enabled` / `model` / `baseUrl` / optional `apiKeyEnv` for an external OpenAI-compatible endpoint with bearer authentication), a `DecisionProvider` with availability verification (`/api/tags` for Ollama, `/v1/models` with auth for external), typed question/response contracts, and `buildEvidenceTriageRequest` for pre-gate triage.
31
31
  - `aiw decision status` and `aiw decision triage --artifact=<path>`: advisory `evidence_complete` (noul) and `gate` (`ready` / `needs-work`) decisions over any project artifact, with explicit confidence reporting.
32
32
 
33
33
  Observed behavior on real plans: an evidence-free plan scored `gate: ready` with confidence 0.044 and `evidence_complete` 0.260, while a fully evidenced plan scored confidence 0.718 and `evidence_complete` 0.924 — low confidence signals an inconclusive decision rather than approval.
@@ -35,4 +35,4 @@ For the first publication only, create a short-lived granular npm token scoped t
35
35
 
36
36
  After `0.1.0` exists, configure organization `MultiLeaf`, repository `aiw`, workflow `release.yml`, and environment `npm` as a trusted publisher in the package settings. Publish a subsequent release through OIDC and verify its provenance on npm before deleting the GitHub bootstrap secret and revoking the granular token. A saved publisher configuration alone is insufficient evidence because npm validates it only during publication.
37
37
 
38
- The workflow uses Node 24 and npm 11.5.1 or newer, strictly validates Semantic Versioning, checks that the tag is `v<package-version>`, requires the approved license, and verifies ancestry from `origin/main`. Publish each tag only once; never reuse, move, or overwrite a published version.
38
+ The workflow uses Node 24 and npm 11.5.1 or newer, strictly validates Semantic Versioning, checks that the tag is `v<package-version>`, requires the approved license, and verifies ancestry from `origin/main`. After publishing, a `github-release` job creates the GitHub Release for the tag from the matching `CHANGELOG.md` section, so the signed Git tag, GitHub release, and npm package always agree on the immutable version. Publish each tag only once; never reuse, move, or overwrite a published version.
@@ -1,6 +1,10 @@
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 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.
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.
4
+
5
+ The provider can be Ollama (default, `http://localhost:11434`) or any OpenAI-compatible embeddings endpoint: set `apiKeyEnv` to the name of the environment variable holding the bearer token, and requests go to `<baseUrl>/v1/embeddings` with `Authorization: Bearer <token>` instead of Ollama's `/api/embed`. The token is read from the environment at call time and never stored in settings.
6
+
7
+ During an interactive `aiw install`, the installer walks through provider selection: it probes the configured Ollama endpoint (`GET /api/tags`), offers Ollama or an external provider (external only, when Ollama is unreachable), lists the locally available embedding models — filtering to embedding-type models — and asks permission to run `ollama pull embeddinggemma-2` when none exist. External configuration asks for the endpoint URL, model name, and an optional token environment variable. The chosen setup is validated with a live request before being recorded in `.aiw/settings.yml`; a failed validation can be retried or skipped, and the next capability (decision triage) follows the same flow. Answering `n`, or installing non-interactively, leaves the section absent.
4
8
 
5
9
  ## Configuration
6
10
 
@@ -14,6 +18,7 @@ semantic:
14
18
  chunkChars: 1200 # optional, minimum 200
15
19
  topK: 8 # optional, results per query
16
20
  excludeLockfiles: true # optional, skip lockfiles and generated vendor manifests (default: true)
21
+ apiKeyEnv: AIW_EMBED_KEY # optional, external OpenAI-compatible endpoint bearer token
17
22
  ```
18
23
 
19
24
  - `enabled: false` (or a missing file) disables semantic mapping entirely; scans stay deterministic and make no network request.
@@ -30,6 +35,19 @@ When semantic mapping is enabled, `aiw scan` additionally:
30
35
 
31
36
  The index stores the model name; queries against an index built with a different model fail explicitly.
32
37
 
38
+ ## Usage visibility
39
+
40
+ Every `aiw scan` reports the models it actually used, for example:
41
+
42
+ ```text
43
+ Project profile generated.
44
+ Models used:
45
+ - embedding: embeddinggemma-2:latest — indexed 824 chunks in 13 batch requests (~45,000 tokens)
46
+ - interpreter: gpt-oss:20b — 5 facts from 12 files (~3,800 tokens)
47
+ ```
48
+
49
+ Token estimates are also recorded per stage in `.aiw/usage.yml` (`scan.embedding`, `scan.interpreter`; the latest scan overwrites its stage entry) and can be inspected with `aiw token-usage`. Scans that use no models report `Models used: none (deterministic scan).`
50
+
33
51
  ## Queries during the flow
34
52
 
35
53
  `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multileaf/ai-workflow",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Portable spec-driven development workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {