@duckcodeailabs/dql-cli 1.13.2 → 1.13.3

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 (67) hide show
  1. package/dist/apps-api.d.ts +25 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1037 -175
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-BlUE3qsX.js → AgentLogPage-B3ugEb3H.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-O88iK6UW.js → AiBuildDialog-BK_TDNcM.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-D-6G4Sh3.js → AiBuildResult-Cob_Lj05.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-MRSGtQDV.js → AiSidePanel-CzIgxmP9.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/{AnalyticsHome-CcRVO6zN.js → AnalyticsHome-C2vJ2fxY.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/AppsView-DEWQ0sHT.js +5588 -0
  11. package/dist/assets/dql-notebook/assets/BlockStudio-CaDNTKMM.js +55 -0
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-itEIUrYy.js → BusinessArtifactView-zwUOtddi.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-CbAqOpNc.js → DbtFirstModelingPage-BlnhTMu3.js} +4 -4
  14. package/dist/assets/dql-notebook/assets/{GitPage-BxOqRYu2.js → GitPage-Coevr9NJ.js} +3 -3
  15. package/dist/assets/dql-notebook/assets/{GlobalAiRail-Tfkx76CI.js → GlobalAiRail-BTZS11x5.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{GovernedContextPage-CeaDbepP.js → GovernedContextPage-CjyyM_ne.js} +3 -3
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D1lAryYv.js → HelpDocsPage-C1UeP85v.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-GB6se6xW.js → HomePage-zGPFIvOY.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-zCyN-hdg.js → LineageDAG-DbXnQWu3.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-Cg0TgG2Y.js → LineageDetailView-Ch1Db01I.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-DwMcenDf.js → LineageDrawer-DLxqJO6D.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-DUZx3ksU.js → LineagePathBreadcrumb-B2tNdkr_.js} +3 -3
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-DT_ycl6s.js → MiniLineageGraph-0gY-ZwCy.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-qdhzeJPF.js → NewBlockModal-DKVkXD20.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BnaDzs1A.js → NewNotebookModal-BUIByvYT.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-CouPFzdo.js → NotebookEditor-D2fIUxjE.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-DB_KbH9Y.js → ReadinessPage-Ci4LyNiQ.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-CamQq_N2.js → SetupOnboarding-BF7ai_0t.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{SkillsPage--mYkOAK2.js → SkillsPage-BgYrgirJ.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-ClEYiYeA.js → TrustBadge-Bob1Laiq.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{UnifiedAgentRunPanel-DBfsmXes.js → UnifiedAgentRunPanel-IxDUKSG8.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-Cg3UyGwh.js → answer-to-notebook-Dk42N0pU.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-DnYPGqlr.js → arrow-left-DKuuTw9l.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-Ct_YGrmU.js → arrow-right-TH8_ufKG.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-DULlwbj6.js → book-open-text-B-dcHHn0.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-ByYxoiS9.js → circle-x-BOc2KCZQ.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-BjYSO53F.js → dagre.esm-CPT_vn5p.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-DprWh9Yf.js → external-link-DtOfpYc6.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-CrEEYkXN.js → grip-vertical-B8joDDH9.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/index-DJRMz64S.js +635 -0
  41. package/dist/assets/dql-notebook/assets/{link-2-BkW-rkEC.js → link-2-DMZPHawg.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{list-tree-BipBLmIE.js → list-tree-Dw0qlxPK.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{minimize-2-BhXsglrO.js → minimize-2-DUiSrPS3.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{panel-right-open-BoCSilYB.js → panel-right-open-BrrFrRZQ.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{play-mdoZawPu.js → play-Ced0HT9g.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{rotate-ccw-ELiDRUmj.js → rotate-ccw-D_9U9o7X.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{semantic-fields-DVNRLfeU.js → semantic-fields-BlDOaonV.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{sliders-horizontal-GeUzpmKz.js → sliders-horizontal-BDt6KRGR.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{star-D63O81Wp.js → star-CxUbF_BW.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{triangle-alert-BWrCxQVE.js → triangle-alert-COWnsSdh.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{upload-CI-ldXhC.js → upload-DSFC2kJX.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-Cv7Yq2hK.js → usePersistedAgentThreadId-DYdx896E.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{user-round-Do21aFGo.js → user-round-Bfu7fosn.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{wand-sparkles-DT7u7F_x.js → wand-sparkles-DSpG0c0j.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{workflow-DHh2mMbn.js → workflow-B1_pj9gq.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{wrench-7tYr_GKb.js → wrench-CvfM03KZ.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{x-CcYceFMz.js → x-B4coLiOl.js} +1 -1
  58. package/dist/assets/dql-notebook/index.html +1 -1
  59. package/dist/local-runtime.d.ts +47 -19
  60. package/dist/local-runtime.d.ts.map +1 -1
  61. package/dist/local-runtime.js +1015 -566
  62. package/dist/local-runtime.js.map +1 -1
  63. package/dist/package.json +10 -10
  64. package/package.json +10 -10
  65. package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +0 -5588
  66. package/dist/assets/dql-notebook/assets/BlockStudio-BrTNaDpb.js +0 -55
  67. 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,153 @@ 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
+ let candidates = shortlistAppSources(projectRoot, prompt, {
2227
+ sourcePolicy: draft.sourcePolicy,
2228
+ domains: draft.pages[0]?.metadata.domain && draft.pages[0].metadata.domain !== 'general'
2229
+ ? [draft.pages[0].metadata.domain]
2230
+ : undefined,
2231
+ }).items;
2232
+ const requestedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
2233
+ if (requestedIds.length) {
2234
+ const exactIds = requestedIds.filter((id) => id.startsWith('app:block:'));
2235
+ const exact = exactIds.length
2236
+ ? resolveAppSourceCatalogRecords(projectRoot, exactIds, draft.sourcePolicy).items
2237
+ : [];
2238
+ const requested = new Set(requestedIds);
2239
+ candidates = uniqueAppSourceRecords([
2240
+ ...exact,
2241
+ ...candidates.filter((candidate) => requested.has(candidate.sourceId)
2242
+ || requested.has(candidate.name)
2243
+ || requested.has(candidate.sourcePath)
2244
+ || requested.has(candidate.qualifiedIdentity)),
2245
+ ...candidates,
2246
+ ]).slice(0, 12);
2247
+ }
2248
+ const brief = await planAppBuildBrief({
2249
+ prompt,
2250
+ candidates,
2251
+ sourcePolicy: draft.sourcePolicy,
1962
2252
  domain: draft.pages[0]?.metadata.domain,
1963
2253
  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 [];
2254
+ complete: hooks?.planAppBuild,
2255
+ });
2256
+ const byId = new Map(candidates.map((candidate) => [candidate.sourceId, candidate]));
2257
+ const plannedSources = brief.selectedSourceIds.flatMap((sourceId) => {
2258
+ const source = byId.get(sourceId);
2259
+ return source ? [source] : [];
2017
2260
  });
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)
2261
+ const sourceOperations = plannedSources.map((source) => ({
2262
+ type: 'upsert_source',
2263
+ source: appDraftSourceFromCatalog(source),
2264
+ }));
2265
+ const page = draft.pages[0];
2266
+ if (!page)
2267
+ throw new Error('The App draft needs a page before AI can compose it.');
2268
+ const structuralItems = page.layout.items.filter((item) => Boolean(item.text));
2269
+ const baseY = structuralItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
2270
+ const reservedItems = [...structuralItems];
2271
+ const componentTileIdById = new Map();
2272
+ const plannedItems = brief.components.flatMap((component, index) => {
2273
+ const source = byId.get(component.sourceId);
2274
+ if (!source)
2023
2275
  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 [];
2276
+ const certified = source.lifecycle === 'certified';
2277
+ const tileId = uniqueTileId(reservedItems, component.id || `component-${index + 1}`);
2278
+ componentTileIdById.set(component.id, tileId);
2279
+ const tile = {
2280
+ i: tileId,
2281
+ x: (index % 2) * 6,
2282
+ y: baseY + Math.floor(index / 2) * 4,
2283
+ w: component.view === 'kpi' ? 3 : 6,
2284
+ h: component.view === 'kpi' ? 2 : 4,
2285
+ sourceId: source.sourceId,
2286
+ sourceRevision: source.sourceRevision,
2287
+ block: { ref: source.executionRef },
2288
+ viz: { type: component.view },
2289
+ title: component.title,
2290
+ sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
2291
+ filterBindings: source.capabilities.filters.map((filter) => ({
2292
+ filter,
2293
+ binding: filter,
2294
+ mode: 'predicate',
2295
+ capability: 'preflight_required',
2296
+ })),
2297
+ review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
2298
+ trustState: certified ? 'certified' : 'review_required',
2299
+ reviewStatus: certified ? 'certified' : 'review_required',
2300
+ };
2301
+ reservedItems.push(tile);
2302
+ return [tile];
2303
+ });
2304
+ const nextPage = {
2305
+ ...page,
2306
+ metadata: { ...page.metadata, description: brief.frame.decision || brief.frame.goal },
2307
+ layout: {
2308
+ ...page.layout,
2309
+ items: [...structuralItems, ...plannedItems],
2310
+ responsive: responsiveLayoutsForDashboardItems([...structuralItems, ...plannedItems]),
2311
+ },
2029
2312
  };
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
- }
2313
+ const coverage = brief.requirements.map((requirement) => {
2314
+ const components = brief.components.filter((component) => component.requirementIds.includes(requirement.id));
2051
2315
  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,
2316
+ requirementId: requirement.id,
2317
+ status: components.length ? 'covered' : 'gap',
2318
+ sourceIds: Array.from(new Set(components.map((component) => component.sourceId))),
2319
+ componentIds: components.map((component) => componentTileIdById.get(component.id) ?? component.id),
2320
+ reasons: components.length
2321
+ ? ['Covered by a source-bound App component from the validated Build Brief.']
2322
+ : ['No supplied source candidate covered this requirement.'],
2057
2323
  };
2058
2324
  });
2325
+ const clarifications = brief.frame.clarificationQuestions ?? [];
2326
+ const reviewTasks = plannedSources
2327
+ .filter((source) => source.lifecycle !== 'certified')
2328
+ .map((source) => ({
2329
+ type: 'set_review_task',
2330
+ task: {
2331
+ id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
2332
+ message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
2333
+ status: 'open',
2334
+ sourceId: source.sourceId,
2335
+ pageId: page.id,
2336
+ },
2337
+ }));
2059
2338
  const operations = [
2060
- { type: 'set_name', name: plan.name },
2061
2339
  {
2062
2340
  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
- },
2341
+ frame: brief.frame,
2076
2342
  },
2077
2343
  {
2078
2344
  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
- })),
2345
+ requirements: brief.requirements,
2089
2346
  coverage,
2090
2347
  },
2091
- ...(replaceInitialBlank ? [{ type: 'remove_page', pageId: draft.pages[0].id }] : []),
2092
- ...dashboards.map((page) => ({ type: 'upsert_page', page })),
2348
+ { type: 'upsert_page', page: nextPage },
2093
2349
  ...sourceOperations,
2094
- ...reviewAnalyses.reviewTasks,
2350
+ ...reviewTasks,
2095
2351
  ];
2096
2352
  return {
2097
- id: session.id,
2353
+ id: `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2098
2354
  draftId: draft.id,
2099
2355
  baseRevision: draft.revision,
2100
2356
  baseProposalHash: draft.proposalHash,
2101
2357
  operations,
2102
- defaultSelectedSourceIds: sourceOperations.flatMap((operation) => (operation.type === 'upsert_source' && operation.source.kind !== 'review_dql' ? [operation.source.id] : [])),
2358
+ defaultSelectedSourceIds: plannedSources
2359
+ .filter((source) => source.eligibility.localPreview)
2360
+ .map((source) => source.sourceId),
2103
2361
  clarifications,
2362
+ candidateSourceIds: brief.candidateSourceIds,
2363
+ warnings: brief.warnings,
2104
2364
  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,
2365
+ requirements: brief.requirements.length,
2366
+ covered: coverage.filter((item) => item.status === 'covered').length,
2367
+ gaps: coverage.filter((item) => item.status === 'gap').length,
2368
+ certifiedSources: plannedSources.filter((source) => source.lifecycle === 'certified').length,
2369
+ semanticSources: 0,
2110
2370
  },
2111
2371
  };
2112
2372
  }
@@ -3264,12 +3524,13 @@ export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input)
3264
3524
  function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3265
3525
  const blockers = [];
3266
3526
  let governedTiles = 0;
3267
- const blocks = collectBlockCandidates(projectRoot);
3527
+ const blocks = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
3268
3528
  for (const dashboard of dashboards) {
3269
3529
  for (const item of dashboard.layout.items) {
3270
3530
  if (item.block) {
3271
3531
  governedTiles += 1;
3272
3532
  const blockRef = item.block;
3533
+ const boundRevision = item.sourceRevision ?? item.review?.sourceFingerprint;
3273
3534
  const block = blocks.find((candidate) => ('blockId' in blockRef
3274
3535
  ? candidate.id === blockRef.blockId || candidate.name === blockRef.blockId
3275
3536
  : candidate.path === blockRef.ref));
@@ -3281,7 +3542,15 @@ function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3281
3542
  message: `${dashboard.id}/${item.i} no longer resolves to a certified block.`,
3282
3543
  });
3283
3544
  }
3284
- else if (item.review?.sourceFingerprint?.startsWith('sha256:') && item.review.sourceFingerprint !== block.fingerprint) {
3545
+ else if (!boundRevision) {
3546
+ blockers.push({
3547
+ dashboardId: dashboard.id,
3548
+ tileId: item.i,
3549
+ code: 'block_source_unversioned',
3550
+ message: `${dashboard.id}/${item.i} has no bound source revision; refresh the certified block before publication.`,
3551
+ });
3552
+ }
3553
+ else if (boundRevision !== block.fingerprint) {
3285
3554
  blockers.push({
3286
3555
  dashboardId: dashboard.id,
3287
3556
  tileId: item.i,
@@ -3350,6 +3619,457 @@ function appBuildDraftDir(projectRoot) {
3350
3619
  // are persisted transactionally in .dql/local/apps.sqlite.
3351
3620
  return join(projectRoot, '.dql', 'local', 'app-builds');
3352
3621
  }
3622
+ function appBuildProposalDir(projectRoot, draftId) {
3623
+ return join(appBuildDraftDir(projectRoot), draftId, 'proposals');
3624
+ }
3625
+ function writeAppBuildDraftAiProposal(projectRoot, proposal) {
3626
+ if (!/^[a-z0-9_:-]+$/i.test(proposal.draftId) || !/^[a-z0-9_:-]+$/i.test(proposal.id)) {
3627
+ throw new Error('APP_BUILD_PROPOSAL_INVALID: proposal identity is invalid.');
3628
+ }
3629
+ const dir = appBuildProposalDir(projectRoot, proposal.draftId);
3630
+ mkdirSync(dir, { recursive: true });
3631
+ const path = join(dir, `${proposal.id}.json`);
3632
+ const candidate = `${path}.candidate`;
3633
+ writeFileSync(candidate, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
3634
+ renameSync(candidate, path);
3635
+ }
3636
+ function loadAppBuildDraftAiProposal(projectRoot, draftId, proposalId) {
3637
+ if (!/^[a-z0-9_:-]+$/i.test(draftId) || !/^[a-z0-9_:-]+$/i.test(proposalId))
3638
+ return null;
3639
+ const path = join(appBuildProposalDir(projectRoot, draftId), `${proposalId}.json`);
3640
+ if (!existsSync(path))
3641
+ return null;
3642
+ try {
3643
+ const proposal = JSON.parse(readFileSync(path, 'utf8'));
3644
+ return proposal.draftId === draftId && proposal.id === proposalId ? proposal : null;
3645
+ }
3646
+ catch {
3647
+ return null;
3648
+ }
3649
+ }
3650
+ async function generateExplicitAppBuildGap(projectRoot, draft, previous, requirementId, generate) {
3651
+ const requirementOperation = previous.operations.find((operation) => operation.type === 'set_requirements');
3652
+ const requirement = requirementOperation?.type === 'set_requirements'
3653
+ ? requirementOperation.requirements.find((candidate) => candidate.id === requirementId)
3654
+ : undefined;
3655
+ const coverage = requirementOperation?.type === 'set_requirements'
3656
+ ? requirementOperation.coverage?.find((candidate) => candidate.requirementId === requirementId)
3657
+ : undefined;
3658
+ if (!requirement || coverage?.status === 'covered') {
3659
+ throw new Error('APP_BUILD_GAP_NOT_FOUND: choose an uncovered App requirement.');
3660
+ }
3661
+ const answer = await generate(requirement.question);
3662
+ const sql = cleanString(answer.sql);
3663
+ if (!sql)
3664
+ throw new Error('APP_BUILD_GAP_NO_QUERY: the provider did not return executable review DQL.');
3665
+ if (sql.includes('"""'))
3666
+ throw new Error('APP_BUILD_GAP_INVALID_QUERY: generated SQL contains an unsupported triple-quote delimiter.');
3667
+ const proposalId = `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3668
+ const key = slugify(requirement.id) || createHash('sha256').update(requirement.question).digest('hex').slice(0, 12);
3669
+ const sourceRef = `proposals/${proposalId}/drafts/${key}.dql`;
3670
+ const source = [
3671
+ `block "app_${slugify(draft.appId)}_${key}" {`,
3672
+ ` domain = "${escapeDqlString(draft.pages[0]?.metadata.domain ?? 'general')}"`,
3673
+ ' type = "custom"',
3674
+ ' status = "review"',
3675
+ ' owner = "local-app-builder"',
3676
+ ` description = "${escapeDqlString(answer.text?.slice(0, 240) || requirement.question)}"`,
3677
+ ` llmContext = "${escapeDqlString(`Explicitly generated App gap: ${requirement.question}`)}"`,
3678
+ ' caveats = ["AI-generated App gap. Validate joins, grain, filters, and interpretation before promotion."]',
3679
+ ' tags = ["app-scoped", "ai-generated", "needs-review"]',
3680
+ '',
3681
+ ' query = """',
3682
+ sql,
3683
+ ' """',
3684
+ '',
3685
+ ' visualization {',
3686
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(answer.suggestedViz))}"`,
3687
+ ' }',
3688
+ '}',
3689
+ '',
3690
+ ].join('\n');
3691
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
3692
+ const absolute = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
3693
+ mkdirSync(dirname(absolute), { recursive: true });
3694
+ writeFileSync(absolute, source, 'utf8');
3695
+ const sourceId = `review-dql:gap:${createHash('sha256').update(`${proposalId}:${requirement.id}`).digest('hex').slice(0, 16)}`;
3696
+ const pageOperation = previous.operations.find((operation) => operation.type === 'upsert_page');
3697
+ if (!pageOperation || pageOperation.type !== 'upsert_page')
3698
+ throw new Error('APP_BUILD_PAGE_REQUIRED: the proposal has no App page.');
3699
+ const tileId = uniqueTileId(pageOperation.page.layout.items, `gap-${requirement.id}`);
3700
+ const y = pageOperation.page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
3701
+ const tile = {
3702
+ i: tileId,
3703
+ x: 0,
3704
+ y,
3705
+ w: 6,
3706
+ h: 4,
3707
+ sourceId,
3708
+ sourceRevision: fingerprint,
3709
+ draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
3710
+ viz: { type: normalizeGeneratedViz(answer.suggestedViz) },
3711
+ title: titleForGapQuestion(requirement.question),
3712
+ sourceClass: 'exploratory_analysis',
3713
+ review: { status: 'required', sourceFingerprint: fingerprint },
3714
+ trustState: 'review_required',
3715
+ reviewStatus: 'review_required',
3716
+ };
3717
+ const operations = previous.operations.map((operation) => {
3718
+ if (operation === pageOperation && operation.type === 'upsert_page') {
3719
+ const items = [...operation.page.layout.items, tile];
3720
+ return { type: 'upsert_page', page: { ...operation.page, layout: { ...operation.page.layout, items, responsive: responsiveLayoutsForDashboardItems(items) } } };
3721
+ }
3722
+ if (operation.type === 'set_requirements') {
3723
+ return {
3724
+ ...operation,
3725
+ coverage: (operation.coverage ?? []).map((item) => item.requirementId === requirement.id
3726
+ ? { ...item, status: 'covered', sourceIds: [sourceId], componentIds: [tileId], reasons: ['Covered by explicitly generated review-required App DQL.'] }
3727
+ : item),
3728
+ };
3729
+ }
3730
+ return operation;
3731
+ });
3732
+ operations.push({
3733
+ type: 'upsert_source',
3734
+ source: {
3735
+ id: sourceId,
3736
+ kind: 'review_dql',
3737
+ sourceRef,
3738
+ qualifiedIdentity: tile.title,
3739
+ sourceRevision: fingerprint,
3740
+ sourceFingerprint: fingerprint,
3741
+ lifecycle: 'review',
3742
+ capabilities: { measures: requirement.measures, dimensions: requirement.dimensions, outputs: [], filters: requirement.filters, allowedVisualizations: [tile.viz.type], parameters: [] },
3743
+ trustState: 'review_required',
3744
+ reviewStatus: 'required',
3745
+ },
3746
+ });
3747
+ operations.push({
3748
+ type: 'set_review_task',
3749
+ task: {
3750
+ id: `review-gap-${key}`,
3751
+ message: `Validate the joins, grain, filters, and business interpretation for ${tile.title}.`,
3752
+ status: 'open',
3753
+ sourceId,
3754
+ pageId: pageOperation.page.id,
3755
+ tileId,
3756
+ },
3757
+ });
3758
+ return {
3759
+ ...previous,
3760
+ id: proposalId,
3761
+ operations,
3762
+ warnings: Array.from(new Set([...(previous.warnings ?? []), 'Generated gap remains review-required and local-only.'])),
3763
+ summary: {
3764
+ ...previous.summary,
3765
+ covered: previous.summary.covered + 1,
3766
+ gaps: Math.max(0, previous.summary.gaps - 1),
3767
+ },
3768
+ };
3769
+ }
3770
+ async function composeStoredAppBuildDraft(projectRoot, current, request) {
3771
+ if (request.mode === 'ai') {
3772
+ const proposal = loadAppBuildDraftAiProposal(projectRoot, current.id, cleanString(request.proposalId));
3773
+ if (!proposal)
3774
+ throw new Error('APP_BUILD_PROPOSAL_NOT_FOUND: ask AI to rebuild the proposal.');
3775
+ if (proposal.baseRevision !== current.revision || proposal.baseProposalHash !== current.proposalHash) {
3776
+ throw new Error('APP_BUILD_PROPOSAL_CONFLICT: the proposal is based on an older draft revision.');
3777
+ }
3778
+ const selected = new Set(unique((request.selectedSourceIds ?? []).map(cleanString).filter(Boolean)));
3779
+ const available = new Map(proposal.operations.flatMap((operation) => (operation.type === 'upsert_source' ? [[operation.source.id, operation.source]] : [])));
3780
+ const missing = Array.from(selected).filter((sourceId) => !available.has(sourceId));
3781
+ if (missing.length)
3782
+ throw new Error(`APP_BUILD_SOURCE_NOT_IN_PROPOSAL: ${missing.join(', ')}`);
3783
+ for (const sourceId of selected) {
3784
+ const source = available.get(sourceId);
3785
+ if (current.sourcePolicy === 'governed_only' && source.trustState !== 'certified') {
3786
+ throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: enable review-required sources before adding ${source.qualifiedIdentity ?? source.sourceRef}.`);
3787
+ }
3788
+ }
3789
+ const operations = operationsForSelectedAppBuildSources(proposal.operations, selected);
3790
+ const next = applyAppBuildDraftOperations(current, current.revision, operations);
3791
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
3792
+ const pages = operations.flatMap((operation) => operation.type === 'upsert_page' ? [operation.page] : []);
3793
+ return {
3794
+ draft: next,
3795
+ pageIds: pages.map((page) => page.id),
3796
+ tileIds: pages.flatMap((page) => page.layout.items.filter((item) => item.sourceId && selected.has(item.sourceId)).map((item) => item.i)),
3797
+ };
3798
+ }
3799
+ const selections = request.selections ?? [];
3800
+ if (selections.length === 0)
3801
+ throw new Error('APP_BUILD_SOURCE_REQUIRED: choose at least one source.');
3802
+ if (selections.length > 100)
3803
+ throw new Error('APP_BUILD_SOURCE_LIMIT: at most 100 components can be composed at once.');
3804
+ const { ensureAgentProjectReady, resolveAppSourceCatalogRecords } = await import('@duckcodeailabs/dql-agent');
3805
+ await ensureAgentProjectReady(projectRoot);
3806
+ const resolved = resolveAppSourceCatalogRecords(projectRoot, selections.map((selection) => selection.sourceId), current.sourcePolicy);
3807
+ if (resolved.missingSourceIds.length) {
3808
+ throw new Error(`APP_BUILD_SOURCE_DRIFT: these sources no longer resolve: ${resolved.missingSourceIds.join(', ')}`);
3809
+ }
3810
+ const byId = new Map(resolved.items.map((source) => [source.sourceId, source]));
3811
+ const workingItems = new Map(current.pages.map((page) => [page.id, [...page.layout.items]]));
3812
+ const operations = [];
3813
+ const pageIds = new Set();
3814
+ const tileIds = [];
3815
+ const coverageAdditions = new Map();
3816
+ for (const [index, selection] of selections.entries()) {
3817
+ const source = byId.get(selection.sourceId);
3818
+ if (!source)
3819
+ throw new Error(`APP_BUILD_SOURCE_DRIFT: ${selection.sourceId} no longer resolves.`);
3820
+ if (!source.eligibility.localPreview) {
3821
+ throw new Error(`APP_BUILD_REVIEW_POLICY_REQUIRED: ${source.title} is ${source.lifecycle}; enable review-required sources to add it locally.`);
3822
+ }
3823
+ const page = current.pages.find((candidate) => candidate.id === cleanString(selection.pageId)) ?? current.pages[0];
3824
+ if (!page)
3825
+ throw new Error('APP_BUILD_PAGE_REQUIRED: add a page before adding a source.');
3826
+ const pageItems = workingItems.get(page.id) ?? [...page.layout.items];
3827
+ const tileId = uniqueTileId(pageItems, `${source.name}-${selection.view}`);
3828
+ const y = pageItems.reduce((max, item) => Math.max(max, item.y + item.h), 0);
3829
+ const certified = source.lifecycle === 'certified';
3830
+ const view = selection.view === 'chart'
3831
+ ? normalizeVizType(source.capabilities.chartType ?? source.capabilities.allowedVisualizations?.[0] ?? 'bar')
3832
+ : selection.view === 'kpi' ? 'kpi' : 'table';
3833
+ const tile = {
3834
+ i: tileId,
3835
+ x: (index % 2) * 6,
3836
+ y,
3837
+ w: selection.view === 'kpi' ? 3 : 6,
3838
+ h: selection.view === 'kpi' ? 2 : 4,
3839
+ sourceId: source.sourceId,
3840
+ sourceRevision: source.sourceRevision,
3841
+ block: { ref: source.executionRef },
3842
+ viz: { type: view },
3843
+ title: source.title,
3844
+ sourceClass: certified ? 'certified_block' : 'exploratory_analysis',
3845
+ filterBindings: source.capabilities.filters.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
3846
+ review: { status: certified ? 'not_required' : 'required', sourceFingerprint: source.sourceRevision },
3847
+ trustState: certified ? 'certified' : 'review_required',
3848
+ reviewStatus: certified ? 'certified' : 'review_required',
3849
+ };
3850
+ pageItems.push(tile);
3851
+ workingItems.set(page.id, pageItems);
3852
+ operations.push({ type: 'upsert_source', source: appDraftSourceFromCatalog(source) });
3853
+ operations.push({ type: 'add_tile', pageId: page.id, tile });
3854
+ if (!certified) {
3855
+ operations.push({
3856
+ type: 'set_review_task',
3857
+ task: {
3858
+ id: `review-source-${createHash('sha256').update(source.sourceId).digest('hex').slice(0, 12)}`,
3859
+ message: `${source.title} is ${source.lifecycle} and must be certified, replaced, or removed before Project publication.`,
3860
+ status: 'open',
3861
+ sourceId: source.sourceId,
3862
+ pageId: page.id,
3863
+ tileId,
3864
+ },
3865
+ });
3866
+ }
3867
+ pageIds.add(page.id);
3868
+ tileIds.push(tileId);
3869
+ for (const requirement of current.requirements) {
3870
+ if (!catalogSourceSatisfiesRequirement(source, requirement))
3871
+ continue;
3872
+ const addition = coverageAdditions.get(requirement.id) ?? { sourceIds: [], componentIds: [] };
3873
+ addition.sourceIds.push(source.sourceId);
3874
+ addition.componentIds.push(tileId);
3875
+ coverageAdditions.set(requirement.id, addition);
3876
+ }
3877
+ }
3878
+ if (current.requirements.length > 0) {
3879
+ const existingByRequirement = new Map(current.coverage.map((item) => [item.requirementId, item]));
3880
+ operations.push({
3881
+ type: 'set_coverage',
3882
+ coverage: current.requirements.map((requirement) => {
3883
+ const existing = existingByRequirement.get(requirement.id);
3884
+ const addition = coverageAdditions.get(requirement.id);
3885
+ const sourceIds = unique([...(existing?.sourceIds ?? []), ...(addition?.sourceIds ?? [])]);
3886
+ const componentIds = unique([...(existing?.componentIds ?? []), ...(addition?.componentIds ?? [])]);
3887
+ const newlyCovered = Boolean(addition?.sourceIds.length && addition.componentIds.length);
3888
+ return {
3889
+ requirementId: requirement.id,
3890
+ status: newlyCovered ? 'covered' : existing?.status ?? 'gap',
3891
+ sourceIds,
3892
+ componentIds,
3893
+ reasons: newlyCovered
3894
+ ? unique([...(existing?.reasons ?? []), 'Covered by a server-resolved source added during manual composition.'])
3895
+ : existing?.reasons ?? ['No selected App source covers this requirement.'],
3896
+ };
3897
+ }),
3898
+ });
3899
+ }
3900
+ const next = applyAppBuildDraftOperations(current, current.revision, operations);
3901
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: current.revision, operations });
3902
+ return { draft: next, pageIds: Array.from(pageIds), tileIds };
3903
+ }
3904
+ function operationsForSelectedAppBuildSources(operations, selected) {
3905
+ const keptComponentIds = new Set(operations.flatMap((operation) => {
3906
+ if (operation.type === 'upsert_page') {
3907
+ return operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId)).map((item) => item.i);
3908
+ }
3909
+ if (operation.type === 'add_tile' && (!operation.tile.sourceId || selected.has(operation.tile.sourceId)))
3910
+ return [operation.tile.i];
3911
+ return [];
3912
+ }));
3913
+ return operations.flatMap((operation) => {
3914
+ if (operation.type === 'upsert_source')
3915
+ return selected.has(operation.source.id) ? [operation] : [];
3916
+ if (operation.type === 'set_review_task') {
3917
+ return !operation.task.sourceId || selected.has(operation.task.sourceId) ? [operation] : [];
3918
+ }
3919
+ if (operation.type === 'upsert_page') {
3920
+ const items = operation.page.layout.items.filter((item) => !item.sourceId || selected.has(item.sourceId));
3921
+ return [{
3922
+ ...operation,
3923
+ page: {
3924
+ ...operation.page,
3925
+ layout: {
3926
+ ...operation.page.layout,
3927
+ items,
3928
+ responsive: responsiveLayoutsForDashboardItems(items),
3929
+ },
3930
+ },
3931
+ }];
3932
+ }
3933
+ if (operation.type === 'set_requirements') {
3934
+ const coverage = (operation.coverage ?? []).map((item) => {
3935
+ const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
3936
+ const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
3937
+ return {
3938
+ ...item,
3939
+ sourceIds,
3940
+ componentIds,
3941
+ status: sourceIds.length && componentIds.length ? item.status : 'gap',
3942
+ };
3943
+ });
3944
+ return [{ ...operation, coverage }];
3945
+ }
3946
+ if (operation.type === 'set_coverage') {
3947
+ return [{
3948
+ ...operation,
3949
+ coverage: operation.coverage.map((item) => {
3950
+ const sourceIds = item.sourceIds.filter((sourceId) => selected.has(sourceId));
3951
+ const componentIds = item.componentIds.filter((componentId) => keptComponentIds.has(componentId));
3952
+ return { ...item, sourceIds, componentIds, status: sourceIds.length && componentIds.length ? item.status : 'gap' };
3953
+ }),
3954
+ }];
3955
+ }
3956
+ return [operation];
3957
+ });
3958
+ }
3959
+ /**
3960
+ * Rehydrate the canonical v3 source registry when a Git-owned App is opened in
3961
+ * Studio. Published dashboard documents intentionally keep the immutable
3962
+ * source binding on each tile; the mutable local draft must restore that
3963
+ * registry before core aggregate validation runs. This is metadata-only and
3964
+ * performs no request-time block scan. Current lifecycle/fingerprint checks
3965
+ * remain the responsibility of preview and publication preflight.
3966
+ */
3967
+ function rehydratePublishedAppSources(pages) {
3968
+ const sources = new Map();
3969
+ const reviewTasks = new Map();
3970
+ let requiresReviewPolicy = false;
3971
+ const normalizedPages = pages.map((page) => ({
3972
+ ...page,
3973
+ layout: {
3974
+ ...page.layout,
3975
+ items: page.layout.items.map((tile) => {
3976
+ const hasDataSource = Boolean(tile.block || tile.semantic || tile.draftAnalysis || tile.aiPin);
3977
+ if (!hasDataSource)
3978
+ return tile;
3979
+ const blockRef = tile.block ? ('ref' in tile.block ? tile.block.ref : tile.block.blockId) : undefined;
3980
+ const sourceRef = blockRef
3981
+ ?? tile.draftAnalysis?.ref
3982
+ ?? tile.semantic?.id
3983
+ ?? tile.aiPin?.id
3984
+ ?? `${page.id}/${tile.i}`;
3985
+ const sourceRevision = tile.sourceRevision
3986
+ ?? tile.review?.sourceFingerprint
3987
+ ?? tile.draftAnalysis?.artifactFingerprint
3988
+ ?? tile.semantic?.definitionFingerprint;
3989
+ const sourceId = tile.sourceId
3990
+ ?? `published:${createHash('sha256').update(`${page.id}\0${tile.i}\0${sourceRef}`).digest('hex').slice(0, 20)}`;
3991
+ const certified = Boolean(tile.block
3992
+ && sourceRevision
3993
+ && tile.trustState === 'certified'
3994
+ && tile.review?.status !== 'required');
3995
+ const kind = tile.semantic
3996
+ ? 'governed_semantic'
3997
+ : tile.draftAnalysis
3998
+ ? 'review_dql'
3999
+ : tile.block
4000
+ ? 'block'
4001
+ : 'text';
4002
+ const nextSource = {
4003
+ id: sourceId,
4004
+ kind,
4005
+ sourceRef,
4006
+ qualifiedIdentity: tile.title || sourceRef,
4007
+ ...(tile.block ? { sourcePath: sourceRef, executionRef: sourceRef } : {}),
4008
+ ...(sourceRevision ? { sourceRevision, sourceFingerprint: sourceRevision } : {}),
4009
+ lifecycle: certified ? 'certified' : tile.draftAnalysis ? 'review' : 'unknown',
4010
+ capabilities: {
4011
+ measures: [],
4012
+ dimensions: [],
4013
+ outputs: [],
4014
+ filters: Array.from(new Set((tile.filterBindings ?? [])
4015
+ .filter((binding) => binding.capability !== 'unsupported')
4016
+ .map((binding) => binding.filter))),
4017
+ allowedVisualizations: Array.from(new Set([
4018
+ tile.viz.type,
4019
+ ...(tile.display?.allowedVisualizations ?? []),
4020
+ ])),
4021
+ parameters: [],
4022
+ },
4023
+ trustState: certified ? 'certified' : 'review_required',
4024
+ reviewStatus: certified ? 'not_required' : 'required',
4025
+ };
4026
+ const previous = sources.get(sourceId);
4027
+ if (!previous) {
4028
+ sources.set(sourceId, nextSource);
4029
+ }
4030
+ else if (previous.sourceRef !== nextSource.sourceRef
4031
+ || previous.sourceRevision !== nextSource.sourceRevision
4032
+ || previous.trustState !== nextSource.trustState) {
4033
+ requiresReviewPolicy = true;
4034
+ sources.set(sourceId, {
4035
+ ...previous,
4036
+ lifecycle: 'unknown',
4037
+ trustState: 'review_required',
4038
+ reviewStatus: 'required',
4039
+ });
4040
+ reviewTasks.set(`published-source-${sourceId}`, {
4041
+ id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
4042
+ message: `${tile.title || sourceRef} has conflicting published source bindings. Refresh or replace it before publication.`,
4043
+ status: 'open',
4044
+ sourceId,
4045
+ pageId: page.id,
4046
+ tileId: tile.i,
4047
+ });
4048
+ }
4049
+ if (!certified) {
4050
+ requiresReviewPolicy = true;
4051
+ reviewTasks.set(`published-source-${sourceId}`, {
4052
+ id: `published-source-${createHash('sha256').update(sourceId).digest('hex').slice(0, 12)}`,
4053
+ message: sourceRevision
4054
+ ? `${tile.title || sourceRef} must be refreshed against the current project source before publication.`
4055
+ : `${tile.title || sourceRef} has no published source revision. Replace or refresh it before publication.`,
4056
+ status: 'open',
4057
+ sourceId,
4058
+ pageId: page.id,
4059
+ tileId: tile.i,
4060
+ });
4061
+ }
4062
+ return tile.sourceId === sourceId ? tile : { ...tile, sourceId, ...(sourceRevision ? { sourceRevision } : {}) };
4063
+ }),
4064
+ },
4065
+ }));
4066
+ return {
4067
+ pages: normalizedPages,
4068
+ sources: Array.from(sources.values()),
4069
+ reviewTasks: Array.from(reviewTasks.values()),
4070
+ requiresReviewPolicy,
4071
+ };
4072
+ }
3353
4073
  export function createStoredAppBuildDraft(projectRoot, input) {
3354
4074
  const baseAppId = cleanString(input.baseAppId);
3355
4075
  const base = baseAppId ? loadAppById(projectRoot, baseAppId) : null;
@@ -3386,7 +4106,8 @@ export function createStoredAppBuildDraft(projectRoot, input) {
3386
4106
  .filter((page) => Boolean(page))
3387
4107
  : [];
3388
4108
  const template = input.template ?? 'blank';
3389
- const pages = basePages.length ? basePages : [applyAppStudioTemplate(blankPage, template, name)];
4109
+ const published = rehydratePublishedAppSources(basePages);
4110
+ const pages = published.pages.length ? published.pages : [applyAppStudioTemplate(blankPage, template, name)];
3390
4111
  const draft = createAppBuildDraft({
3391
4112
  id,
3392
4113
  appId,
@@ -3394,23 +4115,19 @@ export function createStoredAppBuildDraft(projectRoot, input) {
3394
4115
  ...(base ? { baseApp: { appId: base.app.id, fingerprint: fingerprintAppDirectory(base.appDir) } } : {}),
3395
4116
  authoringMode: input.authoringMode === 'manual' ? 'manual' : 'ai',
3396
4117
  template,
3397
- sourcePolicy: input.sourcePolicy,
4118
+ sourcePolicy: published.requiresReviewPolicy ? 'include_review_required' : input.sourcePolicy,
3398
4119
  frame: {
3399
4120
  goal: cleanString(input.goal) || `Create ${name}`,
3400
4121
  audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
3401
4122
  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
- }],
4123
+ // Clarifications are returned only when the App-specific planner finds a
4124
+ // material ambiguity. A synthetic unanswered question here used to make
4125
+ // every new AI draft enter a UI dead-end before planning even ran.
4126
+ clarificationQuestions: [],
3412
4127
  },
4128
+ sources: published.sources,
3413
4129
  pages,
4130
+ reviewTasks: published.reviewTasks,
3414
4131
  });
3415
4132
  writeStoredAppBuildDraft(projectRoot, draft);
3416
4133
  return draft;
@@ -3626,12 +4343,16 @@ function loadStoredAppBuildDraft(projectRoot, id) {
3626
4343
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3627
4344
  try {
3628
4345
  const stored = storage.getAppBuildDraft(clean);
3629
- if (stored)
3630
- return stored;
4346
+ if (stored) {
4347
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
4348
+ if (migrated && stored.version !== migrated.version)
4349
+ storage.saveAppBuildDraft(migrated);
4350
+ return migrated;
4351
+ }
3631
4352
  const legacyPath = join(appBuildDraftDir(projectRoot), `${clean}.json`);
3632
4353
  if (!existsSync(legacyPath))
3633
4354
  return null;
3634
- const migrated = migrateLegacyAppBuildDraft(JSON.parse(readFileSync(legacyPath, 'utf-8')));
4355
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, JSON.parse(readFileSync(legacyPath, 'utf-8')));
3635
4356
  if (!migrated)
3636
4357
  return null;
3637
4358
  storage.saveAppBuildDraft(migrated);
@@ -3653,12 +4374,14 @@ export function writeStoredAppBuildDraft(projectRoot, draft, input = {}) {
3653
4374
  storage.close();
3654
4375
  }
3655
4376
  }
3656
- function migrateLegacyAppBuildDraft(value) {
4377
+ function migrateLegacyAppBuildDraft(projectRoot, value) {
3657
4378
  if (!value || typeof value !== 'object' || Array.isArray(value))
3658
4379
  return null;
3659
4380
  const legacy = value;
3660
- if (legacy.version === 2)
4381
+ if (legacy.version === 3)
3661
4382
  return legacy;
4383
+ if (legacy.version === 2)
4384
+ return migrateV2AppBuildDraft(projectRoot, legacy);
3662
4385
  if (legacy.version !== 1 || typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
3663
4386
  return null;
3664
4387
  const frame = (legacy.frame && typeof legacy.frame === 'object' ? legacy.frame : {});
@@ -3704,6 +4427,128 @@ function migrateLegacyAppBuildDraft(value) {
3704
4427
  updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : migrated.updatedAt,
3705
4428
  };
3706
4429
  }
4430
+ function migrateV2AppBuildDraft(projectRoot, legacy) {
4431
+ if (typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
4432
+ return null;
4433
+ const declarations = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
4434
+ const oldSources = Array.isArray(legacy.sources) ? legacy.sources : [];
4435
+ const idMap = new Map();
4436
+ const revisionById = new Map();
4437
+ const pathById = new Map();
4438
+ const migrationTasks = [];
4439
+ const sources = oldSources.flatMap((raw, index) => {
4440
+ const oldId = cleanString(raw.id) || `legacy-source-${index + 1}`;
4441
+ const kind = cleanString(raw.kind);
4442
+ if (!['block', 'certified_block', 'review_block'].includes(kind)) {
4443
+ idMap.set(oldId, oldId);
4444
+ return [{ ...raw, id: oldId }];
4445
+ }
4446
+ const sourceRef = cleanString(raw.sourceRef);
4447
+ const fingerprint = cleanString(raw.sourceFingerprint);
4448
+ const matches = declarations.filter((candidate) => (candidate.path === sourceRef
4449
+ || candidate.id === sourceRef
4450
+ || candidate.name === sourceRef
4451
+ || candidate.path.endsWith(`/${sourceRef}`)) && (!fingerprint || candidate.fingerprint === fingerprint));
4452
+ if (matches.length === 1) {
4453
+ const candidate = matches[0];
4454
+ const id = canonicalAppBlockSourceId(candidate);
4455
+ idMap.set(oldId, id);
4456
+ revisionById.set(id, candidate.fingerprint);
4457
+ pathById.set(id, candidate.path);
4458
+ const certified = candidate.status === 'certified';
4459
+ return [{
4460
+ id,
4461
+ kind: 'block',
4462
+ sourceRef: candidate.path,
4463
+ qualifiedIdentity: `${candidate.domain || 'global'}::block::${candidate.name}`,
4464
+ sourcePath: candidate.path,
4465
+ executionRef: candidate.path,
4466
+ sourceRevision: candidate.fingerprint,
4467
+ sourceFingerprint: candidate.fingerprint,
4468
+ lifecycle: certified ? 'certified' : candidate.status === 'review' ? 'review' : 'draft',
4469
+ capabilities: {
4470
+ measures: [],
4471
+ dimensions: candidate.dimensionIds,
4472
+ outputs: [],
4473
+ filters: candidate.filterIds,
4474
+ chartType: candidate.chartType,
4475
+ allowedVisualizations: candidate.chartType ? [candidate.chartType] : [],
4476
+ parameters: [],
4477
+ },
4478
+ trustState: certified ? 'certified' : 'review_required',
4479
+ reviewStatus: certified ? 'not_required' : 'required',
4480
+ }];
4481
+ }
4482
+ idMap.set(oldId, oldId);
4483
+ migrationTasks.push({
4484
+ id: `legacy-source-${createHash('sha256').update(oldId).digest('hex').slice(0, 12)}`,
4485
+ message: matches.length > 1
4486
+ ? `Legacy source ${sourceRef || oldId} is ambiguous. Select the exact path and revision before previewing or publishing.`
4487
+ : `Legacy source ${sourceRef || oldId} no longer resolves. Replace or remove it before previewing or publishing.`,
4488
+ status: 'open',
4489
+ sourceId: oldId,
4490
+ });
4491
+ return [{
4492
+ id: oldId,
4493
+ kind: 'block',
4494
+ sourceRef: sourceRef || oldId,
4495
+ qualifiedIdentity: cleanString(raw.qualifiedIdentity) || sourceRef || oldId,
4496
+ sourceRevision: fingerprint || undefined,
4497
+ sourceFingerprint: fingerprint || undefined,
4498
+ lifecycle: 'unknown',
4499
+ capabilities: { measures: [], dimensions: [], outputs: [], filters: [], allowedVisualizations: [], parameters: [] },
4500
+ trustState: 'review_required',
4501
+ reviewStatus: 'required',
4502
+ }];
4503
+ });
4504
+ const pages = (Array.isArray(legacy.pages) ? legacy.pages : []).map((page) => ({
4505
+ ...page,
4506
+ layout: {
4507
+ ...page.layout,
4508
+ items: page.layout.items.map((item) => {
4509
+ if (item.sourceId)
4510
+ return item;
4511
+ const blockRef = item.block
4512
+ ? ('ref' in item.block ? item.block.ref : item.block.blockId)
4513
+ : undefined;
4514
+ if (!blockRef)
4515
+ return item;
4516
+ const oldSource = oldSources.find((source) => {
4517
+ const ref = cleanString(source.sourceRef);
4518
+ return ref === blockRef || cleanString(source.id) === `block:${blockRef}` || cleanString(source.id) === `review-block:${blockRef}`;
4519
+ });
4520
+ const sourceId = oldSource ? idMap.get(cleanString(oldSource.id)) : undefined;
4521
+ if (!sourceId)
4522
+ return item;
4523
+ return {
4524
+ ...item,
4525
+ sourceId,
4526
+ sourceRevision: revisionById.get(sourceId) ?? (cleanString(oldSource?.sourceFingerprint) || undefined),
4527
+ ...(pathById.get(sourceId) ? { block: { ref: pathById.get(sourceId) } } : {}),
4528
+ };
4529
+ }),
4530
+ },
4531
+ }));
4532
+ const coverage = (Array.isArray(legacy.coverage) ? legacy.coverage : []).map((item) => ({
4533
+ ...item,
4534
+ sourceIds: item.sourceIds.map((id) => idMap.get(id) ?? id),
4535
+ }));
4536
+ const migratedWithoutHash = {
4537
+ ...legacy,
4538
+ version: 3,
4539
+ sources,
4540
+ pages,
4541
+ coverage,
4542
+ reviewTasks: [
4543
+ ...(Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : []),
4544
+ ...migrationTasks,
4545
+ ],
4546
+ };
4547
+ return {
4548
+ ...migratedWithoutHash,
4549
+ proposalHash: appBuildDraftHash({ ...migratedWithoutHash, proposalHash: undefined }),
4550
+ };
4551
+ }
3707
4552
  export function deleteStoredAppBuildDraft(projectRoot, draft) {
3708
4553
  const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
3709
4554
  let deleted;
@@ -3828,22 +4673,33 @@ export function preflightStoredAppBuildDraft(projectRoot, draft) {
3828
4673
  errors.push(`Run a current settled preview for page ${page.metadata.title || page.id} before Project publication.`);
3829
4674
  }
3830
4675
  for (const source of draft.sources) {
3831
- if (source.kind === 'certified_block') {
4676
+ if (source.kind === 'block' || source.kind === 'certified_block') {
3832
4677
  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.`);
4678
+ const lifecycle = source.lifecycle ?? (source.kind === 'certified_block' ? 'certified' : block?.status);
4679
+ const boundRevision = source.sourceRevision ?? source.sourceFingerprint;
4680
+ if (!block)
4681
+ errors.push(`Source ${source.id} no longer resolves at ${source.sourceRef}.`);
4682
+ if (block && canonicalAppBlockSourceId(block) !== source.id && source.kind === 'block')
4683
+ errors.push(`Source ${source.id} no longer matches its qualified path identity.`);
4684
+ if (!block || block.status !== 'certified' || lifecycle !== 'certified' || source.trustState !== 'certified') {
4685
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} is ${lifecycle ?? 'unknown'} and cannot be published to the Project.`);
4686
+ }
4687
+ if (block && !boundRevision) {
4688
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} has no bound source revision; refresh it before Project publication.`);
4689
+ }
4690
+ else if (block && boundRevision !== block.fingerprint) {
4691
+ errors.push(`Source ${source.qualifiedIdentity ?? source.id} changed after selection.`);
4692
+ }
3837
4693
  }
3838
4694
  if (source.kind === 'governed_semantic' || source.kind === 'semantic_query') {
3839
4695
  if (source.reviewStatus !== 'approved' || !source.snapshotId || !source.receiptId) {
3840
4696
  errors.push(`Source ${source.id} requires snapshot-bound semantic approval before Project publication.`);
3841
4697
  }
3842
4698
  }
3843
- if (source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
4699
+ if ((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
3844
4700
  errors.push(`Source ${source.id} is review-required and cannot be published to the Project.`);
3845
4701
  }
3846
- if ((source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') && draft.sourcePolicy !== 'include_review_required') {
4702
+ 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
4703
  errors.push(`Source ${source.id} requires include_review_required policy.`);
3848
4704
  }
3849
4705
  }
@@ -3964,7 +4820,7 @@ export function publishStoredAppBuildDraft(projectRoot, draft) {
3964
4820
  const domain = draft.pages[0]?.metadata.domain || 'general';
3965
4821
  const name = draft.name.trim() || draft.appId;
3966
4822
  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');
4823
+ const hasCertifiedAnalysis = draft.sources.some((source) => (source.kind === 'block' || source.kind === 'certified_block') && source.trustState === 'certified');
3968
4824
  const lifecycle = hasCertifiedAnalysis && !containsSemantic ? 'certified' : 'review';
3969
4825
  const app = {
3970
4826
  version: 1,
@@ -5139,7 +5995,7 @@ function appReadme(app, audience, blocks, dashboardId = 'overview') {
5139
5995
  '',
5140
5996
  ].join('\n');
5141
5997
  }
5142
- function collectBlockCandidates(projectRoot) {
5998
+ function collectBlockCandidates(projectRoot, options = {}) {
5143
5999
  const blocks = [];
5144
6000
  const seen = new Set();
5145
6001
  const scanDir = (dir) => {
@@ -5189,6 +6045,8 @@ function collectBlockCandidates(projectRoot) {
5189
6045
  };
5190
6046
  scanDir(join(projectRoot, 'blocks'));
5191
6047
  scanDir(join(projectRoot, 'domains'));
6048
+ if (options.preserveDuplicates)
6049
+ return blocks.sort((left, right) => left.path.localeCompare(right.path));
5192
6050
  const byId = new Map();
5193
6051
  for (const block of blocks) {
5194
6052
  const current = byId.get(block.id);
@@ -5203,6 +6061,10 @@ function collectBlockCandidates(projectRoot) {
5203
6061
  }
5204
6062
  return Array.from(byId.values());
5205
6063
  }
6064
+ function canonicalAppBlockSourceId(block) {
6065
+ const pathIdentity = createHash('sha256').update(`${block.path}\u0000${block.name}`).digest('hex').slice(0, 20);
6066
+ return `app:block:${block.domain || 'global'}:${pathIdentity}`;
6067
+ }
5206
6068
  function declaredAppFilterIds(source) {
5207
6069
  const filterSection = sectionBody(source, 'filterBindings');
5208
6070
  const parameterSection = sectionBody(source, 'parameterPolicy');