@duckcodeailabs/dql-cli 1.13.2 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/apps-api.d.ts +26 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1045 -175
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-BlUE3qsX.js → AgentLogPage-DYmHO6_i.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-O88iK6UW.js → AiBuildDialog-BfhaOd1h.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-D-6G4Sh3.js → AiBuildResult-DLKFAe6R.js} +3 -3
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-MRSGtQDV.js → AiSidePanel-C6Z3URGr.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/{AnalyticsHome-CcRVO6zN.js → AnalyticsHome-uG3xCP_s.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/AppsView-DfieNjkh.js +5595 -0
  11. package/dist/assets/dql-notebook/assets/BlockStudio-90J_wvWL.js +55 -0
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-itEIUrYy.js → BusinessArtifactView-_zSMae5q.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-CbAqOpNc.js → DbtFirstModelingPage-DoXiPssu.js} +4 -4
  14. package/dist/assets/dql-notebook/assets/{GitPage-BxOqRYu2.js → GitPage-CUfAhS67.js} +3 -3
  15. package/dist/assets/dql-notebook/assets/{GlobalAiRail-Tfkx76CI.js → GlobalAiRail-ovtL7nQu.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{GovernedContextPage-CeaDbepP.js → GovernedContextPage-CaMiKAzA.js} +3 -3
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D1lAryYv.js → HelpDocsPage-CEGUx28b.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-GB6se6xW.js → HomePage-2v3SRf8V.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-zCyN-hdg.js → LineageDAG-AaqLEiBN.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-Cg0TgG2Y.js → LineageDetailView-CFc8pm91.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-DwMcenDf.js → LineageDrawer-DOkFjPq-.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-DUZx3ksU.js → LineagePathBreadcrumb-BrDwCuMs.js} +3 -3
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-DT_ycl6s.js → MiniLineageGraph-CiHtQs3y.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-qdhzeJPF.js → NewBlockModal-DAgJZbws.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BnaDzs1A.js → NewNotebookModal-xjG7nZ3O.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-CouPFzdo.js → NotebookEditor-CXZaKfYT.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-DB_KbH9Y.js → ReadinessPage-MuJBFwwg.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-CamQq_N2.js → SetupOnboarding-vKFtKtjh.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{SkillsPage--mYkOAK2.js → SkillsPage-DCFVOV5f.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-ClEYiYeA.js → TrustBadge-TcwXFFHK.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{UnifiedAgentRunPanel-DBfsmXes.js → UnifiedAgentRunPanel-Cu-YgIe4.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-Cg3UyGwh.js → answer-to-notebook-DCFWlHB7.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-DnYPGqlr.js → arrow-left-BLnlJAtp.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-Ct_YGrmU.js → arrow-right-BmUAb1aW.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-DULlwbj6.js → book-open-text-bdSChSag.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-ByYxoiS9.js → circle-x-Cc6dEaf4.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-BjYSO53F.js → dagre.esm-CPebQKQ9.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-DprWh9Yf.js → external-link-rmXvubf3.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-CrEEYkXN.js → grip-vertical-Sg4zd5-Q.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/index-mQd_pC-P.js +635 -0
  41. package/dist/assets/dql-notebook/assets/{link-2-BkW-rkEC.js → link-2-BLaPoFxW.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{list-tree-BipBLmIE.js → list-tree-j0s4asFK.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{minimize-2-BhXsglrO.js → minimize-2-Cl4pef4s.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{panel-right-open-BoCSilYB.js → panel-right-open-Clz-FtWi.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{play-mdoZawPu.js → play-DFdazMg7.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{rotate-ccw-ELiDRUmj.js → rotate-ccw-C3tTaR7s.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{semantic-fields-DVNRLfeU.js → semantic-fields-DIAhTlfy.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{sliders-horizontal-GeUzpmKz.js → sliders-horizontal-BUiu2tfY.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{star-D63O81Wp.js → star--Yq77aH4.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{triangle-alert-BWrCxQVE.js → triangle-alert-D5Ng-xZO.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{upload-CI-ldXhC.js → upload-L8BKQAxP.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-Cv7Yq2hK.js → usePersistedAgentThreadId-DBu97YaM.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{user-round-Do21aFGo.js → user-round-BK5N1cJW.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{wand-sparkles-DT7u7F_x.js → wand-sparkles-D8XDQD9z.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{workflow-DHh2mMbn.js → workflow-Bl9zwG0q.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{wrench-7tYr_GKb.js → wrench-C-zLCMbW.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{x-CcYceFMz.js → x-stiJ-sP7.js} +1 -1
  58. package/dist/assets/dql-notebook/index.html +1 -1
  59. package/dist/local-operation-coordinator.d.ts.map +1 -1
  60. package/dist/local-operation-coordinator.js +16 -2
  61. package/dist/local-operation-coordinator.js.map +1 -1
  62. package/dist/local-runtime.d.ts +55 -19
  63. package/dist/local-runtime.d.ts.map +1 -1
  64. package/dist/local-runtime.js +1098 -581
  65. package/dist/local-runtime.js.map +1 -1
  66. package/dist/package.json +10 -10
  67. package/package.json +10 -10
  68. package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +0 -5588
  69. package/dist/assets/dql-notebook/assets/BlockStudio-BrTNaDpb.js +0 -55
  70. package/dist/assets/dql-notebook/assets/index-9w93YPF9.js +0 -635
package/dist/apps-api.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { join, dirname, relative, basename } from 'node:path';
9
- import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, normalizeDqlArtifactReference, suggestAppId, createAppBuildDraft, applyAppBuildDraftOperations, } from '@duckcodeailabs/dql-core';
9
+ import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, normalizeDqlArtifactReference, suggestAppId, createAppBuildDraft, appBuildDraftHash, applyAppBuildDraftOperations, } from '@duckcodeailabs/dql-core';
10
10
  import { defaultPersonaRegistry, defaultLocalAppsDbPath, LocalAppStorage, personaFromMember, } from '@duckcodeailabs/dql-project';
11
11
  export async function handleAppsApi(ctx) {
12
12
  const { req, res, path, projectRoot } = ctx;
@@ -40,13 +40,71 @@ export async function handleAppsApi(ctx) {
40
40
  if (req.method === 'GET' && path === '/api/app-builds') {
41
41
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
42
42
  try {
43
- sendJson(res, 200, { ok: true, drafts: storage.listAppBuildDrafts() });
43
+ const drafts = storage.listAppBuildDrafts().flatMap((stored) => {
44
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
45
+ if (!migrated)
46
+ return [];
47
+ if (stored.version !== migrated.version)
48
+ storage.saveAppBuildDraft(migrated);
49
+ return [migrated];
50
+ });
51
+ sendJson(res, 200, { ok: true, drafts });
44
52
  }
45
53
  finally {
46
54
  storage.close();
47
55
  }
48
56
  return true;
49
57
  }
58
+ const sourceCandidatesMatch = path.match(/^\/api\/app-builds\/([^/]+)\/source-candidates$/);
59
+ if (sourceCandidatesMatch && req.method === 'GET') {
60
+ try {
61
+ const id = decodeURIComponent(sourceCandidatesMatch[1]);
62
+ const current = loadStoredAppBuildDraft(projectRoot, id);
63
+ if (!current) {
64
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
65
+ return true;
66
+ }
67
+ const { ensureAgentProjectReady, queryAppSourceCatalog } = await import('@duckcodeailabs/dql-agent');
68
+ await ensureAgentProjectReady(projectRoot);
69
+ const lifecycles = splitQueryValues(ctx.url.searchParams.getAll('lifecycle'));
70
+ const domains = splitQueryValues(ctx.url.searchParams.getAll('domain'));
71
+ const page = queryAppSourceCatalog(projectRoot, {
72
+ query: ctx.url.searchParams.get('q') ?? undefined,
73
+ cursor: ctx.url.searchParams.get('cursor') ?? undefined,
74
+ limit: Number(ctx.url.searchParams.get('limit')) || 50,
75
+ lifecycles: lifecycles.length ? lifecycles : undefined,
76
+ domains: domains.length ? domains : undefined,
77
+ sourcePolicy: current.sourcePolicy,
78
+ includeDeprecated: ctx.url.searchParams.get('includeDeprecated') === 'true',
79
+ });
80
+ sendJson(res, 200, { ok: true, ...page });
81
+ }
82
+ catch (err) {
83
+ const error = err;
84
+ sendJson(res, error.code?.includes('CURSOR') ? 409 : 400, { ok: false, code: error.code, error: error.message });
85
+ }
86
+ return true;
87
+ }
88
+ if (sourceCandidatesMatch && req.method === 'POST') {
89
+ try {
90
+ const id = decodeURIComponent(sourceCandidatesMatch[1]);
91
+ const current = loadStoredAppBuildDraft(projectRoot, id);
92
+ if (!current) {
93
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
94
+ return true;
95
+ }
96
+ const body = await readJson(req);
97
+ const { ensureAgentProjectReady, resolveAppSourceCatalogRecords } = await import('@duckcodeailabs/dql-agent');
98
+ await ensureAgentProjectReady(projectRoot);
99
+ const result = resolveAppSourceCatalogRecords(projectRoot, body.sourceIds ?? [], current.sourcePolicy);
100
+ sendJson(res, 200, { ok: true, ...result });
101
+ }
102
+ catch (err) {
103
+ const error = err;
104
+ sendJson(res, 400, { ok: false, code: error.code, error: error.message });
105
+ }
106
+ return true;
107
+ }
50
108
  let buildMatch = path.match(/^\/api\/app-builds\/([^/]+)$/);
51
109
  if (buildMatch && req.method === 'GET') {
52
110
  const draft = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
@@ -130,7 +188,103 @@ export async function handleAppsApi(ctx) {
130
188
  const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
131
189
  prompt: cleanString(body.prompt) || current.frame.goal,
132
190
  selectedBlockIds: body.selectedBlockIds,
133
- }, { generateGovernedAnswer: ctx.generateGovernedAnswer });
191
+ }, { generateGovernedAnswer: ctx.generateGovernedAnswer, planAppBuild: ctx.planAppBuild });
192
+ writeAppBuildDraftAiProposal(projectRoot, proposal);
193
+ sendJson(res, 201, { ok: true, proposal });
194
+ }
195
+ catch (err) {
196
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
197
+ }
198
+ return true;
199
+ }
200
+ const composeBuildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/compose$/);
201
+ if (composeBuildMatch && req.method === 'POST') {
202
+ try {
203
+ const id = decodeURIComponent(composeBuildMatch[1]);
204
+ const current = loadStoredAppBuildDraft(projectRoot, id);
205
+ if (!current) {
206
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
207
+ return true;
208
+ }
209
+ const body = await readJson(req);
210
+ if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash) {
211
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before composing this App.' });
212
+ return true;
213
+ }
214
+ const result = await composeStoredAppBuildDraft(projectRoot, current, body);
215
+ sendJson(res, 200, { ok: true, ...result });
216
+ }
217
+ catch (err) {
218
+ const message = err instanceof Error ? err.message : String(err);
219
+ sendJson(res, message.includes('APP_BUILD_') ? 409 : 400, { ok: false, error: message });
220
+ }
221
+ return true;
222
+ }
223
+ const reviseProposalMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals\/([^/]+)\/revisions$/);
224
+ if (reviseProposalMatch && req.method === 'POST') {
225
+ try {
226
+ const id = decodeURIComponent(reviseProposalMatch[1]);
227
+ const proposalId = decodeURIComponent(reviseProposalMatch[2]);
228
+ const current = loadStoredAppBuildDraft(projectRoot, id);
229
+ const previous = current ? loadAppBuildDraftAiProposal(projectRoot, id, proposalId) : null;
230
+ if (!current || !previous) {
231
+ sendJson(res, 404, { ok: false, error: 'App Build proposal not found.' });
232
+ return true;
233
+ }
234
+ const body = await readJson(req);
235
+ if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash
236
+ || previous.baseRevision !== current.revision || previous.baseProposalHash !== current.proposalHash) {
237
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh before revising this proposal.' });
238
+ return true;
239
+ }
240
+ const answers = body.answers ?? {};
241
+ const answerText = previous.clarifications.flatMap((question) => {
242
+ const answerId = answers[question.id];
243
+ const choice = question.choices.find((candidate) => candidate.id === answerId);
244
+ return choice ? [`${question.question}: ${choice.label}`] : [];
245
+ });
246
+ const selectedSourceIds = unique([
247
+ ...(body.selectedSourceIds ?? previous.defaultSelectedSourceIds ?? []),
248
+ ...(body.additionalSourceIds ?? []),
249
+ ].map(cleanString).filter(Boolean));
250
+ const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
251
+ prompt: [current.frame.goal, answerText.length ? `Clarification decisions: ${answerText.join('; ')}` : ''].filter(Boolean).join('\n'),
252
+ selectedBlockIds: selectedSourceIds,
253
+ }, { generateGovernedAnswer: ctx.generateGovernedAnswer, planAppBuild: ctx.planAppBuild });
254
+ proposal.defaultSelectedSourceIds = selectedSourceIds.filter((sourceId) => proposal.operations.some((operation) => (operation.type === 'upsert_source' && operation.source.id === sourceId)));
255
+ writeAppBuildDraftAiProposal(projectRoot, proposal);
256
+ sendJson(res, 201, { ok: true, proposal });
257
+ }
258
+ catch (err) {
259
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
260
+ }
261
+ return true;
262
+ }
263
+ const generateGapMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals\/([^/]+)\/gaps$/);
264
+ if (generateGapMatch && req.method === 'POST') {
265
+ try {
266
+ const id = decodeURIComponent(generateGapMatch[1]);
267
+ const proposalId = decodeURIComponent(generateGapMatch[2]);
268
+ const current = loadStoredAppBuildDraft(projectRoot, id);
269
+ const previous = current ? loadAppBuildDraftAiProposal(projectRoot, id, proposalId) : null;
270
+ if (!current || !previous) {
271
+ sendJson(res, 404, { ok: false, error: 'App Build proposal not found.' });
272
+ return true;
273
+ }
274
+ const body = await readJson(req);
275
+ if (body.expectedRevision !== current.revision || body.expectedProposalHash !== current.proposalHash
276
+ || previous.baseRevision !== current.revision || previous.baseProposalHash !== current.proposalHash) {
277
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh before generating a gap.' });
278
+ return true;
279
+ }
280
+ if (current.sourcePolicy !== 'include_review_required') {
281
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before generating a gap.' });
282
+ return true;
283
+ }
284
+ if (!ctx.generateGovernedAnswer)
285
+ throw new Error('The configured provider cannot generate an App gap in this runtime.');
286
+ const proposal = await generateExplicitAppBuildGap(projectRoot, current, previous, cleanString(body.requirementId), ctx.generateGovernedAnswer);
287
+ writeAppBuildDraftAiProposal(projectRoot, proposal);
134
288
  sendJson(res, 201, { ok: true, proposal });
135
289
  }
136
290
  catch (err) {
@@ -216,7 +370,16 @@ export async function handleAppsApi(ctx) {
216
370
  if (req.method === 'POST' && path === '/api/apps/recommend-blocks') {
217
371
  try {
218
372
  const body = await readJson(req);
219
- sendJson(res, 200, { blocks: recommendBlocks(projectRoot, body) });
373
+ const { ensureAgentProjectReady, queryAppSourceCatalog } = await import('@duckcodeailabs/dql-agent');
374
+ await ensureAgentProjectReady(projectRoot);
375
+ const page = queryAppSourceCatalog(projectRoot, {
376
+ query: [body.purpose, body.audience, ...(body.tags ?? [])].filter(Boolean).join(' '),
377
+ domains: body.domain && body.domain !== 'general' ? [body.domain] : undefined,
378
+ lifecycles: body.certifiedOnly ? ['certified'] : undefined,
379
+ sourcePolicy: body.certifiedOnly ? 'governed_only' : 'include_review_required',
380
+ limit: 50,
381
+ });
382
+ sendJson(res, 200, { blocks: page.items.map(appSourceCatalogRecordToLegacyRecommendation) });
220
383
  }
221
384
  catch (err) {
222
385
  sendJson(res, 500, { error: err.message });
@@ -930,22 +1093,6 @@ export async function handleAppsApi(ctx) {
930
1093
  }
931
1094
  return true;
932
1095
  }
933
- m = path.match(/^\/api\/app-recoveries\/([^/]+)\/restore$/);
934
- if (m && req.method === 'POST') {
935
- try {
936
- const result = restoreAppRecovery(projectRoot, decodeURIComponent(m[1]));
937
- if (!result.ok) {
938
- sendJson(res, result.status, result);
939
- return true;
940
- }
941
- ctx.scheduleProjectRefresh?.('app-restored');
942
- sendJson(res, 200, result);
943
- }
944
- catch (err) {
945
- sendJson(res, 500, { ok: false, error: err.message });
946
- }
947
- return true;
948
- }
949
1096
  if (m && req.method === 'PATCH') {
950
1097
  const id = decodeURIComponent(m[1]);
951
1098
  try {
@@ -964,7 +1111,7 @@ export async function handleAppsApi(ctx) {
964
1111
  return true;
965
1112
  }
966
1113
  if (m && req.method === 'GET') {
967
- const id = m[1];
1114
+ const id = decodeURIComponent(m[1]);
968
1115
  const result = loadAppById(projectRoot, id);
969
1116
  if (!result) {
970
1117
  sendJson(res, 404, { error: `App "${id}" not found` });
@@ -973,6 +1120,22 @@ export async function handleAppsApi(ctx) {
973
1120
  sendJson(res, 200, result);
974
1121
  return true;
975
1122
  }
1123
+ m = path.match(/^\/api\/app-recoveries\/([^/]+)\/restore$/);
1124
+ if (m && req.method === 'POST') {
1125
+ try {
1126
+ const result = restoreAppRecovery(projectRoot, decodeURIComponent(m[1]));
1127
+ if (!result.ok) {
1128
+ sendJson(res, result.status, result);
1129
+ return true;
1130
+ }
1131
+ ctx.scheduleProjectRefresh?.('app-restored');
1132
+ sendJson(res, 200, result);
1133
+ }
1134
+ catch (err) {
1135
+ sendJson(res, 500, { ok: false, error: err.message });
1136
+ }
1137
+ return true;
1138
+ }
976
1139
  // /api/apps/:id/dashboards
977
1140
  m = path.match(/^\/api\/apps\/([^/]+)\/dashboards$/);
978
1141
  if (m && req.method === 'GET') {
@@ -1807,6 +1970,107 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1807
1970
  kg.close();
1808
1971
  }
1809
1972
  }
1973
+ function appDraftSourceFromCatalog(source) {
1974
+ const certified = source.lifecycle === 'certified';
1975
+ return {
1976
+ id: source.sourceId,
1977
+ kind: 'block',
1978
+ sourceRef: source.executionRef,
1979
+ qualifiedIdentity: source.qualifiedIdentity,
1980
+ sourcePath: source.sourcePath,
1981
+ executionRef: source.executionRef,
1982
+ snapshotId: source.snapshotId,
1983
+ sourceRevision: source.sourceRevision,
1984
+ sourceFingerprint: source.sourceRevision,
1985
+ lifecycle: source.lifecycle,
1986
+ capabilities: source.capabilities,
1987
+ trustState: certified ? 'certified' : 'review_required',
1988
+ reviewStatus: certified ? 'not_required' : 'required',
1989
+ };
1990
+ }
1991
+ function appSourceCatalogRecordToLegacyRecommendation(source) {
1992
+ return {
1993
+ id: source.sourceId,
1994
+ name: source.name,
1995
+ domain: source.domain ?? 'uncategorized',
1996
+ status: source.lifecycle,
1997
+ owner: source.owner ?? null,
1998
+ tags: source.tags,
1999
+ path: source.executionRef,
2000
+ fingerprint: source.sourceRevision,
2001
+ lastModified: new Date(0).toISOString(),
2002
+ description: source.description ?? '',
2003
+ llmContext: null,
2004
+ chartType: source.capabilities.chartType,
2005
+ filterIds: source.capabilities.filters,
2006
+ dimensionIds: source.capabilities.dimensions,
2007
+ score: source.score ?? 0,
2008
+ reasons: source.reasons,
2009
+ };
2010
+ }
2011
+ function uniqueAppSourceRecords(sources) {
2012
+ const seen = new Set();
2013
+ return sources.filter((source) => {
2014
+ if (seen.has(source.sourceId))
2015
+ return false;
2016
+ seen.add(source.sourceId);
2017
+ return true;
2018
+ });
2019
+ }
2020
+ function uniqueTileId(existing, preferred) {
2021
+ const base = slugify(preferred) || 'component';
2022
+ const ids = new Set(existing.map((item) => item.i));
2023
+ if (!ids.has(base))
2024
+ return base;
2025
+ for (let index = 2; index < 10_000; index += 1) {
2026
+ const candidate = `${base}-${index}`;
2027
+ if (!ids.has(candidate))
2028
+ return candidate;
2029
+ }
2030
+ return `${base}-${Date.now()}`;
2031
+ }
2032
+ const APP_REQUIREMENT_STOP_WORDS = new Set([
2033
+ 'a', 'an', 'and', 'app', 'application', 'build', 'by', 'create', 'dashboard',
2034
+ 'for', 'from', 'in', 'last', 'me', 'of', 'on', 'report', 'show', 'showing',
2035
+ 'the', 'to', 'using', 'view', 'with',
2036
+ ]);
2037
+ function appRequirementTokens(value) {
2038
+ return new Set(value.toLowerCase().split(/[^a-z0-9]+/)
2039
+ .map((token) => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token)
2040
+ .filter((token) => token.length > 1 && !APP_REQUIREMENT_STOP_WORDS.has(token)));
2041
+ }
2042
+ function catalogSourceSatisfiesRequirement(source, requirement) {
2043
+ const normalized = (values) => new Set(values.flatMap((value) => Array.from(appRequirementTokens(value))));
2044
+ const sourceMeasures = normalized(source.capabilities.measures);
2045
+ const sourceDimensions = normalized(source.capabilities.dimensions);
2046
+ const sourceFilters = normalized(source.capabilities.filters);
2047
+ const requires = [
2048
+ [normalized(requirement.measures), sourceMeasures],
2049
+ [normalized(requirement.dimensions), sourceDimensions],
2050
+ [normalized(requirement.filters), sourceFilters],
2051
+ ];
2052
+ const declaredCompatible = requires.every(([required, available]) => (required.size === 0 || (available.size > 0 && Array.from(required).every((field) => available.has(field)))));
2053
+ const hasDeclaredRequirement = requires.some(([required]) => required.size > 0);
2054
+ if (declaredCompatible && hasDeclaredRequirement)
2055
+ return true;
2056
+ const requirementTerms = appRequirementTokens(requirement.question);
2057
+ if (requirementTerms.size === 0)
2058
+ return false;
2059
+ const sourceTerms = appRequirementTokens([
2060
+ source.title,
2061
+ source.name,
2062
+ source.description ?? '',
2063
+ source.qualifiedIdentity,
2064
+ ...source.tags,
2065
+ ...source.capabilities.measures,
2066
+ ...source.capabilities.dimensions,
2067
+ ].join(' '));
2068
+ const overlap = Array.from(requirementTerms).filter((term) => sourceTerms.has(term)).length;
2069
+ return overlap >= Math.min(2, requirementTerms.size);
2070
+ }
2071
+ function splitQueryValues(values) {
2072
+ return unique(values.flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean));
2073
+ }
1810
2074
  function materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, proposalId, candidates, inputDashboards) {
1811
2075
  const eligible = candidates.filter((candidate) => (candidate.source === 'ai_generated'
1812
2076
  && Boolean(candidate.sql)
@@ -1956,157 +2220,155 @@ function materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, pro
1956
2220
  * any canonical App source.
1957
2221
  */
1958
2222
  export async function proposeAppBuildDraftOperations(projectRoot, draft, input, hooks) {
1959
- const session = await proposeAppAiBuild(projectRoot, {
1960
- sessionId: `app_build_${draft.id}_${draft.revision}`,
1961
- prompt: input.prompt,
2223
+ const prompt = cleanString(input.prompt) || draft.frame.goal;
2224
+ const { ensureAgentProjectReady, planAppBuildBrief, resolveAppSourceCatalogRecords, shortlistAppSources, } = await import('@duckcodeailabs/dql-agent');
2225
+ await ensureAgentProjectReady(projectRoot);
2226
+ const requestedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
2227
+ if (requestedIds.length > 12) {
2228
+ throw new Error('APP_BUILD_SOURCE_LIMIT: at most 12 required App sources can be planned at once.');
2229
+ }
2230
+ const resolvedRequired = requestedIds.length
2231
+ ? resolveAppSourceCatalogRecords(projectRoot, requestedIds, draft.sourcePolicy)
2232
+ : { items: [], missingSourceIds: [] };
2233
+ if (resolvedRequired.missingSourceIds.length) {
2234
+ throw new Error(`APP_BUILD_SOURCE_DRIFT: these exact App sources no longer resolve: ${resolvedRequired.missingSourceIds.join(', ')}`);
2235
+ }
2236
+ const policyBlocked = resolvedRequired.items.filter((source) => !source.eligibility.localPreview);
2237
+ if (policyBlocked.length) {
2238
+ throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before adding ${policyBlocked.map((source) => source.title).join(', ')}.`);
2239
+ }
2240
+ const candidates = uniqueAppSourceRecords([
2241
+ ...resolvedRequired.items,
2242
+ ...shortlistAppSources(projectRoot, prompt, {
2243
+ sourcePolicy: draft.sourcePolicy,
2244
+ domains: draft.pages[0]?.metadata.domain && draft.pages[0].metadata.domain !== 'general'
2245
+ ? [draft.pages[0].metadata.domain]
2246
+ : undefined,
2247
+ }).items,
2248
+ ]).slice(0, 12);
2249
+ const brief = await planAppBuildBrief({
2250
+ prompt,
2251
+ candidates,
2252
+ requiredSourceIds: resolvedRequired.items.map((source) => source.sourceId),
2253
+ sourcePolicy: draft.sourcePolicy,
1962
2254
  domain: draft.pages[0]?.metadata.domain,
1963
2255
  audience: draft.frame.audience,
1964
- mode: draft.sourcePolicy === 'include_review_required' ? 'personal' : 'stakeholder',
1965
- exploreGaps: draft.sourcePolicy === 'include_review_required',
1966
- selectedBlockIds: input.selectedBlockIds,
1967
- ...(draft.baseApp ? { existingAppId: draft.baseApp.appId } : {}),
1968
- }, hooks);
1969
- if (session.status !== 'proposed' || !session.plan || !session.proposal) {
1970
- throw new Error(session.error ?? 'The App planner did not produce a proposal.');
1971
- }
1972
- const plan = session.plan;
1973
- const { KGStore, defaultKgPath, materializeAppPlanDocuments } = await import('@duckcodeailabs/dql-agent');
1974
- const kg = new KGStore(defaultKgPath(projectRoot));
1975
- let dashboards;
1976
- try {
1977
- dashboards = materializeAppPlanDocuments(plan, kg).dashboards;
1978
- }
1979
- finally {
1980
- kg.close();
1981
- }
1982
- const proposalTileById = new Map(session.proposal.tiles.map((tile) => [tile.id, tile]));
1983
- const plannedTiles = plan.pages.flatMap((page) => page.tiles);
1984
- const sourceOperations = plannedTiles.flatMap((tile) => {
1985
- const proposalTile = proposalTileById.get(tile.id);
1986
- if (tile.kind === 'certified_block' && tile.blockId) {
1987
- return [{
1988
- type: 'upsert_source',
1989
- source: {
1990
- id: `block:${tile.blockId}`,
1991
- kind: 'certified_block',
1992
- sourceRef: tile.blockId,
1993
- sourceFingerprint: proposalTile?.preflight.sourceFingerprint,
1994
- receiptId: proposalTile?.preflight.receiptId,
1995
- trustState: 'certified',
1996
- reviewStatus: 'not_required',
1997
- },
1998
- }];
1999
- }
2000
- if (tile.kind === 'semantic_query' && tile.semantic) {
2001
- return [{
2002
- type: 'upsert_source',
2003
- source: {
2004
- id: `semantic:${tile.semantic.id}`,
2005
- kind: 'governed_semantic',
2006
- sourceRef: tile.semantic.id,
2007
- qualifiedIdentity: tile.semantic.qualifiedMetricIds?.join(',') || tile.semantic.id,
2008
- snapshotId: tile.semantic.snapshotId,
2009
- sourceFingerprint: tile.semantic.definitionFingerprint,
2010
- receiptId: proposalTile?.preflight.receiptId,
2011
- trustState: 'review_required',
2012
- reviewStatus: 'required',
2013
- },
2014
- }];
2015
- }
2016
- return [];
2256
+ complete: hooks?.planAppBuild,
2257
+ });
2258
+ const byId = new Map(candidates.map((candidate) => [candidate.sourceId, candidate]));
2259
+ const plannedSources = brief.selectedSourceIds.flatMap((sourceId) => {
2260
+ const source = byId.get(sourceId);
2261
+ return source ? [source] : [];
2017
2262
  });
2018
- const reviewAnalyses = materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, session.id, session.proposal.tiles, dashboards);
2019
- dashboards = reviewAnalyses.dashboards;
2020
- sourceOperations.push(...reviewAnalyses.sourceOperations);
2021
- const sourceIdForCoverage = (source, sourceId) => {
2022
- if (!sourceId)
2263
+ const sourceOperations = plannedSources.map((source) => ({
2264
+ type: 'upsert_source',
2265
+ source: appDraftSourceFromCatalog(source),
2266
+ }));
2267
+ const page = draft.pages[0];
2268
+ if (!page)
2269
+ throw new Error('The App draft needs a page before AI can compose it.');
2270
+ const structuralItems = page.layout.items.filter((item) => Boolean(item.text));
2271
+ const baseY = structuralItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
2272
+ const reservedItems = [...structuralItems];
2273
+ const componentTileIdById = new Map();
2274
+ const plannedItems = brief.components.flatMap((component, index) => {
2275
+ const source = byId.get(component.sourceId);
2276
+ if (!source)
2023
2277
  return [];
2024
- if (source === 'certified_block')
2025
- return [sourceId.startsWith('block:') ? sourceId : `block:${sourceId}`];
2026
- if (source === 'semantic_query')
2027
- return [sourceId.startsWith('semantic:') ? sourceId : `semantic:${sourceId}`];
2028
- return [];
2278
+ const certified = source.lifecycle === 'certified';
2279
+ const tileId = uniqueTileId(reservedItems, component.id || `component-${index + 1}`);
2280
+ componentTileIdById.set(component.id, tileId);
2281
+ const tile = {
2282
+ i: tileId,
2283
+ x: (index % 2) * 6,
2284
+ y: baseY + Math.floor(index / 2) * 4,
2285
+ w: component.view === 'kpi' ? 3 : 6,
2286
+ h: component.view === 'kpi' ? 2 : 4,
2287
+ sourceId: source.sourceId,
2288
+ sourceRevision: source.sourceRevision,
2289
+ block: { ref: source.executionRef },
2290
+ viz: { type: component.view },
2291
+ title: component.title,
2292
+ sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
2293
+ filterBindings: source.capabilities.filters.map((filter) => ({
2294
+ filter,
2295
+ binding: filter,
2296
+ mode: 'predicate',
2297
+ capability: 'preflight_required',
2298
+ })),
2299
+ review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
2300
+ trustState: certified ? 'certified' : 'review_required',
2301
+ reviewStatus: certified ? 'certified' : 'review_required',
2302
+ };
2303
+ reservedItems.push(tile);
2304
+ return [tile];
2305
+ });
2306
+ const nextPage = {
2307
+ ...page,
2308
+ metadata: { ...page.metadata, description: brief.frame.decision || brief.frame.goal },
2309
+ layout: {
2310
+ ...page.layout,
2311
+ items: [...structuralItems, ...plannedItems],
2312
+ responsive: responsiveLayoutsForDashboardItems([...structuralItems, ...plannedItems]),
2313
+ },
2029
2314
  };
2030
- const clarifications = session.proposal.clarifications.map((clarification) => ({
2031
- id: clarification.id,
2032
- question: clarification.question,
2033
- choices: clarification.choices.map((label, index) => ({ id: slugify(label) || `choice-${index + 1}`, label })),
2034
- required: clarification.required,
2035
- }));
2036
- const replaceInitialBlank = !draft.baseApp
2037
- && draft.pages.length === 1
2038
- && draft.pages[0].layout.items.length === 0
2039
- && !dashboards.some((page) => page.id === draft.pages[0].id);
2040
- const coverage = plan.requirementCoverage.map((item) => {
2041
- const reviewCoverage = reviewAnalyses.coverageByRequirement.get(item.requirementId);
2042
- if (reviewCoverage) {
2043
- return {
2044
- requirementId: item.requirementId,
2045
- status: 'covered',
2046
- sourceIds: reviewCoverage.sourceIds,
2047
- componentIds: reviewCoverage.componentIds,
2048
- reasons: ['Covered by explicitly selectable review-required App analysis.'],
2049
- };
2050
- }
2315
+ const coverage = brief.requirements.map((requirement) => {
2316
+ const components = brief.components.filter((component) => component.requirementIds.includes(requirement.id));
2051
2317
  return {
2052
- requirementId: item.requirementId,
2053
- status: item.status === 'covered' ? 'covered' : 'gap',
2054
- sourceIds: sourceIdForCoverage(item.source, item.sourceId),
2055
- componentIds: item.tileId ? [item.tileId] : [],
2056
- reasons: item.reasons,
2318
+ requirementId: requirement.id,
2319
+ status: components.length ? 'covered' : 'gap',
2320
+ sourceIds: Array.from(new Set(components.map((component) => component.sourceId))),
2321
+ componentIds: components.map((component) => componentTileIdById.get(component.id) ?? component.id),
2322
+ reasons: components.length
2323
+ ? ['Covered by a source-bound App component from the validated Build Brief.']
2324
+ : ['No supplied source candidate covered this requirement.'],
2057
2325
  };
2058
2326
  });
2327
+ const clarifications = brief.frame.clarificationQuestions ?? [];
2328
+ const reviewTasks = plannedSources
2329
+ .filter((source) => source.lifecycle !== 'certified')
2330
+ .map((source) => ({
2331
+ type: 'set_review_task',
2332
+ task: {
2333
+ id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
2334
+ message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
2335
+ status: 'open',
2336
+ sourceId: source.sourceId,
2337
+ pageId: page.id,
2338
+ },
2339
+ }));
2059
2340
  const operations = [
2060
- { type: 'set_name', name: plan.name },
2061
2341
  {
2062
2342
  type: 'set_frame',
2063
- frame: {
2064
- goal: session.proposal.buildFrame.goal,
2065
- decision: plan.businessGoal,
2066
- audience: session.proposal.buildFrame.audience,
2067
- metrics: session.proposal.buildFrame.metrics,
2068
- dimensions: session.proposal.buildFrame.dimensions,
2069
- grain: session.proposal.buildFrame.grain,
2070
- timeRange: session.proposal.buildFrame.timeRange,
2071
- comparison: session.proposal.buildFrame.comparison,
2072
- filters: session.proposal.buildFrame.filters,
2073
- desiredOutput: session.proposal.buildFrame.desiredOutput,
2074
- clarificationQuestions: clarifications,
2075
- },
2343
+ frame: brief.frame,
2076
2344
  },
2077
2345
  {
2078
2346
  type: 'set_requirements',
2079
- requirements: plan.requirements.map((requirement) => ({
2080
- id: requirement.id,
2081
- question: requirement.question,
2082
- role: requirement.role,
2083
- required: true,
2084
- measures: requirement.measures,
2085
- dimensions: requirement.dimensions,
2086
- filters: requirement.filters,
2087
- grain: requirement.grain,
2088
- })),
2347
+ requirements: brief.requirements,
2089
2348
  coverage,
2090
2349
  },
2091
- ...(replaceInitialBlank ? [{ type: 'remove_page', pageId: draft.pages[0].id }] : []),
2092
- ...dashboards.map((page) => ({ type: 'upsert_page', page })),
2350
+ { type: 'upsert_page', page: nextPage },
2093
2351
  ...sourceOperations,
2094
- ...reviewAnalyses.reviewTasks,
2352
+ ...reviewTasks,
2095
2353
  ];
2096
2354
  return {
2097
- id: session.id,
2355
+ id: `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2098
2356
  draftId: draft.id,
2099
2357
  baseRevision: draft.revision,
2100
2358
  baseProposalHash: draft.proposalHash,
2101
2359
  operations,
2102
- defaultSelectedSourceIds: sourceOperations.flatMap((operation) => (operation.type === 'upsert_source' && operation.source.kind !== 'review_dql' ? [operation.source.id] : [])),
2360
+ defaultSelectedSourceIds: plannedSources
2361
+ .filter((source) => source.eligibility.localPreview)
2362
+ .map((source) => source.sourceId),
2103
2363
  clarifications,
2364
+ candidateSourceIds: brief.candidateSourceIds,
2365
+ warnings: brief.warnings,
2104
2366
  summary: {
2105
- requirements: plan.requirements.length,
2106
- covered: plan.requirementCoverage.filter((item) => item.status === 'covered').length,
2107
- gaps: plan.requirementCoverage.filter((item) => item.status === 'gap').length,
2108
- certifiedSources: session.proposal.coverage.certifiedTiles,
2109
- semanticSources: session.proposal.coverage.semanticTiles,
2367
+ requirements: brief.requirements.length,
2368
+ covered: coverage.filter((item) => item.status === 'covered').length,
2369
+ gaps: coverage.filter((item) => item.status === 'gap').length,
2370
+ certifiedSources: plannedSources.filter((source) => source.lifecycle === 'certified').length,
2371
+ semanticSources: 0,
2110
2372
  },
2111
2373
  };
2112
2374
  }
@@ -3264,12 +3526,13 @@ export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input)
3264
3526
  function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3265
3527
  const blockers = [];
3266
3528
  let governedTiles = 0;
3267
- const blocks = collectBlockCandidates(projectRoot);
3529
+ const blocks = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
3268
3530
  for (const dashboard of dashboards) {
3269
3531
  for (const item of dashboard.layout.items) {
3270
3532
  if (item.block) {
3271
3533
  governedTiles += 1;
3272
3534
  const blockRef = item.block;
3535
+ const boundRevision = item.sourceRevision ?? item.review?.sourceFingerprint;
3273
3536
  const block = blocks.find((candidate) => ('blockId' in blockRef
3274
3537
  ? candidate.id === blockRef.blockId || candidate.name === blockRef.blockId
3275
3538
  : candidate.path === blockRef.ref));
@@ -3281,7 +3544,15 @@ function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3281
3544
  message: `${dashboard.id}/${item.i} no longer resolves to a certified block.`,
3282
3545
  });
3283
3546
  }
3284
- else if (item.review?.sourceFingerprint?.startsWith('sha256:') && item.review.sourceFingerprint !== block.fingerprint) {
3547
+ else if (!boundRevision) {
3548
+ blockers.push({
3549
+ dashboardId: dashboard.id,
3550
+ tileId: item.i,
3551
+ code: 'block_source_unversioned',
3552
+ message: `${dashboard.id}/${item.i} has no bound source revision; refresh the certified block before publication.`,
3553
+ });
3554
+ }
3555
+ else if (boundRevision !== block.fingerprint) {
3285
3556
  blockers.push({
3286
3557
  dashboardId: dashboard.id,
3287
3558
  tileId: item.i,
@@ -3350,6 +3621,463 @@ function appBuildDraftDir(projectRoot) {
3350
3621
  // are persisted transactionally in .dql/local/apps.sqlite.
3351
3622
  return join(projectRoot, '.dql', 'local', 'app-builds');
3352
3623
  }
3624
+ function appBuildProposalDir(projectRoot, draftId) {
3625
+ return join(appBuildDraftDir(projectRoot), draftId, 'proposals');
3626
+ }
3627
+ function writeAppBuildDraftAiProposal(projectRoot, proposal) {
3628
+ if (!/^[a-z0-9_:-]+$/i.test(proposal.draftId) || !/^[a-z0-9_:-]+$/i.test(proposal.id)) {
3629
+ throw new Error('APP_BUILD_PROPOSAL_INVALID: proposal identity is invalid.');
3630
+ }
3631
+ const dir = appBuildProposalDir(projectRoot, proposal.draftId);
3632
+ mkdirSync(dir, { recursive: true });
3633
+ const path = join(dir, `${proposal.id}.json`);
3634
+ const candidate = `${path}.candidate`;
3635
+ writeFileSync(candidate, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
3636
+ renameSync(candidate, path);
3637
+ }
3638
+ function loadAppBuildDraftAiProposal(projectRoot, draftId, proposalId) {
3639
+ if (!/^[a-z0-9_:-]+$/i.test(draftId) || !/^[a-z0-9_:-]+$/i.test(proposalId))
3640
+ return null;
3641
+ const path = join(appBuildProposalDir(projectRoot, draftId), `${proposalId}.json`);
3642
+ if (!existsSync(path))
3643
+ return null;
3644
+ try {
3645
+ const proposal = JSON.parse(readFileSync(path, 'utf8'));
3646
+ return proposal.draftId === draftId && proposal.id === proposalId ? proposal : null;
3647
+ }
3648
+ catch {
3649
+ return null;
3650
+ }
3651
+ }
3652
+ async function generateExplicitAppBuildGap(projectRoot, draft, previous, requirementId, generate) {
3653
+ const requirementOperation = previous.operations.find((operation) => operation.type === 'set_requirements');
3654
+ const requirement = requirementOperation?.type === 'set_requirements'
3655
+ ? requirementOperation.requirements.find((candidate) => candidate.id === requirementId)
3656
+ : undefined;
3657
+ const coverage = requirementOperation?.type === 'set_requirements'
3658
+ ? requirementOperation.coverage?.find((candidate) => candidate.requirementId === requirementId)
3659
+ : undefined;
3660
+ if (!requirement || coverage?.status === 'covered') {
3661
+ throw new Error('APP_BUILD_GAP_NOT_FOUND: choose an uncovered App requirement.');
3662
+ }
3663
+ const answer = await generate(requirement.question);
3664
+ const sql = cleanString(answer.sql);
3665
+ if (!sql)
3666
+ throw new Error('APP_BUILD_GAP_NO_QUERY: the provider did not return executable review DQL.');
3667
+ if (sql.includes('"""'))
3668
+ throw new Error('APP_BUILD_GAP_INVALID_QUERY: generated SQL contains an unsupported triple-quote delimiter.');
3669
+ const proposalId = `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3670
+ const key = slugify(requirement.id) || createHash('sha256').update(requirement.question).digest('hex').slice(0, 12);
3671
+ const sourceRef = `proposals/${proposalId}/drafts/${key}.dql`;
3672
+ const source = [
3673
+ `block "app_${slugify(draft.appId)}_${key}" {`,
3674
+ ` domain = "${escapeDqlString(draft.pages[0]?.metadata.domain ?? 'general')}"`,
3675
+ ' type = "custom"',
3676
+ ' status = "review"',
3677
+ ' owner = "local-app-builder"',
3678
+ ` description = "${escapeDqlString(answer.text?.slice(0, 240) || requirement.question)}"`,
3679
+ ` llmContext = "${escapeDqlString(`Explicitly generated App gap: ${requirement.question}`)}"`,
3680
+ ' caveats = ["AI-generated App gap. Validate joins, grain, filters, and interpretation before promotion."]',
3681
+ ' tags = ["app-scoped", "ai-generated", "needs-review"]',
3682
+ '',
3683
+ ' query = """',
3684
+ sql,
3685
+ ' """',
3686
+ '',
3687
+ ' visualization {',
3688
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(answer.suggestedViz))}"`,
3689
+ ' }',
3690
+ '}',
3691
+ '',
3692
+ ].join('\n');
3693
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
3694
+ const absolute = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
3695
+ mkdirSync(dirname(absolute), { recursive: true });
3696
+ writeFileSync(absolute, source, 'utf8');
3697
+ const sourceId = `review-dql:gap:${createHash('sha256').update(`${proposalId}:${requirement.id}`).digest('hex').slice(0, 16)}`;
3698
+ const pageOperation = previous.operations.find((operation) => operation.type === 'upsert_page');
3699
+ if (!pageOperation || pageOperation.type !== 'upsert_page')
3700
+ throw new Error('APP_BUILD_PAGE_REQUIRED: the proposal has no App page.');
3701
+ const tileId = uniqueTileId(pageOperation.page.layout.items, `gap-${requirement.id}`);
3702
+ const y = pageOperation.page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
3703
+ const tile = {
3704
+ i: tileId,
3705
+ x: 0,
3706
+ y,
3707
+ w: 6,
3708
+ h: 4,
3709
+ sourceId,
3710
+ sourceRevision: fingerprint,
3711
+ draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
3712
+ viz: { type: normalizeGeneratedViz(answer.suggestedViz) },
3713
+ title: titleForGapQuestion(requirement.question),
3714
+ sourceClass: 'exploratory_analysis',
3715
+ review: { status: 'required', sourceFingerprint: fingerprint },
3716
+ trustState: 'review_required',
3717
+ reviewStatus: 'review_required',
3718
+ };
3719
+ const operations = previous.operations.map((operation) => {
3720
+ if (operation === pageOperation && operation.type === 'upsert_page') {
3721
+ const items = [...operation.page.layout.items, tile];
3722
+ return { type: 'upsert_page', page: { ...operation.page, layout: { ...operation.page.layout, items, responsive: responsiveLayoutsForDashboardItems(items) } } };
3723
+ }
3724
+ if (operation.type === 'set_requirements') {
3725
+ return {
3726
+ ...operation,
3727
+ coverage: (operation.coverage ?? []).map((item) => item.requirementId === requirement.id
3728
+ ? { ...item, status: 'covered', sourceIds: [sourceId], componentIds: [tileId], reasons: ['Covered by explicitly generated review-required App DQL.'] }
3729
+ : item),
3730
+ };
3731
+ }
3732
+ return operation;
3733
+ });
3734
+ operations.push({
3735
+ type: 'upsert_source',
3736
+ source: {
3737
+ id: sourceId,
3738
+ kind: 'review_dql',
3739
+ sourceRef,
3740
+ qualifiedIdentity: tile.title,
3741
+ sourceRevision: fingerprint,
3742
+ sourceFingerprint: fingerprint,
3743
+ lifecycle: 'review',
3744
+ capabilities: { measures: requirement.measures, dimensions: requirement.dimensions, outputs: [], filters: requirement.filters, allowedVisualizations: [tile.viz.type], parameters: [] },
3745
+ trustState: 'review_required',
3746
+ reviewStatus: 'required',
3747
+ },
3748
+ });
3749
+ operations.push({
3750
+ type: 'set_review_task',
3751
+ task: {
3752
+ id: `review-gap-${key}`,
3753
+ message: `Validate the joins, grain, filters, and business interpretation for ${tile.title}.`,
3754
+ status: 'open',
3755
+ sourceId,
3756
+ pageId: pageOperation.page.id,
3757
+ tileId,
3758
+ },
3759
+ });
3760
+ return {
3761
+ ...previous,
3762
+ id: proposalId,
3763
+ operations,
3764
+ warnings: Array.from(new Set([...(previous.warnings ?? []), 'Generated gap remains review-required and local-only.'])),
3765
+ summary: {
3766
+ ...previous.summary,
3767
+ covered: previous.summary.covered + 1,
3768
+ gaps: Math.max(0, previous.summary.gaps - 1),
3769
+ },
3770
+ };
3771
+ }
3772
+ async function composeStoredAppBuildDraft(projectRoot, current, request) {
3773
+ if (request.mode === 'ai') {
3774
+ const proposal = loadAppBuildDraftAiProposal(projectRoot, current.id, cleanString(request.proposalId));
3775
+ if (!proposal)
3776
+ throw new Error('APP_BUILD_PROPOSAL_NOT_FOUND: ask AI to rebuild the proposal.');
3777
+ if (proposal.baseRevision !== current.revision || proposal.baseProposalHash !== current.proposalHash) {
3778
+ throw new Error('APP_BUILD_PROPOSAL_CONFLICT: the proposal is based on an older draft revision.');
3779
+ }
3780
+ const selected = new Set(unique((request.selectedSourceIds ?? []).map(cleanString).filter(Boolean)));
3781
+ const available = new Map(proposal.operations.flatMap((operation) => (operation.type === 'upsert_source' ? [[operation.source.id, operation.source]] : [])));
3782
+ const missing = Array.from(selected).filter((sourceId) => !available.has(sourceId));
3783
+ if (missing.length)
3784
+ throw new Error(`APP_BUILD_SOURCE_NOT_IN_PROPOSAL: ${missing.join(', ')}`);
3785
+ for (const sourceId of selected) {
3786
+ const source = available.get(sourceId);
3787
+ if (current.sourcePolicy === 'governed_only' && source.trustState !== 'certified') {
3788
+ throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before adding ${source.qualifiedIdentity ?? source.sourceRef}.`);
3789
+ }
3790
+ }
3791
+ const operations = operationsForSelectedAppBuildSources(proposal.operations, selected);
3792
+ const next = applyAppBuildDraftOperations(current, current.revision, operations);
3793
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
3794
+ const pages = operations.flatMap((operation) => operation.type === 'upsert_page' ? [operation.page] : []);
3795
+ return {
3796
+ draft: next,
3797
+ pageIds: pages.map((page) => page.id),
3798
+ tileIds: pages.flatMap((page) => page.layout.items.filter((item) => item.sourceId && selected.has(item.sourceId)).map((item) => item.i)),
3799
+ };
3800
+ }
3801
+ const selections = request.selections ?? [];
3802
+ if (selections.length === 0)
3803
+ throw new Error('APP_BUILD_SOURCE_REQUIRED: choose at least one source.');
3804
+ if (selections.length > 100)
3805
+ throw new Error('APP_BUILD_SOURCE_LIMIT: at most 100 components can be composed at once.');
3806
+ const { ensureAgentProjectReady, resolveAppSourceCatalogRecords } = await import('@duckcodeailabs/dql-agent');
3807
+ await ensureAgentProjectReady(projectRoot);
3808
+ const effectiveSourcePolicy = request.enableReviewRequired === true
3809
+ ? 'include_review_required'
3810
+ : current.sourcePolicy;
3811
+ const resolved = resolveAppSourceCatalogRecords(projectRoot, selections.map((selection) => selection.sourceId), effectiveSourcePolicy);
3812
+ if (resolved.missingSourceIds.length) {
3813
+ throw new Error(`APP_BUILD_SOURCE_DRIFT: these sources no longer resolve: ${resolved.missingSourceIds.join(', ')}`);
3814
+ }
3815
+ const byId = new Map(resolved.items.map((source) => [source.sourceId, source]));
3816
+ const workingItems = new Map(current.pages.map((page) => [page.id, [...page.layout.items]]));
3817
+ const operations = request.enableReviewRequired === true
3818
+ && current.sourcePolicy !== 'include_review_required'
3819
+ ? [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]
3820
+ : [];
3821
+ const pageIds = new Set();
3822
+ const tileIds = [];
3823
+ const coverageAdditions = new Map();
3824
+ for (const [index, selection] of selections.entries()) {
3825
+ const source = byId.get(selection.sourceId);
3826
+ if (!source)
3827
+ throw new Error(`APP_BUILD_SOURCE_DRIFT: ${selection.sourceId} no longer resolves.`);
3828
+ if (!source.eligibility.localPreview) {
3829
+ throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: ${source.title} is ${source.lifecycle}; enable review-required sources to add it locally.`);
3830
+ }
3831
+ const page = current.pages.find((candidate) => candidate.id === cleanString(selection.pageId)) ?? current.pages[0];
3832
+ if (!page)
3833
+ throw new Error('APP_BUILD_PAGE_REQUIRED: add a page before adding a source.');
3834
+ const pageItems = workingItems.get(page.id) ?? [...page.layout.items];
3835
+ const tileId = uniqueTileId(pageItems, `${source.name}-${selection.view}`);
3836
+ const y = pageItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
3837
+ const certified = source.lifecycle === 'certified';
3838
+ const view = selection.view === 'chart'
3839
+ ? normalizeVizType(source.capabilities.chartType ?? source.capabilities.allowedVisualizations?.[0] ?? 'bar')
3840
+ : selection.view === 'kpi' ? 'kpi' : 'table';
3841
+ const tile = {
3842
+ i: tileId,
3843
+ x: (index % 2) * 6,
3844
+ y,
3845
+ w: selection.view === 'kpi' ? 3 : 6,
3846
+ h: selection.view === 'kpi' ? 2 : 4,
3847
+ sourceId: source.sourceId,
3848
+ sourceRevision: source.sourceRevision,
3849
+ block: { ref: source.executionRef },
3850
+ viz: { type: view },
3851
+ title: source.title,
3852
+ sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
3853
+ filterBindings: source.capabilities.filters.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
3854
+ review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
3855
+ trustState: certified ? 'certified' : 'review_required',
3856
+ reviewStatus: certified ? 'certified' : 'review_required',
3857
+ };
3858
+ pageItems.push(tile);
3859
+ workingItems.set(page.id, pageItems);
3860
+ operations.push({ type: 'upsert_source', source: appDraftSourceFromCatalog(source) });
3861
+ operations.push({ type: 'add_tile', pageId: page.id, tile });
3862
+ if (!certified) {
3863
+ operations.push({
3864
+ type: 'set_review_task',
3865
+ task: {
3866
+ id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
3867
+ message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
3868
+ status: 'open',
3869
+ sourceId: source.sourceId,
3870
+ pageId: page.id,
3871
+ tileId,
3872
+ },
3873
+ });
3874
+ }
3875
+ pageIds.add(page.id);
3876
+ tileIds.push(tileId);
3877
+ for (const requirement of current.requirements) {
3878
+ if (!catalogSourceSatisfiesRequirement(source, requirement))
3879
+ continue;
3880
+ const addition = coverageAdditions.get(requirement.id) ?? { sourceIds: [], componentIds: [] };
3881
+ addition.sourceIds.push(source.sourceId);
3882
+ addition.componentIds.push(tileId);
3883
+ coverageAdditions.set(requirement.id, addition);
3884
+ }
3885
+ }
3886
+ if (current.requirements.length > 0) {
3887
+ const existingByRequirement = new Map(current.coverage.map((item) => [item.requirementId, item]));
3888
+ operations.push({
3889
+ type: 'set_coverage',
3890
+ coverage: current.requirements.map((requirement) => {
3891
+ const existing = existingByRequirement.get(requirement.id);
3892
+ const addition = coverageAdditions.get(requirement.id);
3893
+ const sourceIds = unique([...(existing?.sourceIds ?? []), ...(addition?.sourceIds ?? [])]);
3894
+ const componentIds = unique([...(existing?.componentIds ?? []), ...(addition?.componentIds ?? [])]);
3895
+ const newlyCovered = Boolean(addition?.sourceIds.length && addition.componentIds.length);
3896
+ return {
3897
+ requirementId: requirement.id,
3898
+ status: newlyCovered ? 'covered' : existing?.status ?? 'gap',
3899
+ sourceIds,
3900
+ componentIds,
3901
+ reasons: newlyCovered
3902
+ ? unique([...(existing?.reasons ?? []), 'Covered by a server-resolved source added during manual composition.'])
3903
+ : existing?.reasons ?? ['No selected App source covers this requirement.'],
3904
+ };
3905
+ }),
3906
+ });
3907
+ }
3908
+ const next = applyAppBuildDraftOperations(current, current.revision, operations);
3909
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
3910
+ return { draft: next, pageIds: Array.from(pageIds), tileIds };
3911
+ }
3912
+ function operationsForSelectedAppBuildSources(operations, selected) {
3913
+ const keptComponentIds = new Set(operations.flatMap((operation) => {
3914
+ if (operation.type === 'upsert_page') {
3915
+ return operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId)).map((item) => item.i);
3916
+ }
3917
+ if (operation.type === 'add_tile' && (!operation.tile.sourceId || selected.has(operation.tile.sourceId)))
3918
+ return [operation.tile.i];
3919
+ return [];
3920
+ }));
3921
+ return operations.flatMap((operation) => {
3922
+ if (operation.type === 'upsert_source')
3923
+ return selected.has(operation.source.id) ? [operation] : [];
3924
+ if (operation.type === 'set_review_task') {
3925
+ return !operation.task.sourceId || selected.has(operation.task.sourceId) ? [operation] : [];
3926
+ }
3927
+ if (operation.type === 'upsert_page') {
3928
+ const items = operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId));
3929
+ return [{
3930
+ ...operation,
3931
+ page: {
3932
+ ...operation.page,
3933
+ layout: {
3934
+ ...operation.page.layout,
3935
+ items,
3936
+ responsive: responsiveLayoutsForDashboardItems(items),
3937
+ },
3938
+ },
3939
+ }];
3940
+ }
3941
+ if (operation.type === 'set_requirements') {
3942
+ const coverage = (operation.coverage ?? []).map((item) => {
3943
+ const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
3944
+ const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
3945
+ return {
3946
+ ...item,
3947
+ sourceIds,
3948
+ componentIds,
3949
+ status: sourceIds.length && componentIds.length ? item.status : 'gap',
3950
+ };
3951
+ });
3952
+ return [{ ...operation, coverage }];
3953
+ }
3954
+ if (operation.type === 'set_coverage') {
3955
+ return [{
3956
+ ...operation,
3957
+ coverage: operation.coverage.map((item) => {
3958
+ const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
3959
+ const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
3960
+ return { ...item, sourceIds, componentIds, status: sourceIds.length && componentIds.length ? item.status : 'gap' };
3961
+ }),
3962
+ }];
3963
+ }
3964
+ return [operation];
3965
+ });
3966
+ }
3967
+ /**
3968
+ * Rehydrate the canonical v3 source registry when a Git-owned App is opened in
3969
+ * Studio. Published dashboard documents intentionally keep the immutable
3970
+ * source binding on each tile; the mutable local draft must restore that
3971
+ * registry before core aggregate validation runs. This is metadata-only and
3972
+ * performs no request-time block scan. Current lifecycle/fingerprint checks
3973
+ * remain the responsibility of preview and publication preflight.
3974
+ */
3975
+ function rehydratePublishedAppSources(pages) {
3976
+ const sources = new Map();
3977
+ const reviewTasks = new Map();
3978
+ let requiresReviewPolicy = false;
3979
+ const normalizedPages = pages.map((page) => ({
3980
+ ...page,
3981
+ layout: {
3982
+ ...page.layout,
3983
+ items: page.layout.items.map((tile) => {
3984
+ const hasDataSource = Boolean(tile.block || tile.semantic || tile.draftAnalysis || tile.aiPin);
3985
+ if (!hasDataSource)
3986
+ return tile;
3987
+ const blockRef = tile.block ? ('ref' in tile.block ? tile.block.ref : tile.block.blockId) : undefined;
3988
+ const sourceRef = blockRef
3989
+ ?? tile.draftAnalysis?.ref
3990
+ ?? tile.semantic?.id
3991
+ ?? tile.aiPin?.id
3992
+ ?? `${page.id}/${tile.i}`;
3993
+ const sourceRevision = tile.sourceRevision
3994
+ ?? tile.review?.sourceFingerprint
3995
+ ?? tile.draftAnalysis?.artifactFingerprint
3996
+ ?? tile.semantic?.definitionFingerprint;
3997
+ const sourceId = tile.sourceId
3998
+ ?? `published:${createHash('sha256').update(`${page.id}\0${tile.i}\0${sourceRef}`).digest('hex').slice(0, 20)}`;
3999
+ const certified = Boolean(tile.block
4000
+ && sourceRevision
4001
+ && tile.trustState === 'certified'
4002
+ && tile.review?.status !== 'required');
4003
+ const kind = tile.semantic
4004
+ ? 'governed_semantic'
4005
+ : tile.draftAnalysis
4006
+ ? 'review_dql'
4007
+ : tile.block
4008
+ ? 'block'
4009
+ : 'text';
4010
+ const nextSource = {
4011
+ id: sourceId,
4012
+ kind,
4013
+ sourceRef,
4014
+ qualifiedIdentity: tile.title || sourceRef,
4015
+ ...(tile.block ? { sourcePath: sourceRef, executionRef: sourceRef } : {}),
4016
+ ...(sourceRevision ? { sourceRevision, sourceFingerprint: sourceRevision } : {}),
4017
+ lifecycle: certified ? 'certified' : tile.draftAnalysis ? 'review' : 'unknown',
4018
+ capabilities: {
4019
+ measures: [],
4020
+ dimensions: [],
4021
+ outputs: [],
4022
+ filters: Array.from(new Set((tile.filterBindings ?? [])
4023
+ .filter((binding) => binding.capability !== 'unsupported')
4024
+ .map((binding) => binding.filter))),
4025
+ allowedVisualizations: Array.from(new Set([
4026
+ tile.viz.type,
4027
+ ...(tile.display?.allowedVisualizations ?? []),
4028
+ ])),
4029
+ parameters: [],
4030
+ },
4031
+ trustState: certified ? 'certified' : 'review_required',
4032
+ reviewStatus: certified ? 'not_required' : 'required',
4033
+ };
4034
+ const previous = sources.get(sourceId);
4035
+ if (!previous) {
4036
+ sources.set(sourceId, nextSource);
4037
+ }
4038
+ else if (previous.sourceRef !== nextSource.sourceRef
4039
+ || previous.sourceRevision !== nextSource.sourceRevision
4040
+ || previous.trustState !== nextSource.trustState) {
4041
+ requiresReviewPolicy = true;
4042
+ sources.set(sourceId, {
4043
+ ...previous,
4044
+ lifecycle: 'unknown',
4045
+ trustState: 'review_required',
4046
+ reviewStatus: 'required',
4047
+ });
4048
+ reviewTasks.set(`published-source-${sourceId}`, {
4049
+ id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
4050
+ message: `${tile.title || sourceRef} has conflicting published source bindings. Refresh or replace it before publication.`,
4051
+ status: 'open',
4052
+ sourceId,
4053
+ pageId: page.id,
4054
+ tileId: tile.i,
4055
+ });
4056
+ }
4057
+ if (!certified) {
4058
+ requiresReviewPolicy = true;
4059
+ reviewTasks.set(`published-source-${sourceId}`, {
4060
+ id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
4061
+ message: sourceRevision
4062
+ ? `${tile.title || sourceRef} must be refreshed against the current project source before publication.`
4063
+ : `${tile.title || sourceRef} has no published source revision. Replace or refresh it before publication.`,
4064
+ status: 'open',
4065
+ sourceId,
4066
+ pageId: page.id,
4067
+ tileId: tile.i,
4068
+ });
4069
+ }
4070
+ return tile.sourceId === sourceId ? tile : { ...tile, sourceId, ...(sourceRevision ? { sourceRevision } : {}) };
4071
+ }),
4072
+ },
4073
+ }));
4074
+ return {
4075
+ pages: normalizedPages,
4076
+ sources: Array.from(sources.values()),
4077
+ reviewTasks: Array.from(reviewTasks.values()),
4078
+ requiresReviewPolicy,
4079
+ };
4080
+ }
3353
4081
  export function createStoredAppBuildDraft(projectRoot, input) {
3354
4082
  const baseAppId = cleanString(input.baseAppId);
3355
4083
  const base = baseAppId ? loadAppById(projectRoot, baseAppId) : null;
@@ -3386,7 +4114,8 @@ export function createStoredAppBuildDraft(projectRoot, input) {
3386
4114
  .filter((page) => Boolean(page))
3387
4115
  : [];
3388
4116
  const template = input.template ?? 'blank';
3389
- const pages = basePages.length ? basePages : [applyAppStudioTemplate(blankPage, template, name)];
4117
+ const published = rehydratePublishedAppSources(basePages);
4118
+ const pages = published.pages.length ? published.pages : [applyAppStudioTemplate(blankPage, template, name)];
3390
4119
  const draft = createAppBuildDraft({
3391
4120
  id,
3392
4121
  appId,
@@ -3394,23 +4123,19 @@ export function createStoredAppBuildDraft(projectRoot, input) {
3394
4123
  ...(base ? { baseApp: { appId: base.app.id, fingerprint: fingerprintAppDirectory(base.appDir) } } : {}),
3395
4124
  authoringMode: input.authoringMode === 'manual' ? 'manual' : 'ai',
3396
4125
  template,
3397
- sourcePolicy: input.sourcePolicy,
4126
+ sourcePolicy: published.requiresReviewPolicy ? 'include_review_required' : input.sourcePolicy,
3398
4127
  frame: {
3399
4128
  goal: cleanString(input.goal) || `Create ${name}`,
3400
4129
  audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
3401
4130
  metrics: [], dimensions: [], filters: [], desiredOutput: 'One responsive analytical page',
3402
- clarificationQuestions: input.authoringMode === 'manual' || base || input.entrypoint === 'ask' ? [] : [{
3403
- id: 'metric',
3404
- question: 'Which governed business metric should lead this App?',
3405
- choices: [
3406
- { id: 'certified', label: 'Choose a certified block' },
3407
- { id: 'semantic', label: 'Choose a semantic metric' },
3408
- { id: 'gap', label: 'Keep a visible gap' },
3409
- ],
3410
- required: true,
3411
- }],
4131
+ // Clarifications are returned only when the App-specific planner finds a
4132
+ // material ambiguity. A synthetic unanswered question here used to make
4133
+ // every new AI draft enter a UI dead-end before planning even ran.
4134
+ clarificationQuestions: [],
3412
4135
  },
4136
+ sources: published.sources,
3413
4137
  pages,
4138
+ reviewTasks: published.reviewTasks,
3414
4139
  });
3415
4140
  writeStoredAppBuildDraft(projectRoot, draft);
3416
4141
  return draft;
@@ -3626,12 +4351,16 @@ function loadStoredAppBuildDraft(projectRoot, id) {
3626
4351
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3627
4352
  try {
3628
4353
  const stored = storage.getAppBuildDraft(clean);
3629
- if (stored)
3630
- return stored;
4354
+ if (stored) {
4355
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
4356
+ if (migrated && stored.version !== migrated.version)
4357
+ storage.saveAppBuildDraft(migrated);
4358
+ return migrated;
4359
+ }
3631
4360
  const legacyPath = join(appBuildDraftDir(projectRoot), `${clean}.json`);
3632
4361
  if (!existsSync(legacyPath))
3633
4362
  return null;
3634
- const migrated = migrateLegacyAppBuildDraft(JSON.parse(readFileSync(legacyPath, 'utf-8')));
4363
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, JSON.parse(readFileSync(legacyPath, 'utf-8')));
3635
4364
  if (!migrated)
3636
4365
  return null;
3637
4366
  storage.saveAppBuildDraft(migrated);
@@ -3653,12 +4382,14 @@ export function writeStoredAppBuildDraft(projectRoot, draft, input = {}) {
3653
4382
  storage.close();
3654
4383
  }
3655
4384
  }
3656
- function migrateLegacyAppBuildDraft(value) {
4385
+ function migrateLegacyAppBuildDraft(projectRoot, value) {
3657
4386
  if (!value || typeof value !== 'object' || Array.isArray(value))
3658
4387
  return null;
3659
4388
  const legacy = value;
3660
- if (legacy.version === 2)
4389
+ if (legacy.version === 3)
3661
4390
  return legacy;
4391
+ if (legacy.version === 2)
4392
+ return migrateV2AppBuildDraft(projectRoot, legacy);
3662
4393
  if (legacy.version !== 1 || typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
3663
4394
  return null;
3664
4395
  const frame = (legacy.frame && typeof legacy.frame === 'object' ? legacy.frame : {});
@@ -3704,6 +4435,128 @@ function migrateLegacyAppBuildDraft(value) {
3704
4435
  updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : migrated.updatedAt,
3705
4436
  };
3706
4437
  }
4438
+ function migrateV2AppBuildDraft(projectRoot, legacy) {
4439
+ if (typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
4440
+ return null;
4441
+ const declarations = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
4442
+ const oldSources = Array.isArray(legacy.sources) ? legacy.sources : [];
4443
+ const idMap = new Map();
4444
+ const revisionById = new Map();
4445
+ const pathById = new Map();
4446
+ const migrationTasks = [];
4447
+ const sources = oldSources.flatMap((raw, index) => {
4448
+ const oldId = cleanString(raw.id) || `legacy-source-${index + 1}`;
4449
+ const kind = cleanString(raw.kind);
4450
+ if (!['block', 'certified_block', 'review_block'].includes(kind)) {
4451
+ idMap.set(oldId, oldId);
4452
+ return [{ ...raw, id: oldId }];
4453
+ }
4454
+ const sourceRef = cleanString(raw.sourceRef);
4455
+ const fingerprint = cleanString(raw.sourceFingerprint);
4456
+ const matches = declarations.filter((candidate) => (candidate.path === sourceRef
4457
+ || candidate.id === sourceRef
4458
+ || candidate.name === sourceRef
4459
+ || candidate.path.endsWith(`/${sourceRef}`)) && (!fingerprint || candidate.fingerprint === fingerprint));
4460
+ if (matches.length === 1) {
4461
+ const candidate = matches[0];
4462
+ const id = canonicalAppBlockSourceId(candidate);
4463
+ idMap.set(oldId, id);
4464
+ revisionById.set(id, candidate.fingerprint);
4465
+ pathById.set(id, candidate.path);
4466
+ const certified = candidate.status === 'certified';
4467
+ return [{
4468
+ id,
4469
+ kind: 'block',
4470
+ sourceRef: candidate.path,
4471
+ qualifiedIdentity: `${candidate.domain || 'global'}::block::${candidate.name}`,
4472
+ sourcePath: candidate.path,
4473
+ executionRef: candidate.path,
4474
+ sourceRevision: candidate.fingerprint,
4475
+ sourceFingerprint: candidate.fingerprint,
4476
+ lifecycle: certified ? 'certified' : candidate.status === 'review' ? 'review' : 'draft',
4477
+ capabilities: {
4478
+ measures: [],
4479
+ dimensions: candidate.dimensionIds,
4480
+ outputs: [],
4481
+ filters: candidate.filterIds,
4482
+ chartType: candidate.chartType,
4483
+ allowedVisualizations: candidate.chartType ? [candidate.chartType] : [],
4484
+ parameters: [],
4485
+ },
4486
+ trustState: certified ? 'certified' : 'review_required',
4487
+ reviewStatus: certified ? 'not_required' : 'required',
4488
+ }];
4489
+ }
4490
+ idMap.set(oldId, oldId);
4491
+ migrationTasks.push({
4492
+ id: `legacy-source-${createHash('sha256').update(oldId).digest('hex').slice(0, 12)}`,
4493
+ message: matches.length > 1
4494
+ ? `Legacy source ${sourceRef || oldId} is ambiguous. Select the exact path and revision before previewing or publishing.`
4495
+ : `Legacy source ${sourceRef || oldId} no longer resolves. Replace or remove it before previewing or publishing.`,
4496
+ status: 'open',
4497
+ sourceId: oldId,
4498
+ });
4499
+ return [{
4500
+ id: oldId,
4501
+ kind: 'block',
4502
+ sourceRef: sourceRef || oldId,
4503
+ qualifiedIdentity: cleanString(raw.qualifiedIdentity) || sourceRef || oldId,
4504
+ sourceRevision: fingerprint || undefined,
4505
+ sourceFingerprint: fingerprint || undefined,
4506
+ lifecycle: 'unknown',
4507
+ capabilities: { measures: [], dimensions: [], outputs: [], filters: [], allowedVisualizations: [], parameters: [] },
4508
+ trustState: 'review_required',
4509
+ reviewStatus: 'required',
4510
+ }];
4511
+ });
4512
+ const pages = (Array.isArray(legacy.pages) ? legacy.pages : []).map((page) => ({
4513
+ ...page,
4514
+ layout: {
4515
+ ...page.layout,
4516
+ items: page.layout.items.map((item) => {
4517
+ if (item.sourceId)
4518
+ return item;
4519
+ const blockRef = item.block
4520
+ ? ('ref' in item.block ? item.block.ref : item.block.blockId)
4521
+ : undefined;
4522
+ if (!blockRef)
4523
+ return item;
4524
+ const oldSource = oldSources.find((source) => {
4525
+ const ref = cleanString(source.sourceRef);
4526
+ return ref === blockRef || cleanString(source.id) === `block:${blockRef}` || cleanString(source.id) === `review-block:${blockRef}`;
4527
+ });
4528
+ const sourceId = oldSource ? idMap.get(cleanString(oldSource.id)) : undefined;
4529
+ if (!sourceId)
4530
+ return item;
4531
+ return {
4532
+ ...item,
4533
+ sourceId,
4534
+ sourceRevision: revisionById.get(sourceId) ?? (cleanString(oldSource?.sourceFingerprint) || undefined),
4535
+ ...(pathById.get(sourceId) ? { block: { ref: pathById.get(sourceId) } } : {}),
4536
+ };
4537
+ }),
4538
+ },
4539
+ }));
4540
+ const coverage = (Array.isArray(legacy.coverage) ? legacy.coverage : []).map((item) => ({
4541
+ ...item,
4542
+ sourceIds: item.sourceIds.map((id) => idMap.get(id) ?? id),
4543
+ }));
4544
+ const migratedWithoutHash = {
4545
+ ...legacy,
4546
+ version: 3,
4547
+ sources,
4548
+ pages,
4549
+ coverage,
4550
+ reviewTasks: [
4551
+ ...(Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : []),
4552
+ ...migrationTasks,
4553
+ ],
4554
+ };
4555
+ return {
4556
+ ...migratedWithoutHash,
4557
+ proposalHash: appBuildDraftHash({ ...migratedWithoutHash, proposalHash: undefined }),
4558
+ };
4559
+ }
3707
4560
  export function deleteStoredAppBuildDraft(projectRoot, draft) {
3708
4561
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3709
4562
  let deleted;
@@ -3828,22 +4681,33 @@ export function preflightStoredAppBuildDraft(projectRoot, draft) {
3828
4681
  errors.push(`Run a current settled preview for page ${page.metadata.title || page.id} before Project publication.`);
3829
4682
  }
3830
4683
  for (const source of draft.sources) {
3831
- if (source.kind === 'certified_block') {
4684
+ if (source.kind === 'block' || source.kind === 'certified_block') {
3832
4685
  const block = blocks.find((candidate) => candidate.id === source.sourceRef || candidate.name === source.sourceRef || candidate.path === source.sourceRef);
3833
- if (!block || block.status !== 'certified')
3834
- errors.push(`Source ${source.id} does not resolve to a current certified block.`);
3835
- if (block && source.sourceFingerprint && source.sourceFingerprint !== block.fingerprint)
3836
- errors.push(`Source ${source.id} changed after selection.`);
4686
+ const lifecycle = source.lifecycle ?? (source.kind === 'certified_block' ? 'certified' : block?.status);
4687
+ const boundRevision = source.sourceRevision ?? source.sourceFingerprint;
4688
+ if (!block)
4689
+ errors.push(`Source ${source.id} no longer resolves at ${source.sourceRef}.`);
4690
+ if (block && canonicalAppBlockSourceId(block) !== source.id && source.kind === 'block')
4691
+ errors.push(`Source ${source.id} no longer matches its qualified path identity.`);
4692
+ if (!block || block.status !== 'certified' || lifecycle !== 'certified' || source.trustState !== 'certified') {
4693
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} is ${lifecycle ?? 'unknown'} and cannot be published to the Project.`);
4694
+ }
4695
+ if (block && !boundRevision) {
4696
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} has no bound source revision; refresh it before Project publication.`);
4697
+ }
4698
+ else if (block && boundRevision !== block.fingerprint) {
4699
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} changed after selection.`);
4700
+ }
3837
4701
  }
3838
4702
  if (source.kind === 'governed_semantic' || source.kind === 'semantic_query') {
3839
4703
  if (source.reviewStatus !== 'approved' || !source.snapshotId || !source.receiptId) {
3840
4704
  errors.push(`Source ${source.id} requires snapshot-bound semantic approval before Project publication.`);
3841
4705
  }
3842
4706
  }
3843
- if (source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
4707
+ if ((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
3844
4708
  errors.push(`Source ${source.id} is review-required and cannot be published to the Project.`);
3845
4709
  }
3846
- if ((source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') && draft.sourcePolicy !== 'include_review_required') {
4710
+ if (((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') && draft.sourcePolicy !== 'include_review_required') {
3847
4711
  errors.push(`Source ${source.id} requires include_review_required policy.`);
3848
4712
  }
3849
4713
  }
@@ -3964,7 +4828,7 @@ export function publishStoredAppBuildDraft(projectRoot, draft) {
3964
4828
  const domain = draft.pages[0]?.metadata.domain || 'general';
3965
4829
  const name = draft.name.trim() || draft.appId;
3966
4830
  const containsSemantic = draft.sources.some((source) => source.kind === 'governed_semantic' || source.kind === 'semantic_query');
3967
- const hasCertifiedAnalysis = draft.sources.some((source) => source.kind === 'certified_block');
4831
+ const hasCertifiedAnalysis = draft.sources.some((source) => (source.kind === 'block' || source.kind === 'certified_block') && source.trustState === 'certified');
3968
4832
  const lifecycle = hasCertifiedAnalysis && !containsSemantic ? 'certified' : 'review';
3969
4833
  const app = {
3970
4834
  version: 1,
@@ -5139,7 +6003,7 @@ function appReadme(app, audience, blocks, dashboardId = 'overview') {
5139
6003
  '',
5140
6004
  ].join('\n');
5141
6005
  }
5142
- function collectBlockCandidates(projectRoot) {
6006
+ function collectBlockCandidates(projectRoot, options = {}) {
5143
6007
  const blocks = [];
5144
6008
  const seen = new Set();
5145
6009
  const scanDir = (dir) => {
@@ -5189,6 +6053,8 @@ function collectBlockCandidates(projectRoot) {
5189
6053
  };
5190
6054
  scanDir(join(projectRoot, 'blocks'));
5191
6055
  scanDir(join(projectRoot, 'domains'));
6056
+ if (options.preserveDuplicates)
6057
+ return blocks.sort((left, right) => left.path.localeCompare(right.path));
5192
6058
  const byId = new Map();
5193
6059
  for (const block of blocks) {
5194
6060
  const current = byId.get(block.id);
@@ -5203,6 +6069,10 @@ function collectBlockCandidates(projectRoot) {
5203
6069
  }
5204
6070
  return Array.from(byId.values());
5205
6071
  }
6072
+ function canonicalAppBlockSourceId(block) {
6073
+ const pathIdentity = createHash('sha256').update(`${block.path}\u0000${block.name}`).digest('hex').slice(0, 20);
6074
+ return `app:block:${block.domain || 'global'}:${pathIdentity}`;
6075
+ }
5206
6076
  function declaredAppFilterIds(source) {
5207
6077
  const filterSection = sectionBody(source, 'filterBindings');
5208
6078
  const parameterSection = sectionBody(source, 'parameterPolicy');