@duckcodeailabs/dql-cli 1.7.0 → 1.7.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.
@@ -10,13 +10,13 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
10
10
  import Anthropic from "@anthropic-ai/sdk";
11
11
  import OpenAI from "openai";
12
12
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
13
- import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, } from '@duckcodeailabs/dql-core';
13
+ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, } from '@duckcodeailabs/dql-core';
14
14
  import { load as loadYaml } from 'js-yaml';
15
15
  import { listBlockTemplates } from './block-templates.js';
16
16
  import { getRunner as getLLMRunner } from './llm/index.js';
17
17
  import { createDqlAgentProviderRunner } from './llm/providers/dql-agent-provider.js';
18
18
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
19
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planApp, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, probeLocalOllamaEmbeddings, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
19
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, probeLocalOllamaEmbeddings, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
20
20
  import { gatherProposeEnrichment } from './propose-enrich.js';
21
21
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
22
22
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -389,13 +389,19 @@ export async function startLocalServer(opts) {
389
389
  const gitRoot = await resolveGitRoot(projectRoot);
390
390
  if (gitRoot)
391
391
  ensureLocalRuntimeGitignore(projectRoot);
392
- let connection = rawConnection ? normalizeProjectConnection(rawConnection, projectRoot) : null;
393
392
  let projectConfig = loadProjectConfig(projectRoot);
393
+ const configuredConnection = rawConnection
394
+ ? normalizeProjectConnection(rawConnection, projectRoot)
395
+ : projectConfig.defaultConnection
396
+ ? normalizeProjectConnection(projectConfig.defaultConnection, projectRoot)
397
+ : null;
398
+ let connection = configuredConnection ?? resolveDbtProfileRuntimeConnection(projectRoot, projectConfig);
394
399
  const projectSnapshots = new ProjectSnapshotService();
400
+ const dashboardRunEvidence = new Map();
395
401
  const dbtNodeDetailCache = new Map();
396
402
  const onboardingJobs = new Map();
397
403
  const projectSnapshot = () => {
398
- const dbtManifestPath = resolveDbtManifestPath(projectRoot) ?? undefined;
404
+ const dbtManifestPath = resolveDbtManifestPath(projectRoot, projectConfig) ?? undefined;
399
405
  const inputs = collectInputFiles({ projectRoot, dbtManifestPath });
400
406
  const signatureHash = createHash('sha256');
401
407
  for (const input of inputs.sort()) {
@@ -421,14 +427,26 @@ export async function startLocalServer(opts) {
421
427
  };
422
428
  };
423
429
  const onboardingDbtPaths = (body = {}) => {
424
- const projectDirInput = typeof body.projectDir === 'string' ? body.projectDir : projectConfig.dbt?.projectDir ?? '.';
430
+ const repoUrl = typeof body.repoUrl === 'string' && body.repoUrl.trim() ? body.repoUrl.trim() : undefined;
431
+ const branch = typeof body.branch === 'string' && body.branch.trim() ? body.branch.trim() : undefined;
432
+ const subPath = typeof body.subPath === 'string' && body.subPath.trim() ? body.subPath.trim() : undefined;
433
+ const repo = repoUrl ? resolveRepoSource({
434
+ provider: 'dbt', source: repoUrl.includes('gitlab') ? 'gitlab' : 'github', repoUrl, branch, subPath,
435
+ }, projectRoot) : undefined;
436
+ const projectDirInput = repo?.localPath ?? (typeof body.projectDir === 'string' ? body.projectDir : projectConfig.dbt?.projectDir ?? '.');
425
437
  const dbtProjectDir = resolve(projectRoot, projectDirInput);
426
438
  const manifestInput = typeof body.manifestPath === 'string' ? body.manifestPath : projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
427
439
  const manifestPath = resolve(dbtProjectDir, manifestInput);
428
440
  if (!(manifestPath === dbtProjectDir || manifestPath.startsWith(`${dbtProjectDir}/`))) {
429
441
  throw Object.assign(new Error('dbt manifestPath must stay inside dbt projectDir.'), { code: 'DBT_ARTIFACT_INVALID' });
430
442
  }
431
- return { dbtProjectDir, manifestPath, projectDirInput, manifestInput };
443
+ const profilesDirInput = typeof body.profilesDir === 'string'
444
+ ? body.profilesDir
445
+ : projectConfig.dbt?.profilesDir;
446
+ const configuredProfilesDir = profilesDirInput ? resolve(projectRoot, profilesDirInput) : undefined;
447
+ const profilePath = findDbtProfilePaths(projectRoot, dbtProjectDir, configuredProfilesDir)[0];
448
+ const profilesDir = configuredProfilesDir ?? (profilePath ? dirname(profilePath) : undefined);
449
+ return { dbtProjectDir, manifestPath, projectDirInput, manifestInput, profilesDir, repoUrl, branch, subPath, repoWarnings: repo?.warnings ?? [] };
432
450
  };
433
451
  const previewDbtOnboarding = (body = {}) => {
434
452
  const paths = onboardingDbtPaths(body);
@@ -445,8 +463,11 @@ export async function startLocalServer(opts) {
445
463
  }
446
464
  const fingerprint = createHash('sha256').update(readFileSync(paths.manifestPath)).digest('hex');
447
465
  return {
448
- projectDir: relative(projectRoot, paths.dbtProjectDir).replace(/\\/g, '/') || '.',
466
+ projectDir: portableProjectPath(projectRoot, paths.dbtProjectDir),
449
467
  manifestPath: relative(paths.dbtProjectDir, paths.manifestPath).replace(/\\/g, '/'),
468
+ ...(paths.profilesDir ? { profilesDir: portableProjectPath(projectRoot, paths.profilesDir) } : {}),
469
+ ...(paths.repoUrl ? { repoUrl: paths.repoUrl, branch: paths.branch, subPath: paths.subPath } : {}),
470
+ ...(paths.repoWarnings.length ? { warnings: paths.repoWarnings } : {}),
450
471
  fingerprint,
451
472
  projectName: typeof raw.metadata?.project_name === 'string' ? raw.metadata.project_name : undefined,
452
473
  counts: {
@@ -1900,17 +1921,19 @@ export async function startLocalServer(opts) {
1900
1921
  // A real app needs a certified anchor: only offer confirmation when at least
1901
1922
  // one certified tile is present. Otherwise fall through to the escalate path
1902
1923
  // (research / draft the missing blocks) so the user isn't sent to a dead end.
1903
- const proposed = Boolean(proposal && proposal.tiles.some((tile) => tile.certification === 'certified' && !tile.error));
1924
+ const proposed = Boolean(proposal && proposal.tiles.some((tile) => (tile.certification === 'certified' || tile.certification === 'reviewed_semantic') && !tile.error));
1904
1925
  const sessionPlan = agentRunRecord(session.plan);
1905
1926
  const appTitle = agentRunString(sessionPlan?.name) ?? 'App proposal';
1906
1927
  const certifiedCount = proposal?.coverage.certifiedTiles ?? 0;
1907
1928
  const generatedCount = proposal?.coverage.generatedTiles ?? 0;
1929
+ const semanticCount = proposal?.coverage.semanticTiles ?? 0;
1908
1930
  const gapCount = proposal?.coverage.gaps ?? 0;
1909
1931
  // A coverage gap is NOT terminal — leave status open so the gate can escalate to
1910
1932
  // drafting the missing blocks. Only genuine infra errors (the catch above) block.
1911
1933
  return {
1912
1934
  summary: proposed
1913
1935
  ? `Proposed ${certifiedCount} certified tile${certifiedCount === 1 ? '' : 's'}`
1936
+ + (semanticCount > 0 ? ` + ${semanticCount} reviewed semantic tile${semanticCount === 1 ? '' : 's'}` : '')
1914
1937
  + (generatedCount > 0 ? ` + ${generatedCount} AI-generated (review required)` : '')
1915
1938
  + (gapCount > 0 ? `; ${gapCount} question${gapCount === 1 ? '' : 's'} still uncovered` : '')
1916
1939
  + '. Review the list and confirm to create the app.'
@@ -1924,6 +1947,7 @@ export async function startLocalServer(opts) {
1924
1947
  dashboardId: session.dashboardId,
1925
1948
  plan: session.plan,
1926
1949
  proposal,
1950
+ proposalHash: session.proposalHash,
1927
1951
  warnings: session.warnings,
1928
1952
  }, session.appId)] : [],
1929
1953
  evaluations: [
@@ -3390,8 +3414,10 @@ export async function startLocalServer(opts) {
3390
3414
  }
3391
3415
  if (req.method === 'GET' && path === '/api/onboarding/status') {
3392
3416
  const requestId = apiRequestId('onboarding-status');
3393
- const dbtProjectDir = resolve(projectRoot, projectConfig.dbt?.projectDir ?? '.');
3417
+ const dbtProjectDir = findDbtProjectPath(projectRoot, projectConfig);
3394
3418
  const manifestPath = resolve(dbtProjectDir, projectConfig.dbt?.manifestPath ?? 'target/manifest.json');
3419
+ const configuredProfilesDir = projectConfig.dbt?.profilesDir ? resolve(projectRoot, projectConfig.dbt.profilesDir) : undefined;
3420
+ const profilePath = findDbtProfilePaths(projectRoot, dbtProjectDir, configuredProfilesDir)[0];
3395
3421
  const registry = loadDomainPackageRegistry(projectRoot);
3396
3422
  const manifestFound = existsSync(manifestPath);
3397
3423
  let snapshotId;
@@ -3412,6 +3438,12 @@ export async function startLocalServer(opts) {
3412
3438
  configured: Boolean(projectConfig.dbt),
3413
3439
  projectDir: projectConfig.dbt?.projectDir,
3414
3440
  manifestPath: projectConfig.dbt?.manifestPath,
3441
+ profilesDir: configuredProfilesDir
3442
+ ? portableProjectPath(projectRoot, configuredProfilesDir)
3443
+ : profilePath ? portableProjectPath(projectRoot, dirname(profilePath)) : undefined,
3444
+ repoUrl: projectConfig.dbt?.repoUrl,
3445
+ branch: projectConfig.dbt?.branch,
3446
+ subPath: projectConfig.dbt?.subPath,
3415
3447
  projectFound: existsSync(join(dbtProjectDir, 'dbt_project.yml')),
3416
3448
  manifestFound,
3417
3449
  },
@@ -3457,7 +3489,9 @@ export async function startLocalServer(opts) {
3457
3489
  throw error;
3458
3490
  const { dbtProjectDir } = onboardingDbtPaths(body);
3459
3491
  try {
3460
- execFileSync('dbt', ['parse', '--project-dir', dbtProjectDir], {
3492
+ const { profilesDir } = onboardingDbtPaths(body);
3493
+ const parseArgs = buildDbtParseArgs(dbtProjectDir, profilesDir);
3494
+ execFileSync('dbt', parseArgs, {
3461
3495
  cwd: dbtProjectDir,
3462
3496
  timeout: 120_000,
3463
3497
  maxBuffer: 1024 * 1024,
@@ -3483,16 +3517,24 @@ export async function startLocalServer(opts) {
3483
3517
  ...projectConfig,
3484
3518
  manifestVersion: 3,
3485
3519
  modeling: { mode: 'dbt-first' },
3486
- dbt: { projectDir: preview.projectDir, manifestPath: preview.manifestPath },
3520
+ dbt: {
3521
+ projectDir: preview.projectDir,
3522
+ manifestPath: preview.manifestPath,
3523
+ ...(preview.profilesDir ? { profilesDir: preview.profilesDir } : {}),
3524
+ ...(preview.repoUrl ? { repoUrl: preview.repoUrl, branch: preview.branch, subPath: preview.subPath } : {}),
3525
+ },
3487
3526
  semanticLayer: { provider: 'dbt', projectPath: preview.projectDir },
3488
3527
  };
3489
3528
  const configPath = join(projectRoot, 'dql.config.json');
3490
3529
  const previousConfigSource = readFileSync(configPath, 'utf8');
3491
3530
  const previousProjectConfig = projectConfig;
3531
+ const previousConnection = connection;
3492
3532
  const tempPath = `${configPath}.tmp-${process.pid}`;
3493
3533
  writeFileSync(tempPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
3494
3534
  renameSync(tempPath, configPath);
3495
3535
  projectConfig = nextConfig;
3536
+ if (!configuredConnection)
3537
+ connection = resolveDbtProfileRuntimeConnection(projectRoot, nextConfig);
3496
3538
  projectSnapshots.invalidate();
3497
3539
  invalidateAgentProjectState(projectRoot);
3498
3540
  let snapshotId;
@@ -3504,6 +3546,7 @@ export async function startLocalServer(opts) {
3504
3546
  writeFileSync(rollbackPath, previousConfigSource, { encoding: 'utf8', mode: 0o600 });
3505
3547
  renameSync(rollbackPath, configPath);
3506
3548
  projectConfig = previousProjectConfig;
3549
+ connection = previousConnection;
3507
3550
  projectSnapshots.invalidate();
3508
3551
  invalidateAgentProjectState(projectRoot);
3509
3552
  throw Object.assign(new Error(`dbt-first configuration did not compile and was rolled back: ${error instanceof Error ? error.message : String(error)}`), { code: 'SNAPSHOT_BUILD_FAILED' });
@@ -4446,6 +4489,52 @@ export async function startLocalServer(opts) {
4446
4489
  // ── Skills (spec 16) — user-authored business context. AI drafts, humans
4447
4490
  // certify; skills never carry certification. PROJECT skills (user empty)
4448
4491
  // are shared; PERSONAL skills (user set) are user-bound. ────────────────
4492
+ if (req.method === 'GET' && path === '/api/skills/settings') {
4493
+ try {
4494
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
4495
+ res.end(serializeJSON(buildSkillPathSettings(projectRoot)));
4496
+ }
4497
+ catch (error) {
4498
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
4499
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
4500
+ }
4501
+ return;
4502
+ }
4503
+ if (req.method === 'PUT' && path === '/api/skills/settings') {
4504
+ try {
4505
+ const body = (await readJSON(req).catch(() => ({})));
4506
+ const requestedPath = typeof body.path === 'string' ? body.path.trim() : '';
4507
+ if (!requestedPath || requestedPath.includes('\0')) {
4508
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
4509
+ res.end(serializeJSON({ error: 'Provide an existing Skills folder path.' }));
4510
+ return;
4511
+ }
4512
+ const resolvedPath = resolve(projectRoot, requestedPath);
4513
+ if (!existsSync(resolvedPath) || !statSync(resolvedPath).isDirectory()) {
4514
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
4515
+ res.end(serializeJSON({ error: `Skills folder not found: ${resolvedPath}` }));
4516
+ return;
4517
+ }
4518
+ const configPath = join(projectRoot, 'dql.config.json');
4519
+ const raw = existsSync(configPath)
4520
+ ? JSON.parse(readFileSync(configPath, 'utf-8'))
4521
+ : {};
4522
+ const layout = raw.layout && typeof raw.layout === 'object' && !Array.isArray(raw.layout)
4523
+ ? raw.layout
4524
+ : {};
4525
+ raw.layout = { ...layout, skillsPath: requestedPath };
4526
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf-8');
4527
+ projectConfig = loadProjectConfig(projectRoot);
4528
+ invalidateAgentProjectState(projectRoot);
4529
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
4530
+ res.end(serializeJSON(buildSkillPathSettings(projectRoot)));
4531
+ }
4532
+ catch (error) {
4533
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
4534
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
4535
+ }
4536
+ return;
4537
+ }
4449
4538
  if (req.method === 'GET' && path === '/api/skills') {
4450
4539
  try {
4451
4540
  const skills = loadSkills(projectRoot).skills
@@ -5148,6 +5237,33 @@ export async function startLocalServer(opts) {
5148
5237
  }
5149
5238
  return;
5150
5239
  }
5240
+ const appStoryRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/story$/);
5241
+ if (req.method === 'POST' && appStoryRun) {
5242
+ const body = await readJSON(req).catch(() => ({}));
5243
+ const runId = typeof body.runId === 'string' ? body.runId : '';
5244
+ const evidence = dashboardRunEvidence.get(runId);
5245
+ const appId = decodeURIComponent(appStoryRun[1]);
5246
+ const dashboardId = decodeURIComponent(appStoryRun[2]);
5247
+ if (!evidence || evidence.expiresAt < Date.now() || evidence.appId !== appId || evidence.dashboardId !== dashboardId) {
5248
+ dashboardRunEvidence.delete(runId);
5249
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
5250
+ res.end(serializeJSON({ error: 'Dashboard run evidence expired or does not match this App. Run the dashboard again.' }));
5251
+ return;
5252
+ }
5253
+ // The endpoint accepts runId only. Optional provider wording can be added
5254
+ // here later; the claim-checked deterministic brief is always available.
5255
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
5256
+ res.end(serializeJSON({
5257
+ runId,
5258
+ snapshotId: evidence.snapshotId,
5259
+ filterFingerprint: evidence.filterFingerprint,
5260
+ resultFingerprint: evidence.resultFingerprint,
5261
+ personaFingerprint: evidence.personaFingerprint,
5262
+ facts: evidence.facts,
5263
+ story: evidence.story,
5264
+ }));
5265
+ return;
5266
+ }
5151
5267
  const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
5152
5268
  if (req.method === 'POST' && appDashRun) {
5153
5269
  try {
@@ -5160,6 +5276,12 @@ export async function startLocalServer(opts) {
5160
5276
  res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
5161
5277
  return;
5162
5278
  }
5279
+ const activeApp = activePersonaAppId();
5280
+ if (activeApp && activeApp !== appId) {
5281
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
5282
+ res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
5283
+ return;
5284
+ }
5163
5285
  const manifest = buildManifest({ projectRoot });
5164
5286
  const variables = body.variables && typeof body.variables === 'object'
5165
5287
  ? body.variables
@@ -5258,6 +5380,70 @@ export async function startLocalServer(opts) {
5258
5380
  });
5259
5381
  continue;
5260
5382
  }
5383
+ if (item.semantic) {
5384
+ try {
5385
+ if (!semanticLayer)
5386
+ throw new Error('Semantic layer is not available for this governed query.');
5387
+ const knownMetrics = new Set(semanticLayer.listMetrics().map((metric) => metric.name));
5388
+ const missingMetrics = item.semantic.metrics.filter((metric) => !knownMetrics.has(metric));
5389
+ if (missingMetrics.length)
5390
+ throw new Error(`Semantic definition changed; missing metric(s): ${missingMetrics.join(', ')}`);
5391
+ const knownModels = new Set(semanticLayer.listSemanticModels().map((model) => model.name));
5392
+ const missingModels = item.semantic.semanticModelRefs.filter((model) => !knownModels.has(model));
5393
+ if (missingModels.length)
5394
+ throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
5395
+ const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
5396
+ const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer);
5397
+ const activeFilters = Object.entries(dashboardVariables)
5398
+ .filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
5399
+ .map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
5400
+ const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
5401
+ dimension: filter.field,
5402
+ operator: filter.operator,
5403
+ values: Array.isArray(filter.value) ? filter.value.map(String) : [String(filter.value)],
5404
+ }));
5405
+ const composed = composeRuntimeSemanticQuery({
5406
+ metrics: item.semantic.metrics,
5407
+ dimensions: item.semantic.dimensions ?? [],
5408
+ filters: [...staticFilters, ...activeFilters],
5409
+ ...(item.semantic.timeDimension ? { timeDimension: { name: item.semantic.timeDimension, granularity: 'month' } } : {}),
5410
+ orderBy: item.semantic.orderBy?.map((order) => ({ name: order.field, direction: order.direction })),
5411
+ limit: item.semantic.limit,
5412
+ engine: item.semantic.provider,
5413
+ }, semanticLayer, {
5414
+ projectRoot,
5415
+ projectConfig,
5416
+ detectedProvider: semanticDetectedProvider,
5417
+ driver: targetConnection.driver,
5418
+ tableMapping,
5419
+ });
5420
+ if (!composed)
5421
+ throw new Error('The governed semantic query could not be composed.');
5422
+ const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
5423
+ const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
5424
+ tiles.push({
5425
+ tileId: item.i,
5426
+ status: 'ok',
5427
+ tileType: 'semantic',
5428
+ title: item.title ?? item.semantic.id,
5429
+ viz: item.viz,
5430
+ result: normalizeQueryResult(result),
5431
+ trustState: 'review_required',
5432
+ citation: { kind: 'semantic_query', name: item.semantic.id },
5433
+ });
5434
+ }
5435
+ catch (err) {
5436
+ tiles.push({
5437
+ tileId: item.i,
5438
+ status: 'error',
5439
+ tileType: 'semantic',
5440
+ title: item.title ?? item.semantic.id,
5441
+ error: err instanceof Error ? err.message : String(err),
5442
+ trustState: 'review_required',
5443
+ });
5444
+ }
5445
+ continue;
5446
+ }
5261
5447
  const block = resolveDashboardItemBlock(item, manifest);
5262
5448
  if (!block) {
5263
5449
  tiles.push({
@@ -5381,12 +5567,55 @@ export async function startLocalServer(opts) {
5381
5567
  }
5382
5568
  }
5383
5569
  localApps?.close();
5570
+ const snapshot = projectSnapshot();
5571
+ const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
5572
+ const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
5573
+ const personaFingerprint = createHash('sha256').update(activePersonaAppId() ?? 'global').digest('hex');
5574
+ const runId = `app_run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5575
+ const storyResult = buildDeterministicDashboardStory({
5576
+ goal: loaded.dashboard.story?.goal ?? loaded.dashboard.metadata.businessOutcome ?? loaded.dashboard.metadata.title,
5577
+ audience: loaded.dashboard.story?.audience ?? loaded.dashboard.metadata.audience,
5578
+ filters: dashboardVariables,
5579
+ tiles: tiles.map((tile) => ({
5580
+ tileId: tile.tileId,
5581
+ title: typeof tile.title === 'string' ? tile.title : tile.tileId,
5582
+ status: tile.status,
5583
+ trustState: (tile.trustState === 'certified' || tile.trustState === 'review_required' || tile.trustState === 'draft_ready'
5584
+ ? tile.trustState
5585
+ : tile.certificationStatus === 'certified' ? 'certified' : 'review_required'),
5586
+ result: tile.result,
5587
+ citation: tile.citation,
5588
+ })),
5589
+ eligibleTileIds: loaded.dashboard.story?.eligibleTileIds,
5590
+ driverTileIds: loaded.dashboard.story?.driverTileIds,
5591
+ });
5592
+ dashboardRunEvidence.set(runId, {
5593
+ appId,
5594
+ dashboardId,
5595
+ snapshotId: snapshot.snapshotId,
5596
+ filterFingerprint,
5597
+ resultFingerprint,
5598
+ personaFingerprint,
5599
+ facts: storyResult.facts,
5600
+ story: storyResult.story,
5601
+ expiresAt: Date.now() + 15 * 60_000,
5602
+ });
5603
+ for (const [id, evidence] of dashboardRunEvidence)
5604
+ if (evidence.expiresAt < Date.now())
5605
+ dashboardRunEvidence.delete(id);
5384
5606
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
5385
5607
  res.end(serializeJSON({
5386
5608
  appId,
5387
5609
  dashboardId,
5388
5610
  persona: activePersonaAppId() ? { appId: activePersonaAppId() } : null,
5611
+ runId,
5612
+ snapshotId: snapshot.snapshotId,
5613
+ filterFingerprint,
5614
+ resultFingerprint,
5615
+ personaFingerprint,
5389
5616
  tiles,
5617
+ facts: storyResult.facts,
5618
+ story: storyResult.story,
5390
5619
  }));
5391
5620
  }
5392
5621
  catch (err) {
@@ -6691,16 +6920,26 @@ export async function startLocalServer(opts) {
6691
6920
  res.end(serializeJSON({ error: 'goal is required' }));
6692
6921
  return;
6693
6922
  }
6694
- const certifiedOnly = body.certifiedOnly !== false;
6695
- const metrics = loadSemanticMetrics(projectRoot);
6696
- let blocks = collectPlanBlocks(projectRoot, { certifiedOnly });
6697
- // If nothing is certified yet, fall back to all drafts so the plan still
6698
- // shows what COULD be assembled (every section then reads as a gap to certify).
6699
- if (blocks.length === 0 && certifiedOnly)
6700
- blocks = collectPlanBlocks(projectRoot, { certifiedOnly: false });
6701
- const plan = await planApp({ goal, metrics, blocks });
6923
+ const snapshot = projectSnapshot();
6924
+ const kgPath = defaultKgPath(projectRoot);
6925
+ await reindexProject(projectRoot, { kgPath, manifest: snapshot.manifest });
6926
+ const kg = new KGStore(kgPath);
6927
+ let plan;
6928
+ try {
6929
+ plan = planAppFromPrompt({
6930
+ prompt: goal,
6931
+ kg,
6932
+ domain: typeof body.domain === 'string' ? body.domain : undefined,
6933
+ audience: typeof body.audience === 'string' ? body.audience : undefined,
6934
+ mode: body.mode === 'personal' ? 'personal' : 'stakeholder',
6935
+ snapshotId: snapshot.snapshotId,
6936
+ });
6937
+ }
6938
+ finally {
6939
+ kg.close();
6940
+ }
6702
6941
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6703
- res.end(serializeJSON({ plan, blockCount: blocks.length, metricCount: metrics.length, certifiedOnly }));
6942
+ res.end(serializeJSON({ plan, snapshotId: snapshot.snapshotId }));
6704
6943
  }
6705
6944
  catch (error) {
6706
6945
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -7595,9 +7834,35 @@ export async function startLocalServer(opts) {
7595
7834
  ?? Object.keys(connections)[0]
7596
7835
  ?? 'default';
7597
7836
  const dbtProfiles = discoverDbtProfileConnections(projectRoot, cfg);
7837
+ const activeDbtProfile = dbtProfiles.find((candidate) => candidate.missingFields.length === 0
7838
+ && !candidate.warnings.some((warning) => warning.startsWith('DuckDB file not found'))
7839
+ && !candidate.warnings.some((warning) => warning.startsWith('Not the default dbt target')));
7840
+ const activeConnection = connection ? {
7841
+ source: Object.keys(connections).length > 0 ? 'dql_config' : activeDbtProfile ? 'dbt_profile' : 'runtime',
7842
+ driver: connection.driver,
7843
+ ...(Object.keys(connections).length === 0 && activeDbtProfile ? { profileId: activeDbtProfile.id } : {}),
7844
+ } : null;
7598
7845
  const connectorStatus = getConnectorInstallStatuses(projectRoot);
7599
7846
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7600
- res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles, connectorStatus }));
7847
+ res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles, activeConnection, connectorStatus }));
7848
+ return;
7849
+ }
7850
+ if (req.method === 'POST' && path === '/api/connections/dbt-profiles/preview') {
7851
+ const body = await readJSON(req).catch(() => ({}));
7852
+ const profilePath = typeof body.path === 'string' ? body.path.trim() : '';
7853
+ if (!profilePath) {
7854
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7855
+ res.end(serializeJSON({ error: 'Enter a profiles.yml file or folder path.' }));
7856
+ return;
7857
+ }
7858
+ const dbtProfiles = discoverDbtProfileConnections(projectRoot, loadProjectConfig(projectRoot), profilePath);
7859
+ if (dbtProfiles.length === 0) {
7860
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
7861
+ res.end(serializeJSON({ error: 'No supported dbt profile targets were found at that path.' }));
7862
+ return;
7863
+ }
7864
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7865
+ res.end(serializeJSON({ dbtProfiles }));
7601
7866
  return;
7602
7867
  }
7603
7868
  if (req.method === "GET" && path === "/api/datasets") {
@@ -10663,6 +10928,18 @@ export function loadProjectConfig(projectRoot) {
10663
10928
  }
10664
10929
  return config;
10665
10930
  }
10931
+ /** Current Git-backed Skills folder and what the loader can read from it. */
10932
+ export function buildSkillPathSettings(projectRoot) {
10933
+ const resolvedPath = skillsDir(projectRoot);
10934
+ const loaded = loadSkills(projectRoot);
10935
+ return {
10936
+ path: configuredSkillsPath(projectRoot),
10937
+ resolvedPath,
10938
+ exists: existsSync(resolvedPath) && statSync(resolvedPath).isDirectory(),
10939
+ skillCount: loaded.skills.filter((skill) => skill.scope === 'project').length,
10940
+ errorCount: loaded.errors.length,
10941
+ };
10942
+ }
10666
10943
  function dbtFirstOwnsSemanticAuthoring(config) {
10667
10944
  return config.manifestVersion === 3 && config.modeling?.mode === 'dbt-first';
10668
10945
  }
@@ -16202,15 +16479,23 @@ function resolveDbtManifestPath(projectRoot, projectConfig = {}) {
16202
16479
  candidates.push(join(projectRoot, 'target', 'manifest.json'), join(resolve(projectRoot, '..'), 'target', 'manifest.json'), join(resolve(projectRoot, '../dbt'), 'target', 'manifest.json'), join(resolve(projectRoot, '../../dbt'), 'target', 'manifest.json'));
16203
16480
  return candidates.find((candidate, index, list) => list.indexOf(candidate) === index && existsSync(candidate));
16204
16481
  }
16205
- export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16482
+ export function discoverDbtProfileConnections(projectRoot, projectConfig, explicitPath) {
16206
16483
  const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
16207
- const projectProfileName = readDbtProjectProfileName(dbtProjectPath);
16208
- const profilePaths = findDbtProfilePaths(projectRoot, dbtProjectPath);
16484
+ const projectProfileName = explicitPath ? null : readDbtProjectProfileName(dbtProjectPath);
16485
+ const configuredProfilesDir = projectConfig.dbt?.profilesDir
16486
+ ? resolve(projectRoot, projectConfig.dbt.profilesDir)
16487
+ : undefined;
16488
+ const profilePaths = explicitPath
16489
+ ? findExplicitDbtProfilePaths(projectRoot, explicitPath)
16490
+ : findDbtProfilePaths(projectRoot, dbtProjectPath, configuredProfilesDir);
16209
16491
  const candidates = [];
16210
16492
  for (const profilePath of profilePaths) {
16211
16493
  const profiles = readYamlFile(profilePath);
16212
16494
  if (!profiles)
16213
16495
  continue;
16496
+ const profileRelativeBase = explicitPath && existsSync(join(dirname(profilePath), 'dbt_project.yml'))
16497
+ ? dirname(profilePath)
16498
+ : dbtProjectPath;
16214
16499
  for (const [profileName, rawProfile] of Object.entries(profiles)) {
16215
16500
  if (!rawProfile || typeof rawProfile !== 'object')
16216
16501
  continue;
@@ -16234,7 +16519,7 @@ export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16234
16519
  mapped.connection.filepath &&
16235
16520
  mapped.connection.filepath !== ':memory:') {
16236
16521
  if (!isAbsoluteLikePath(mapped.connection.filepath)) {
16237
- mapped.connection.filepath = resolve(dbtProjectPath, mapped.connection.filepath);
16522
+ mapped.connection.filepath = resolve(profileRelativeBase, mapped.connection.filepath);
16238
16523
  }
16239
16524
  if (!existsSync(mapped.connection.filepath)) {
16240
16525
  mapped.warnings.push(`DuckDB file not found at ${mapped.connection.filepath} — run \`dbt build\` (or \`dbt seed\`) to create it before querying.`);
@@ -16259,6 +16544,21 @@ export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16259
16544
  }
16260
16545
  return candidates.slice(0, 20);
16261
16546
  }
16547
+ export function buildDbtParseArgs(dbtProjectDir, profilesDir) {
16548
+ return [
16549
+ 'parse',
16550
+ '--project-dir', dbtProjectDir,
16551
+ ...(profilesDir ? ['--profiles-dir', profilesDir] : []),
16552
+ ];
16553
+ }
16554
+ /** CFG-003: use a complete default dbt target when no saved DQL connection exists. */
16555
+ export function resolveDbtProfileRuntimeConnection(projectRoot, projectConfig) {
16556
+ const candidates = discoverDbtProfileConnections(projectRoot, projectConfig);
16557
+ const complete = candidates.filter((candidate) => candidate.missingFields.length === 0
16558
+ && !candidate.warnings.some((warning) => warning.startsWith('DuckDB file not found')));
16559
+ const defaultTarget = complete.find((candidate) => !candidate.warnings.some((warning) => warning.startsWith('Not the default dbt target')));
16560
+ return defaultTarget?.connection ?? (complete.length === 1 ? complete[0].connection : null);
16561
+ }
16262
16562
  function findDbtProjectPath(projectRoot, projectConfig) {
16263
16563
  const configuredDbtDir = projectConfig.dbt?.projectDir
16264
16564
  ? resolve(projectRoot, projectConfig.dbt.projectDir)
@@ -16279,8 +16579,18 @@ function findDbtProjectPath(projectRoot, projectConfig) {
16279
16579
  ?? semanticDbtDir
16280
16580
  ?? projectRoot;
16281
16581
  }
16282
- function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16582
+ function portableProjectPath(projectRoot, absolutePath) {
16583
+ const normalizedAbsolute = resolve(absolutePath).replace(/\\/g, '/');
16584
+ const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
16585
+ return !relativePath
16586
+ ? '.'
16587
+ : relativePath === '..' || relativePath.startsWith('../')
16588
+ ? normalizedAbsolute
16589
+ : relativePath;
16590
+ }
16591
+ function findDbtProfilePaths(projectRoot, dbtProjectPath, configuredProfilesDir) {
16283
16592
  const dirs = [
16593
+ configuredProfilesDir,
16284
16594
  process.env.DBT_PROFILES_DIR,
16285
16595
  dbtProjectPath,
16286
16596
  projectRoot,
@@ -16288,7 +16598,7 @@ function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16288
16598
  ].filter((value) => Boolean(value));
16289
16599
  const paths = [];
16290
16600
  for (const dir of dirs) {
16291
- for (const filename of ['profiles.yml', 'profiles.yaml']) {
16601
+ for (const filename of ['profiles.yml', 'profiles.yaml', 'profile.yml', 'profile.yaml']) {
16292
16602
  const profilePath = resolve(dir, filename);
16293
16603
  if (existsSync(profilePath) && !paths.includes(profilePath)) {
16294
16604
  paths.push(profilePath);
@@ -16297,6 +16607,24 @@ function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16297
16607
  }
16298
16608
  return paths;
16299
16609
  }
16610
+ function findExplicitDbtProfilePaths(projectRoot, input) {
16611
+ const trimmed = input.trim();
16612
+ if (!trimmed)
16613
+ return [];
16614
+ const expanded = trimmed === '~'
16615
+ ? homedir()
16616
+ : trimmed.startsWith('~/')
16617
+ ? join(homedir(), trimmed.slice(2))
16618
+ : trimmed;
16619
+ const absolute = resolve(projectRoot, expanded);
16620
+ if (!existsSync(absolute))
16621
+ return [];
16622
+ if (!statSync(absolute).isDirectory())
16623
+ return [absolute];
16624
+ return ['profiles.yml', 'profiles.yaml', 'profile.yml', 'profile.yaml']
16625
+ .map((filename) => join(absolute, filename))
16626
+ .filter((profilePath) => existsSync(profilePath));
16627
+ }
16300
16628
  function readDbtProjectProfileName(dbtProjectPath) {
16301
16629
  const projectFile = join(dbtProjectPath, 'dbt_project.yml');
16302
16630
  const projectYaml = readYamlFile(projectFile);