@duckcodeailabs/dql-cli 1.13.1 → 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 (90) hide show
  1. package/dist/apps-api.d.ts +249 -7
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +2855 -160
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-B3ugEb3H.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-BK_TDNcM.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-Cob_Lj05.js} +8 -8
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-CzIgxmP9.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/AnalyticsHome-C2vJ2fxY.js +6 -0
  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-DtRMiaF4.js → BusinessArtifactView-zwUOtddi.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-BlnhTMu3.js +22 -0
  14. package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-Coevr9NJ.js} +3 -3
  15. package/dist/assets/dql-notebook/assets/GlobalAiRail-BTZS11x5.js +1 -0
  16. package/dist/assets/dql-notebook/assets/GovernedContextPage-CjyyM_ne.js +7 -0
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-C1UeP85v.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-zGPFIvOY.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-DbXnQWu3.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Ch1Db01I.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DLxqJO6D.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-B2tNdkr_.js} +3 -3
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-0gY-ZwCy.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-DKVkXD20.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BUIByvYT.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-D2fIUxjE.js} +5 -5
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-Ci4LyNiQ.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-BF7ai_0t.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/SkillsPage-BgYrgirJ.js +10 -0
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-Bob1Laiq.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-IxDUKSG8.js +88 -0
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Dk42N0pU.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DKuuTw9l.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-TH8_ufKG.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-B-dcHHn0.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-BOc2KCZQ.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-CPT_vn5p.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DtOfpYc6.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-B8joDDH9.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
  41. package/dist/assets/dql-notebook/assets/index-DJRMz64S.js +635 -0
  42. package/dist/assets/dql-notebook/assets/link-2-DMZPHawg.js +6 -0
  43. package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-Dw0qlxPK.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-DUiSrPS3.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BrrFrRZQ.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-Ced0HT9g.js} +7 -2
  47. package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-D_9U9o7X.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-BlDOaonV.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-BDt6KRGR.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-CxUbF_BW.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-COWnsSdh.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/upload-DSFC2kJX.js +6 -0
  53. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-DYdx896E.js +32 -0
  54. package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Bfu7fosn.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DSpG0c0j.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-B1_pj9gq.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-CvfM03KZ.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-B4coLiOl.js} +1 -1
  59. package/dist/assets/dql-notebook/index.html +2 -2
  60. package/dist/commands/app.d.ts.map +1 -1
  61. package/dist/commands/app.js +65 -133
  62. package/dist/commands/app.js.map +1 -1
  63. package/dist/commands/model.js +21 -2
  64. package/dist/commands/model.js.map +1 -1
  65. package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
  66. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  67. package/dist/llm/providers/dql-agent-provider.js +65 -2
  68. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  69. package/dist/local-runtime.d.ts +112 -19
  70. package/dist/local-runtime.d.ts.map +1 -1
  71. package/dist/local-runtime.js +2026 -694
  72. package/dist/local-runtime.js.map +1 -1
  73. package/dist/package.json +10 -10
  74. package/dist/sql-result-filter.d.ts +9 -0
  75. package/dist/sql-result-filter.d.ts.map +1 -1
  76. package/dist/sql-result-filter.js +25 -0
  77. package/dist/sql-result-filter.js.map +1 -1
  78. package/package.json +10 -10
  79. package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +0 -5
  80. package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +0 -6
  81. package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +0 -4578
  82. package/dist/assets/dql-notebook/assets/BlockStudio--jNw9XUI.js +0 -60
  83. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +0 -22
  84. package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
  85. package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
  86. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
  87. package/dist/assets/dql-notebook/assets/index-Cpx47jCH.js +0 -634
  88. package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
  89. package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
  90. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +0 -28
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, } 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;
@@ -26,6 +26,342 @@ export async function handleAppsApi(ctx) {
26
26
  return true;
27
27
  }
28
28
  // ── Apps ────────────────────────────────────────────────────────────────
29
+ if (req.method === 'POST' && path === '/api/app-builds') {
30
+ try {
31
+ const body = await readJson(req);
32
+ const draft = createStoredAppBuildDraft(projectRoot, body);
33
+ sendJson(res, 201, { ok: true, draft });
34
+ }
35
+ catch (err) {
36
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
37
+ }
38
+ return true;
39
+ }
40
+ if (req.method === 'GET' && path === '/api/app-builds') {
41
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
42
+ try {
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 });
52
+ }
53
+ finally {
54
+ storage.close();
55
+ }
56
+ return true;
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
+ }
108
+ let buildMatch = path.match(/^\/api\/app-builds\/([^/]+)$/);
109
+ if (buildMatch && req.method === 'GET') {
110
+ const draft = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
111
+ sendJson(res, draft ? 200 : 404, draft ? { ok: true, draft } : { ok: false, error: 'App Build Draft not found.' });
112
+ return true;
113
+ }
114
+ if (buildMatch && req.method === 'PATCH') {
115
+ try {
116
+ const id = decodeURIComponent(buildMatch[1]);
117
+ const current = loadStoredAppBuildDraft(projectRoot, id);
118
+ if (!current) {
119
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
120
+ return true;
121
+ }
122
+ const body = await readJson(req);
123
+ if (body.expectedProposalHash && body.expectedProposalHash !== current.proposalHash) {
124
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_PROPOSAL_CONFLICT: refresh the local draft before applying changes.' });
125
+ return true;
126
+ }
127
+ const next = applyAppBuildDraftOperations(current, body.expectedRevision ?? -1, body.operations ?? []);
128
+ writeStoredAppBuildDraft(projectRoot, next, {
129
+ expectedRevision: current.revision,
130
+ operations: body.operations ?? [],
131
+ });
132
+ sendJson(res, 200, { ok: true, draft: next });
133
+ }
134
+ catch (err) {
135
+ const message = err instanceof Error ? err.message : String(err);
136
+ sendJson(res, message.includes('APP_BUILD_REVISION_CONFLICT') ? 409 : 400, { ok: false, error: message });
137
+ }
138
+ return true;
139
+ }
140
+ if (buildMatch && req.method === 'DELETE') {
141
+ try {
142
+ const id = decodeURIComponent(buildMatch[1]);
143
+ const current = loadStoredAppBuildDraft(projectRoot, id);
144
+ if (!current) {
145
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
146
+ return true;
147
+ }
148
+ const body = await readJson(req);
149
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
150
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before deleting this local draft.' });
151
+ return true;
152
+ }
153
+ sendJson(res, 200, deleteStoredAppBuildDraft(projectRoot, current));
154
+ }
155
+ catch (err) {
156
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
157
+ }
158
+ return true;
159
+ }
160
+ const askResultMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ask-results$/);
161
+ if (askResultMatch && req.method === 'POST') {
162
+ try {
163
+ const id = decodeURIComponent(askResultMatch[1]);
164
+ const body = await readJson(req);
165
+ const result = addAskResultToAppBuildDraft(projectRoot, id, body);
166
+ sendJson(res, result.deduped ? 200 : 201, result);
167
+ }
168
+ catch (err) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ sendJson(res, message.includes('APP_BUILD_') ? 409 : 400, { ok: false, error: message });
171
+ }
172
+ return true;
173
+ }
174
+ const draftAiProposalMatch = path.match(/^\/api\/app-builds\/([^/]+)\/ai-proposals$/);
175
+ if (draftAiProposalMatch && req.method === 'POST') {
176
+ try {
177
+ const id = decodeURIComponent(draftAiProposalMatch[1]);
178
+ const current = loadStoredAppBuildDraft(projectRoot, id);
179
+ if (!current) {
180
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
181
+ return true;
182
+ }
183
+ const body = await readJson(req);
184
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
185
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before asking AI to change this draft.' });
186
+ return true;
187
+ }
188
+ const proposal = await proposeAppBuildDraftOperations(projectRoot, current, {
189
+ prompt: cleanString(body.prompt) || current.frame.goal,
190
+ selectedBlockIds: body.selectedBlockIds,
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);
288
+ sendJson(res, 201, { ok: true, proposal });
289
+ }
290
+ catch (err) {
291
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
292
+ }
293
+ return true;
294
+ }
295
+ buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/preflight$/);
296
+ if (buildMatch && req.method === 'POST') {
297
+ const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
298
+ if (!current) {
299
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
300
+ return true;
301
+ }
302
+ const body = await readJson(req);
303
+ if ((body.expectedRevision !== undefined && body.expectedRevision !== current.revision)
304
+ || (body.proposalHash && body.proposalHash !== current.proposalHash)) {
305
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh before preflight.' });
306
+ return true;
307
+ }
308
+ const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
309
+ if (errors.length) {
310
+ sendJson(res, 400, {
311
+ ok: false,
312
+ code: 'APP_BUILD_NOT_READY',
313
+ message: 'This App is not ready to publish yet.',
314
+ draft: current,
315
+ errors,
316
+ details: { draft: current, errors },
317
+ });
318
+ return true;
319
+ }
320
+ const next = markStoredAppBuildDraftPreflighted(projectRoot, current);
321
+ sendJson(res, 200, { ok: true, draft: next, errors: [] });
322
+ return true;
323
+ }
324
+ buildMatch = path.match(/^\/api\/app-builds\/([^/]+)\/(publish-to-project|commit)$/);
325
+ if (buildMatch && req.method === 'POST') {
326
+ try {
327
+ const current = loadStoredAppBuildDraft(projectRoot, decodeURIComponent(buildMatch[1]));
328
+ if (!current) {
329
+ sendJson(res, 404, { ok: false, error: 'App Build Draft not found.' });
330
+ return true;
331
+ }
332
+ const body = await readJson(req);
333
+ if (body.expectedRevision !== current.revision || body.proposalHash !== current.proposalHash) {
334
+ sendJson(res, 409, { ok: false, error: 'APP_BUILD_REVISION_CONFLICT: refresh the Build Frame before committing.' });
335
+ return true;
336
+ }
337
+ const errors = preflightAppBuildRequestErrors(projectRoot, current, ctx.verifyAppBuildPreview);
338
+ if (errors.length) {
339
+ sendJson(res, 400, {
340
+ ok: false,
341
+ code: 'APP_BUILD_NOT_READY',
342
+ message: 'This App is not ready to publish yet.',
343
+ draft: current,
344
+ errors,
345
+ details: { draft: current, errors },
346
+ });
347
+ return true;
348
+ }
349
+ // The final confirmation consumes the exact receipt the user reviewed.
350
+ // Compatibility callers that arrive without one are preflighted once in
351
+ // this same request so they cannot enter a client-side recheck loop.
352
+ const prepared = hasCurrentAppBuildPreflightReceipt(current)
353
+ ? current
354
+ : markStoredAppBuildDraftPreflighted(projectRoot, current);
355
+ const result = publishStoredAppBuildDraft(projectRoot, prepared);
356
+ await refreshGeneratedMetadata(projectRoot);
357
+ sendJson(res, 201, { ...result, compatibilityRoute: buildMatch[2] === 'commit' });
358
+ }
359
+ catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ sendJson(res, message.includes('already exists') ? 409 : 400, { ok: false, error: message });
362
+ }
363
+ return true;
364
+ }
29
365
  if (req.method === 'GET' && path === '/api/apps') {
30
366
  const apps = collectAppsList(projectRoot);
31
367
  sendJson(res, 200, { apps });
@@ -34,7 +370,16 @@ export async function handleAppsApi(ctx) {
34
370
  if (req.method === 'POST' && path === '/api/apps/recommend-blocks') {
35
371
  try {
36
372
  const body = await readJson(req);
37
- 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) });
38
383
  }
39
384
  catch (err) {
40
385
  sendJson(res, 500, { error: err.message });
@@ -156,7 +501,9 @@ export async function handleAppsApi(ctx) {
156
501
  if (req.method === 'POST' && path === '/api/apps') {
157
502
  try {
158
503
  const body = await readJson(req);
159
- const result = createAppPackage(projectRoot, body);
504
+ // Compatibility adapter: the legacy manual endpoint now traverses the
505
+ // same versioned draft, preflight, and private commit service as Studio.
506
+ const result = createAppPackageViaBuildDraft(projectRoot, body);
160
507
  if (!result.ok) {
161
508
  sendJson(res, 400, { error: result.error });
162
509
  return true;
@@ -204,6 +551,24 @@ export async function handleAppsApi(ctx) {
204
551
  }
205
552
  return true;
206
553
  }
554
+ m = path.match(/^\/api\/apps\/([^/]+)\/publish-to-project$/);
555
+ if (m && req.method === 'POST') {
556
+ const appId = decodeURIComponent(m[1]);
557
+ try {
558
+ const body = await readJson(req);
559
+ const result = promoteAppForStakeholders(projectRoot, appId, body);
560
+ if (!result.ok) {
561
+ sendJson(res, 409, result);
562
+ return true;
563
+ }
564
+ await refreshGeneratedMetadata(projectRoot);
565
+ sendJson(res, 200, result);
566
+ }
567
+ catch (err) {
568
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
569
+ }
570
+ return true;
571
+ }
207
572
  m = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/approve-semantic$/);
208
573
  if (m && req.method === 'POST') {
209
574
  const appId = decodeURIComponent(m[1]);
@@ -714,7 +1079,8 @@ export async function handleAppsApi(ctx) {
714
1079
  if (m && req.method === 'DELETE') {
715
1080
  const id = decodeURIComponent(m[1]);
716
1081
  try {
717
- const result = deleteAppPackage(projectRoot, id);
1082
+ const body = await readJson(req);
1083
+ const result = deleteAppPackage(projectRoot, id, body);
718
1084
  if (!result.ok) {
719
1085
  sendJson(res, result.status, { ok: false, error: result.error });
720
1086
  return true;
@@ -727,8 +1093,25 @@ export async function handleAppsApi(ctx) {
727
1093
  }
728
1094
  return true;
729
1095
  }
1096
+ if (m && req.method === 'PATCH') {
1097
+ const id = decodeURIComponent(m[1]);
1098
+ try {
1099
+ const body = await readJson(req);
1100
+ const result = renameApp(projectRoot, id, body);
1101
+ if (!result.ok) {
1102
+ sendJson(res, result.code === 'APP_NOT_FOUND' ? 404 : result.code === 'APP_CHANGED' ? 409 : 400, result);
1103
+ return true;
1104
+ }
1105
+ ctx.scheduleProjectRefresh?.('app-renamed');
1106
+ sendJson(res, 200, result);
1107
+ }
1108
+ catch (err) {
1109
+ sendJson(res, 500, { ok: false, error: err.message });
1110
+ }
1111
+ return true;
1112
+ }
730
1113
  if (m && req.method === 'GET') {
731
- const id = m[1];
1114
+ const id = decodeURIComponent(m[1]);
732
1115
  const result = loadAppById(projectRoot, id);
733
1116
  if (!result) {
734
1117
  sendJson(res, 404, { error: `App "${id}" not found` });
@@ -737,6 +1120,22 @@ export async function handleAppsApi(ctx) {
737
1120
  sendJson(res, 200, result);
738
1121
  return true;
739
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
+ }
740
1139
  // /api/apps/:id/dashboards
741
1140
  m = path.match(/^\/api\/apps\/([^/]+)\/dashboards$/);
742
1141
  if (m && req.method === 'GET') {
@@ -830,6 +1229,7 @@ function collectAppsList(projectRoot) {
830
1229
  out.push({
831
1230
  id: document.id,
832
1231
  name: document.name,
1232
+ fingerprint: fingerprintAppDirectory(appDir),
833
1233
  filePath: relative(projectRoot, appDir),
834
1234
  domain: document.domain,
835
1235
  ownerDomain: document.ownerDomain,
@@ -1242,13 +1642,51 @@ function buildAppProposal(plan) {
1242
1642
  seenGap.add(question.toLowerCase());
1243
1643
  gaps.push({ id: `gap_${gaps.length + 1}`, question, reason: 'No certified block covers this question.' });
1244
1644
  }
1645
+ const metrics = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.measures)));
1646
+ const dimensions = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.dimensions)));
1647
+ const filters = Array.from(new Set(plan.requirements.flatMap((requirement) => requirement.filters)));
1648
+ const clarifications = [];
1649
+ if (metrics.length === 0) {
1650
+ clarifications.push({
1651
+ id: 'metric',
1652
+ question: 'Which governed business metric should lead this App?',
1653
+ choices: ['Choose a certified block', 'Choose a semantic metric', 'Describe the calculation as review-required analysis'],
1654
+ required: true,
1655
+ });
1656
+ }
1657
+ if (tiles.length === 0 || gaps.length > 0) {
1658
+ clarifications.push({
1659
+ id: 'coverage',
1660
+ question: 'How should DQL handle requirements without governed coverage?',
1661
+ choices: ['Refine metrics or dimensions', 'Use governed semantic coverage', 'Include review-required analysis', 'Keep visible gaps'],
1662
+ required: tiles.length === 0,
1663
+ });
1664
+ }
1665
+ if (!plan.prompt.match(/\b(day|week|month|quarter|year|today|yesterday|current|previous|over time)\b/i)) {
1666
+ clarifications.push({
1667
+ id: 'time',
1668
+ question: 'What time window and comparison should the App use?',
1669
+ choices: ['Current period vs previous period', 'Trailing 30 days', 'Year to date', 'No time comparison'],
1670
+ required: false,
1671
+ });
1672
+ }
1245
1673
  return {
1246
1674
  intent: {
1247
1675
  target: plan.publicationIntent,
1248
1676
  initialVisibility: 'private',
1249
1677
  },
1678
+ buildFrame: {
1679
+ goal: plan.businessGoal,
1680
+ audience: plan.audience,
1681
+ metrics,
1682
+ dimensions,
1683
+ grain: plan.requirements.find((requirement) => requirement.grain)?.grain,
1684
+ filters,
1685
+ desiredOutput: plan.planning.displayStrategy,
1686
+ },
1250
1687
  tiles,
1251
1688
  gaps,
1689
+ clarifications,
1252
1690
  followUps: gaps.map((gap) => gap.question).slice(0, 4),
1253
1691
  coverage: {
1254
1692
  certifiedTiles: tiles.filter((tile) => tile.certification === 'certified').length,
@@ -1343,6 +1781,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1343
1781
  certification: 'ai_generated',
1344
1782
  sourceClass: 'exploratory_analysis',
1345
1783
  reviewStatus: 'required',
1784
+ requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
1346
1785
  preflight: {
1347
1786
  status: preview ? 'passed' : unresolvedExecutionError ? 'blocked' : 'review_required',
1348
1787
  sourceFingerprint: `sha256:${createHash('sha256').update(sql).digest('hex')}`,
@@ -1383,6 +1822,7 @@ async function fillProposalGaps(proposal, hooks, maxGeneratedTiles) {
1383
1822
  certification: 'ai_generated',
1384
1823
  sourceClass: 'exploratory_analysis',
1385
1824
  reviewStatus: 'required',
1825
+ requirementIds: gap.id.startsWith('gap_') ? [gap.id.slice('gap_'.length)] : undefined,
1386
1826
  preflight: {
1387
1827
  status: 'blocked',
1388
1828
  message: 'Exploratory analysis generation failed.',
@@ -1530,52 +1970,452 @@ export async function proposeAppAiBuild(projectRoot, input, hooks) {
1530
1970
  kg.close();
1531
1971
  }
1532
1972
  }
1533
- /**
1534
- * Materialize explicitly selected exploratory candidates as Git-owned, app-scoped
1535
- * review DQL. They are never promoted into the shared block library and the
1536
- * dashboard stores a fingerprinted reference instead of inline SQL.
1537
- */
1538
- function attachGeneratedDraftAnalyses(projectRoot, plan, generatedPaths, candidates) {
1539
- const dashboardId = plan.pages[0]?.id ?? 'overview';
1540
- const dashboardPath = generatedPaths.find((path) => path.endsWith(`${dashboardId}.dqld`))
1541
- ?? generatedPaths.find((path) => path.endsWith('.dqld'));
1542
- if (!dashboardPath)
1543
- return;
1544
- const absolutePath = join(projectRoot, dashboardPath);
1545
- if (!existsSync(absolutePath))
1546
- return;
1547
- const doc = JSON.parse(readFileSync(absolutePath, 'utf-8'));
1548
- // Story layout: exploratory tiles live in the review appendix. Classic grids
1549
- // (no sections) just append at the bottom, untagged.
1550
- const storyMode = Array.isArray(doc.sections) && doc.sections.length > 0;
1551
- if (storyMode && !doc.sections.some((section) => section.kind === 'appendix')) {
1552
- doc.sections.push({
1553
- id: 'appendix',
1554
- title: 'AI-generated analysis — needs review',
1555
- kind: 'appendix',
1556
- order: doc.sections.length,
1557
- });
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;
1558
2029
  }
1559
- let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
1560
- let x = 0;
1561
- const draftsDir = join(projectRoot, 'apps', plan.appId, 'drafts');
1562
- mkdirSync(draftsDir, { recursive: true });
1563
- for (const tile of candidates) {
1564
- if (!tile.sql)
1565
- continue;
1566
- if (tile.sql.includes('"""'))
1567
- throw new Error(`Exploratory SQL for "${tile.title}" contains an unsupported triple-quote delimiter.`);
1568
- const draftId = slugify(tile.title) || slugify(tile.id) || `analysis-${Date.now()}`;
1569
- const draftPath = join(draftsDir, `${draftId}.dql`);
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
+ }
2074
+ function materializeAppBuildProposalReviewAnalyses(projectRoot, draft, plan, proposalId, candidates, inputDashboards) {
2075
+ const eligible = candidates.filter((candidate) => (candidate.source === 'ai_generated'
2076
+ && Boolean(candidate.sql)
2077
+ && !candidate.error
2078
+ && candidate.preflight.status === 'passed'));
2079
+ if (eligible.length === 0 || inputDashboards.length === 0) {
2080
+ return { dashboards: inputDashboards, sourceOperations: [], reviewTasks: [], coverageByRequirement: new Map() };
2081
+ }
2082
+ const proposalKey = slugify(proposalId) || `proposal-${Date.now()}`;
2083
+ const relativeDraftDir = `proposals/${proposalKey}/drafts`;
2084
+ const absoluteDraftDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, relativeDraftDir);
2085
+ mkdirSync(absoluteDraftDir, { recursive: true });
2086
+ const sourceOperations = [];
2087
+ const reviewTasks = [];
2088
+ const coverageByRequirement = new Map();
2089
+ const generatedItems = [];
2090
+ const normalizeQuestion = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
2091
+ for (const [index, candidate] of eligible.entries()) {
2092
+ const sql = candidate.sql;
2093
+ if (sql.includes('"""'))
2094
+ throw new Error(`Exploratory SQL for "${candidate.title}" contains an unsupported triple-quote delimiter.`);
2095
+ const candidateKey = slugify(candidate.id) || `analysis-${index + 1}`;
2096
+ const blockName = `app_${slugify(draft.appId)}_${candidateKey}`;
2097
+ const sourceRef = `${relativeDraftDir}/${candidateKey}.dql`;
1570
2098
  const source = [
1571
- `block "${escapeDqlString(draftId)}" {`,
2099
+ `block "${escapeDqlString(blockName)}" {`,
1572
2100
  ` domain = "${escapeDqlString(plan.domain)}"`,
1573
2101
  ' type = "custom"',
1574
2102
  ' status = "review"',
1575
2103
  ` owner = "${escapeDqlString(plan.owner)}"`,
1576
- ` description = "${escapeDqlString((tile.answer ?? tile.question ?? tile.title).slice(0, 240))}"`,
1577
- tile.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${tile.question}`)}"` : '',
1578
- tile.question ? ` examples = [{ question = "${escapeDqlString(tile.question)}" }]` : '',
2104
+ ` description = "${escapeDqlString((candidate.answer ?? candidate.question ?? candidate.title).slice(0, 240))}"`,
2105
+ candidate.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${candidate.question}`)}"` : '',
2106
+ ' caveats = ["AI-generated App analysis. Validate joins, filters, grain, and business interpretation before promotion."]',
2107
+ ' tags = ["app-scoped", "ai-generated", "needs-review"]',
2108
+ '',
2109
+ ' query = """',
2110
+ sql,
2111
+ ' """',
2112
+ '',
2113
+ ' visualization {',
2114
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(candidate.viz))}"`,
2115
+ ' }',
2116
+ '}',
2117
+ '',
2118
+ ].filter(Boolean).join('\n');
2119
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
2120
+ writeFileSync(join(absoluteDraftDir, `${candidateKey}.dql`), source, 'utf-8');
2121
+ const sourceId = `review-dql:${candidate.id}`;
2122
+ const componentId = `review_${candidateKey}`;
2123
+ sourceOperations.push({
2124
+ type: 'upsert_source',
2125
+ source: {
2126
+ id: sourceId,
2127
+ kind: 'review_dql',
2128
+ sourceRef,
2129
+ qualifiedIdentity: candidate.title,
2130
+ snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId,
2131
+ sourceFingerprint: fingerprint,
2132
+ receiptId: candidate.preflight.receiptId,
2133
+ trustState: 'review_required',
2134
+ reviewStatus: 'required',
2135
+ },
2136
+ });
2137
+ reviewTasks.push({
2138
+ type: 'set_review_task',
2139
+ task: {
2140
+ id: `review-generated-${candidateKey}`,
2141
+ message: `Validate the joins, grain, filters, and business interpretation for ${candidate.title}.`,
2142
+ status: 'open',
2143
+ sourceId,
2144
+ pageId: inputDashboards[0].id,
2145
+ tileId: componentId,
2146
+ },
2147
+ });
2148
+ const requirementIds = candidate.requirementIds?.length
2149
+ ? candidate.requirementIds
2150
+ : plan.requirements.filter((requirement) => normalizeQuestion(requirement.question) === normalizeQuestion(candidate.question ?? '')).map((requirement) => requirement.id);
2151
+ for (const requirementId of requirementIds) {
2152
+ const current = coverageByRequirement.get(requirementId) ?? { sourceIds: [], componentIds: [] };
2153
+ current.sourceIds.push(sourceId);
2154
+ current.componentIds.push(componentId);
2155
+ coverageByRequirement.set(requirementId, current);
2156
+ }
2157
+ generatedItems.push({
2158
+ i: componentId,
2159
+ x: (index % 2) * 6,
2160
+ y: Math.floor(index / 2) * 4,
2161
+ w: 6,
2162
+ h: 4,
2163
+ draftAnalysis: {
2164
+ ref: sourceRef,
2165
+ artifactFingerprint: fingerprint,
2166
+ ...(candidate.preflight.snapshotId ?? plan.snapshotId ? { snapshotId: candidate.preflight.snapshotId ?? plan.snapshotId } : {}),
2167
+ ...(candidate.preflight.receiptId ? { executionReceiptId: candidate.preflight.receiptId } : {}),
2168
+ },
2169
+ viz: { type: normalizeGeneratedViz(candidate.viz) },
2170
+ title: candidate.title,
2171
+ sourceClass: 'exploratory_analysis',
2172
+ sourceEvidence: [
2173
+ ...(candidate.sourceEvidence ?? []),
2174
+ {
2175
+ source: sourceRef,
2176
+ reason: candidate.question
2177
+ ? `AI generated this private analysis for: ${candidate.question}`
2178
+ : 'AI generated this private analysis for a requirement without governed coverage.',
2179
+ kind: 'review_dql',
2180
+ trustState: 'review_required',
2181
+ },
2182
+ ],
2183
+ review: { status: 'required', sourceFingerprint: fingerprint, ...(candidate.preflight.receiptId ? { preflightReceiptId: candidate.preflight.receiptId } : {}) },
2184
+ trustState: 'review_required',
2185
+ reviewStatus: 'review_required',
2186
+ });
2187
+ }
2188
+ const first = inputDashboards[0];
2189
+ const baseY = first.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
2190
+ const items = [...first.layout.items, ...generatedItems.map((item) => ({ ...item, y: item.y + baseY }))];
2191
+ const sections = first.sections
2192
+ ? first.sections.some((section) => section.kind === 'appendix')
2193
+ ? first.sections
2194
+ : [...first.sections, { id: 'review-appendix', title: 'Review-required analysis', kind: 'appendix', order: first.sections.length }]
2195
+ : undefined;
2196
+ const appendixId = sections?.find((section) => section.kind === 'appendix')?.id;
2197
+ const finalItems = appendixId
2198
+ ? items.map((item) => generatedItems.some((generated) => generated.i === item.i) ? { ...item, sectionId: appendixId } : item)
2199
+ : items;
2200
+ const page = {
2201
+ ...first,
2202
+ ...(sections ? { sections } : {}),
2203
+ layout: {
2204
+ ...first.layout,
2205
+ items: finalItems,
2206
+ responsive: responsiveLayoutsForDashboardItems(finalItems),
2207
+ },
2208
+ };
2209
+ return {
2210
+ dashboards: [page, ...inputDashboards.slice(1)],
2211
+ sourceOperations,
2212
+ reviewTasks,
2213
+ coverageByRequirement,
2214
+ };
2215
+ }
2216
+ /**
2217
+ * AI is a coauthor of the same local AppBuildDraft used by manual authoring.
2218
+ * This adapter reuses the governed planner, compiles its result outside the
2219
+ * project, and returns typed operations. It does not mutate the draft or write
2220
+ * any canonical App source.
2221
+ */
2222
+ export async function proposeAppBuildDraftOperations(projectRoot, draft, input, hooks) {
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,
2252
+ domain: draft.pages[0]?.metadata.domain,
2253
+ audience: draft.frame.audience,
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] : [];
2260
+ });
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)
2275
+ 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
+ },
2312
+ };
2313
+ const coverage = brief.requirements.map((requirement) => {
2314
+ const components = brief.components.filter((component) => component.requirementIds.includes(requirement.id));
2315
+ return {
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.'],
2323
+ };
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
+ }));
2338
+ const operations = [
2339
+ {
2340
+ type: 'set_frame',
2341
+ frame: brief.frame,
2342
+ },
2343
+ {
2344
+ type: 'set_requirements',
2345
+ requirements: brief.requirements,
2346
+ coverage,
2347
+ },
2348
+ { type: 'upsert_page', page: nextPage },
2349
+ ...sourceOperations,
2350
+ ...reviewTasks,
2351
+ ];
2352
+ return {
2353
+ id: `proposal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2354
+ draftId: draft.id,
2355
+ baseRevision: draft.revision,
2356
+ baseProposalHash: draft.proposalHash,
2357
+ operations,
2358
+ defaultSelectedSourceIds: plannedSources
2359
+ .filter((source) => source.eligibility.localPreview)
2360
+ .map((source) => source.sourceId),
2361
+ clarifications,
2362
+ candidateSourceIds: brief.candidateSourceIds,
2363
+ warnings: brief.warnings,
2364
+ summary: {
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,
2370
+ },
2371
+ };
2372
+ }
2373
+ /**
2374
+ * Materialize explicitly selected exploratory candidates as Git-owned, app-scoped
2375
+ * review DQL. They are never promoted into the shared block library and the
2376
+ * dashboard stores a fingerprinted reference instead of inline SQL.
2377
+ */
2378
+ function attachGeneratedDraftAnalyses(projectRoot, plan, generatedPaths, candidates) {
2379
+ const dashboardId = plan.pages[0]?.id ?? 'overview';
2380
+ const dashboardPath = generatedPaths.find((path) => path.endsWith(`${dashboardId}.dqld`))
2381
+ ?? generatedPaths.find((path) => path.endsWith('.dqld'));
2382
+ if (!dashboardPath)
2383
+ return;
2384
+ const absolutePath = join(projectRoot, dashboardPath);
2385
+ if (!existsSync(absolutePath))
2386
+ return;
2387
+ const doc = JSON.parse(readFileSync(absolutePath, 'utf-8'));
2388
+ // Story layout: exploratory tiles live in the review appendix. Classic grids
2389
+ // (no sections) just append at the bottom, untagged.
2390
+ const storyMode = Array.isArray(doc.sections) && doc.sections.length > 0;
2391
+ if (storyMode && !doc.sections.some((section) => section.kind === 'appendix')) {
2392
+ doc.sections.push({
2393
+ id: 'appendix',
2394
+ title: 'AI-generated analysis — needs review',
2395
+ kind: 'appendix',
2396
+ order: doc.sections.length,
2397
+ });
2398
+ }
2399
+ let y = doc.layout.items.reduce((max, item) => Math.max(max, item.y + item.h), 0);
2400
+ let x = 0;
2401
+ const draftsDir = join(projectRoot, 'apps', plan.appId, 'drafts');
2402
+ mkdirSync(draftsDir, { recursive: true });
2403
+ for (const tile of candidates) {
2404
+ if (!tile.sql)
2405
+ continue;
2406
+ if (tile.sql.includes('"""'))
2407
+ throw new Error(`Exploratory SQL for "${tile.title}" contains an unsupported triple-quote delimiter.`);
2408
+ const draftId = slugify(tile.title) || slugify(tile.id) || `analysis-${Date.now()}`;
2409
+ const draftPath = join(draftsDir, `${draftId}.dql`);
2410
+ const source = [
2411
+ `block "${escapeDqlString(draftId)}" {`,
2412
+ ` domain = "${escapeDqlString(plan.domain)}"`,
2413
+ ' type = "custom"',
2414
+ ' status = "review"',
2415
+ ` owner = "${escapeDqlString(plan.owner)}"`,
2416
+ ` description = "${escapeDqlString((tile.answer ?? tile.question ?? tile.title).slice(0, 240))}"`,
2417
+ tile.question ? ` llmContext = "${escapeDqlString(`App-scoped exploratory analysis. Question: ${tile.question}`)}"` : '',
2418
+ tile.question ? ` examples = [{ question = "${escapeDqlString(tile.question)}" }]` : '',
1579
2419
  ' caveats = ["AI-generated app draft. Validate joins, filters, grain, and business interpretation before promotion or publication."]',
1580
2420
  ' tags = ["app-scoped", "ai-generated", "needs-review"]',
1581
2421
  '',
@@ -1677,10 +2517,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1677
2517
  if (selected.size === 0) {
1678
2518
  return { ok: false, error: 'Select at least one tile to create the app.', status: 400 };
1679
2519
  }
2520
+ const plannedFilterIds = new Set([
2521
+ ...(plan.globalFilters ?? []).map((filter) => filter.id),
2522
+ ...plan.pages.flatMap((page) => (page.filters ?? []).map((filter) => filter.id)),
2523
+ ]);
2524
+ const selectedFilterIds = new Set((input.filterIds ?? Array.from(plannedFilterIds)).filter((filterId) => plannedFilterIds.has(filterId)));
1680
2525
  // Filter the plan's certified tiles down to the confirmed selection; structural
1681
2526
  // tiles (narrative, draft placeholders) stay — they document the story + gaps.
1682
2527
  const committedPlan = {
1683
2528
  ...plan,
2529
+ globalFilters: (plan.globalFilters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
2530
+ // `+ Add page` must not rewrite the App it is adding to (`UI-017`), but the
2531
+ // fields were dropped with no error, so a name typed in that brief silently
2532
+ // vanished. Adding a page keeps the App's own name; use `PATCH /api/apps/:id`
2533
+ // to rename, and see `appliedNameWarnings` below for the reported diagnostic.
1684
2534
  ...(!isExistingPageBuild && cleanString(input.appName) ? { name: cleanString(input.appName).slice(0, 120) } : {}),
1685
2535
  ...(!isExistingPageBuild && cleanString(input.audience) ? { audience: cleanString(input.audience).slice(0, 120) } : {}),
1686
2536
  pages: plan.pages.map((page, pageIndex) => ({
@@ -1688,15 +2538,20 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1688
2538
  ...(isExistingPageBuild && pageIndex === 0 && cleanString(input.pageTitle)
1689
2539
  ? { title: cleanString(input.pageTitle).slice(0, 120) }
1690
2540
  : {}),
2541
+ filters: (page.filters ?? []).filter((filter) => selectedFilterIds.has(filter.id)),
1691
2542
  tiles: page.tiles
1692
2543
  .filter((tile) => !['certified_block', 'semantic_query'].includes(tile.kind) || selected.has(tile.id))
1693
2544
  .map((tile) => {
1694
2545
  const override = tileOverrides[tile.id];
2546
+ const filteredTile = {
2547
+ ...tile,
2548
+ filterBindings: (tile.filterBindings ?? []).filter((binding) => selectedFilterIds.has(binding.filter)),
2549
+ };
1695
2550
  if (!override)
1696
- return tile;
2551
+ return filteredTile;
1697
2552
  const viz = override.viz;
1698
2553
  return {
1699
- ...tile,
2554
+ ...filteredTile,
1700
2555
  ...(override.title ? { title: override.title } : {}),
1701
2556
  ...(viz ? { viz } : {}),
1702
2557
  ...(viz && tile.display ? {
@@ -1721,7 +2576,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1721
2576
  const { KGStore, defaultKgPath, ensureMetadataCatalogFresh, generateAppFromPlan, reindexProject, validateAppPlan, } = await import('@duckcodeailabs/dql-agent');
1722
2577
  const index = await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
1723
2578
  if (!session.snapshotId || index.kgFingerprint !== session.snapshotId) {
1724
- return { ok: false, error: 'Project sources changed after this proposal was created. Refresh and review the App proposal again.', status: 409 };
2579
+ // This compares a whole-project index, so an unrelated block or model edit
2580
+ // invalidates a pending proposal. Kept deliberately strict — the proposal's
2581
+ // preflight receipts are bound to that snapshot — but the message now says
2582
+ // what to do instead of only reporting a conflict.
2583
+ return {
2584
+ ok: false,
2585
+ error: 'Project sources changed after this proposal was created, so its preflight evidence no longer applies. Re-run the build request to get a fresh proposal.',
2586
+ status: 409,
2587
+ };
1725
2588
  }
1726
2589
  const kg = new KGStore(defaultKgPath(projectRoot));
1727
2590
  try {
@@ -1747,7 +2610,10 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1747
2610
  const stagedAppDir = isExistingPageBuild
1748
2611
  ? join(stagingRoot, 'candidate', 'apps', committedPlan.appId)
1749
2612
  : generatedAppDir;
1750
- const targetAppDir = join(projectRoot, 'apps', committedPlan.appId);
2613
+ // Apps also resolve under `domains/**/apps/<id>`. Hardcoding the canonical
2614
+ // root meant a domain-hosted App always failed the existence check below
2615
+ // and could never have a page added.
2616
+ const targetAppDir = existingApp?.appDir ?? join(projectRoot, 'apps', committedPlan.appId);
1751
2617
  const backupAppDir = join(projectRoot, '.dql', 'local', 'app-build-staging', `${session.id}-backup`);
1752
2618
  rmSync(stagingRoot, { recursive: true, force: true });
1753
2619
  try {
@@ -1841,6 +2707,15 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1841
2707
  rmSync(stagingRoot, { recursive: true, force: true });
1842
2708
  }
1843
2709
  const attachWarnings = [];
2710
+ // Adding a page deliberately cannot rename or re-audience the App it joins.
2711
+ // Say so instead of discarding the input silently — this array existed and
2712
+ // was never written to, so the drop had no surface at all.
2713
+ if (isExistingPageBuild && cleanString(input.appName)) {
2714
+ attachWarnings.push('The App name was not changed: adding a page cannot rename the App. Rename it from the App header.');
2715
+ }
2716
+ if (isExistingPageBuild && cleanString(input.audience)) {
2717
+ attachWarnings.push('The audience was not changed: adding a page keeps the App\'s existing audience.');
2718
+ }
1844
2719
  await ensureMetadataCatalogFresh(projectRoot, { force: true });
1845
2720
  const app = collectAppsList(projectRoot).find((entry) => entry.id === committedPlan.appId) ?? null;
1846
2721
  const next = {
@@ -1858,6 +2733,7 @@ export async function commitAppAiBuild(projectRoot, sessionId, input = {}, hooks
1858
2733
  ? { pageTitle: committedPlan.pages[0]?.title }
1859
2734
  : { appName: committedPlan.name }),
1860
2735
  audience: committedPlan.audience,
2736
+ filterIds: Array.from(selectedFilterIds),
1861
2737
  tileOverrides,
1862
2738
  },
1863
2739
  warnings: [...session.warnings, ...attachWarnings],
@@ -1920,7 +2796,7 @@ async function routeAppAskQuestion(ctx, appId, input) {
1920
2796
  const tile = dashboard?.layout.items.find((item) => item.i === input.tileId ||
1921
2797
  (input.blockId && item.block && 'blockId' in item.block && item.block.blockId === input.blockId));
1922
2798
  const blockId = cleanString(input.blockId) || (tile?.block && 'blockId' in tile.block ? tile.block.blockId : undefined);
1923
- const appContext = buildAppContextEnvelope(loaded.app, loaded.appDir, {
2799
+ const appContext = buildAppContextEnvelope(ctx.projectRoot, loaded.app, loaded.appDir, {
1924
2800
  dashboardId,
1925
2801
  tileId: cleanString(input.tileId) || undefined,
1926
2802
  blockId,
@@ -2170,17 +3046,80 @@ function questionIsAboutFocusedTile(question) {
2170
3046
  return true;
2171
3047
  }
2172
3048
  /** Map a governed AgentAnswer into the App Copilot's response shape. */
3049
+ /** Overlap needed before a draft is offered as covering the question. */
3050
+ const APP_DRAFT_MATCH_MIN_OVERLAP = 2;
3051
+ const APP_DRAFT_STOPWORDS = new Set([
3052
+ 'the', 'a', 'an', 'and', 'or', 'for', 'with', 'what', 'which', 'who', 'how', 'why', 'is', 'are',
3053
+ 'do', 'does', 'can', 'you', 'me', 'my', 'our', 'this', 'that', 'show', 'give', 'get', 'by', 'of',
3054
+ 'in', 'on', 'to', 'from', 'all', 'complete', 'view',
3055
+ ]);
3056
+ function appDraftTokens(value) {
3057
+ return new Set(value.toLowerCase().split(/[^a-z0-9]+/)
3058
+ .filter((word) => word.length > 2 && !APP_DRAFT_STOPWORDS.has(word)));
3059
+ }
3060
+ /**
3061
+ * The app's own draft that best covers a question the governed loop could not answer.
3062
+ *
3063
+ * Drafts are review-required exploratory analyses saved in the app. They are not
3064
+ * indexed into the manifest, so retrieval cannot find them — but when the loop
3065
+ * has just refused for want of a certified path, the app frequently already
3066
+ * contains a draft that does exactly the thing. Offering it is strictly better
3067
+ * than explaining the gap and stopping, and it never launders trust: the answer
3068
+ * stays review-required and says the source is an unreviewed draft.
3069
+ */
3070
+ export function matchAppDraftForQuestion(drafts, question) {
3071
+ const asked = appDraftTokens(question);
3072
+ if (asked.size === 0)
3073
+ return undefined;
3074
+ let best;
3075
+ for (const draft of drafts ?? []) {
3076
+ const candidate = appDraftTokens([draft.name, draft.question ?? '', draft.description ?? ''].join(' '));
3077
+ let score = 0;
3078
+ for (const token of asked)
3079
+ if (candidate.has(token))
3080
+ score += 1;
3081
+ if (score >= APP_DRAFT_MATCH_MIN_OVERLAP && (!best || score > best.score))
3082
+ best = { draft, score };
3083
+ }
3084
+ return best?.draft;
3085
+ }
2173
3086
  function mapGovernedToAppAsk(governed, input) {
2174
3087
  const isCertified = governed.certification === 'certified' || governed.kind === 'certified';
2175
3088
  const noAnswer = governed.kind === 'no_answer';
2176
- const answer = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
3089
+ const governedText = cleanString(governed.answer) || cleanString(governed.text) || 'No governed answer was available for this question.';
3090
+ // The loop just declined for want of certified evidence. Before returning only
3091
+ // the gap, check whether this app already saved a draft that covers it.
3092
+ const draft = noAnswer ? matchAppDraftForQuestion(input.appContext?.drafts ?? [], input.question) : undefined;
3093
+ // App drafts are often generated FROM a refusal like this one, so their
3094
+ // description can be the same paragraph the loop just returned. Only add it
3095
+ // when it actually says something new.
3096
+ const normalize = (value) => value.toLowerCase().replace(/\s+/g, ' ').trim();
3097
+ const draftAdds = draft?.description && !normalize(governedText).includes(normalize(draft.description).slice(0, 80))
3098
+ ? draft.description
3099
+ : '';
3100
+ const answer = draft
3101
+ ? [
3102
+ governedText,
3103
+ '',
3104
+ `This app already has a saved draft for it: "${draft.name}" (${draft.status}).`,
3105
+ draftAdds,
3106
+ 'It is an unreviewed draft, not a certified answer — open it under Drafts, check the join, grain, and filters, then certify it to make it governed.',
3107
+ ].filter(Boolean).join('\n')
3108
+ : governedText;
2177
3109
  const route = noAnswer ? 'metadata_answer' : isCertified ? 'certified_answer' : 'generated_answer';
2178
- const trustState = noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
3110
+ // Offering a draft is actionable but unreviewed, so it is review_required
3111
+ // rather than the bare draft_ready used when there is nothing to offer.
3112
+ const trustState = draft ? 'review_required' : noAnswer ? 'draft_ready' : isCertified ? 'certified' : 'review_required';
2179
3113
  const govCitations = Array.isArray(governed.citations)
2180
3114
  ? governed.citations.map((c) => ({ kind: String(c.kind ?? 'source'), name: String(c.name ?? '') })).filter((c) => c.name)
2181
3115
  : [];
2182
3116
  const seen = new Set();
2183
- const citations = [...input.citations, ...govCitations].filter((c) => {
3117
+ const citations = [
3118
+ ...input.citations,
3119
+ ...govCitations,
3120
+ // Cite the draft so the user can open the exact file being offered.
3121
+ ...(draft ? [{ kind: 'draft', name: draft.name, path: draft.path }] : []),
3122
+ ].filter((c) => {
2184
3123
  const key = `${c.kind}:${c.name}`;
2185
3124
  if (seen.has(key))
2186
3125
  return false;
@@ -2194,9 +3133,11 @@ function mapGovernedToAppAsk(governed, input) {
2194
3133
  trustState,
2195
3134
  reviewStatus: trustState,
2196
3135
  citations,
2197
- followUps: noAnswer
2198
- ? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
2199
- : ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
3136
+ followUps: draft
3137
+ ? [`Open the "${draft.name}" draft`, 'Review its join and grain', 'Certify it to make it governed']
3138
+ : noAnswer
3139
+ ? ['Name the metric and grain', 'Pick a certified tile', 'Start driver analysis']
3140
+ : ['Investigate drivers', 'Break this down further', 'Create a draft DQL block'],
2200
3141
  decision: buildAppAskDecision(route, { question: input.question, blockId: input.blockId, appName: input.appName }),
2201
3142
  };
2202
3143
  }
@@ -2386,6 +3327,75 @@ export function recommendDashboardTile(projectRoot, appId, dashboardId, input) {
2386
3327
  warnings: recommendation.warnings,
2387
3328
  };
2388
3329
  }
3330
+ /**
3331
+ * Rename an App, and optionally a page inside it.
3332
+ *
3333
+ * `dql.app.json` previously had exactly two writers — create and publish — so
3334
+ * an App could never be renamed after it was built. Only the display `name`
3335
+ * changes here: the App **id** is also its folder name and is referenced by
3336
+ * notebooks, product context, and deep links, so renaming must not move it.
3337
+ *
3338
+ * The write is staged and swapped like the publish path, and guarded by the
3339
+ * caller-supplied fingerprint so a concurrent edit cannot be clobbered.
3340
+ */
3341
+ export function renameApp(projectRoot, appId, input) {
3342
+ const loaded = loadAppById(projectRoot, appId);
3343
+ if (!loaded)
3344
+ return { ok: false, error: `App "${appId}" not found`, code: 'APP_NOT_FOUND' };
3345
+ if (input.expectedFingerprint && fingerprintAppDirectory(loaded.appDir) !== input.expectedFingerprint) {
3346
+ return { ok: false, error: 'This App changed since it was loaded. Refresh and rename again.', code: 'APP_CHANGED' };
3347
+ }
3348
+ const name = cleanString(input.name);
3349
+ const pageTitle = cleanString(input.pageTitle);
3350
+ if (!name && !pageTitle)
3351
+ return { ok: false, error: 'Provide a name or a pageTitle.', code: 'INVALID_REQUEST' };
3352
+ const appPath = join(loaded.appDir, 'dql.app.json');
3353
+ const parsedApp = parseAppDocument(JSON.stringify({ ...loaded.app, ...(name ? { name } : {}) }), appPath);
3354
+ if (!parsedApp.document)
3355
+ return { ok: false, error: parsedApp.errors.map((err) => err.message).join('; ') };
3356
+ const renamedDashboards = [];
3357
+ if (pageTitle) {
3358
+ const homepage = loaded.app.homepage;
3359
+ const dashboardId = cleanString(input.dashboardId)
3360
+ || (homepage?.type === 'dashboard' ? homepage.id : undefined);
3361
+ const source = findDashboardsForApp(loaded.appDir)
3362
+ .map((path) => ({ path, document: loadDashboardDocument(path).document }))
3363
+ .find((item) => item.document && (!dashboardId || item.document.id === dashboardId));
3364
+ if (!source?.document)
3365
+ return { ok: false, error: `Dashboard "${dashboardId ?? ''}" not found in app "${appId}"`, code: 'DASHBOARD_NOT_FOUND' };
3366
+ const next = { ...source.document, metadata: { ...source.document.metadata, title: pageTitle } };
3367
+ const parsed = parseDashboardDocument(JSON.stringify(next), source.path);
3368
+ if (!parsed.document)
3369
+ return { ok: false, error: parsed.errors.map((err) => err.message).join('; ') };
3370
+ renamedDashboards.push({ sourcePath: source.path, document: parsed.document });
3371
+ }
3372
+ const stagingBase = join(projectRoot, '.dql', 'local', 'app-rename-staging');
3373
+ const stagedAppDir = join(stagingBase, `${appId}-next`);
3374
+ const backupAppDir = join(stagingBase, `${appId}-backup`);
3375
+ rmSync(stagedAppDir, { recursive: true, force: true });
3376
+ rmSync(backupAppDir, { recursive: true, force: true });
3377
+ mkdirSync(stagingBase, { recursive: true });
3378
+ cpSync(loaded.appDir, stagedAppDir, { recursive: true });
3379
+ writeFileSync(join(stagedAppDir, 'dql.app.json'), JSON.stringify(parsedApp.document, null, 2) + '\n', 'utf-8');
3380
+ for (const dashboard of renamedDashboards) {
3381
+ writeFileSync(join(stagedAppDir, relative(loaded.appDir, dashboard.sourcePath)), JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
3382
+ }
3383
+ try {
3384
+ renameSync(loaded.appDir, backupAppDir);
3385
+ renameSync(stagedAppDir, loaded.appDir);
3386
+ rmSync(backupAppDir, { recursive: true, force: true });
3387
+ }
3388
+ catch (error) {
3389
+ if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
3390
+ renameSync(backupAppDir, loaded.appDir);
3391
+ throw error;
3392
+ }
3393
+ return {
3394
+ ok: true,
3395
+ app: parsedApp.document,
3396
+ paths: [relative(projectRoot, appPath), ...renamedDashboards.map((item) => relative(projectRoot, item.sourcePath))],
3397
+ };
3398
+ }
2389
3399
  export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2390
3400
  const loaded = loadAppById(projectRoot, appId);
2391
3401
  if (!loaded)
@@ -2393,7 +3403,7 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2393
3403
  const dashboardSources = findDashboardsForApp(loaded.appDir)
2394
3404
  .map((path) => ({ path, document: loadDashboardDocument(path).document }))
2395
3405
  .filter((item) => Boolean(item.document));
2396
- const readiness = appPublicationReadiness(dashboardSources.map((item) => item.document));
3406
+ const readiness = appPublicationReadiness(projectRoot, dashboardSources.map((item) => item.document));
2397
3407
  if (!readiness.ready) {
2398
3408
  return {
2399
3409
  ok: false,
@@ -2456,101 +3466,1449 @@ export function promoteAppForStakeholders(projectRoot, appId, input = {}) {
2456
3466
  writeFileSync(stagedPath, JSON.stringify(dashboard.document, null, 2) + '\n', 'utf-8');
2457
3467
  }
2458
3468
  try {
2459
- renameSync(loaded.appDir, backupAppDir);
2460
- renameSync(stagedAppDir, loaded.appDir);
2461
- rmSync(backupAppDir, { recursive: true, force: true });
3469
+ renameSync(loaded.appDir, backupAppDir);
3470
+ renameSync(stagedAppDir, loaded.appDir);
3471
+ rmSync(backupAppDir, { recursive: true, force: true });
3472
+ }
3473
+ catch (error) {
3474
+ if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
3475
+ renameSync(backupAppDir, loaded.appDir);
3476
+ throw error;
3477
+ }
3478
+ const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
3479
+ return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
3480
+ }
3481
+ export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input) {
3482
+ const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
3483
+ if (!loaded)
3484
+ return { ok: false, error: `Dashboard "${dashboardId}" not found in App "${appId}".` };
3485
+ const currentFingerprint = `sha256:${createHash('sha256').update(JSON.stringify(loaded.dashboard)).digest('hex')}`;
3486
+ if (!input.expectedDashboardFingerprint || input.expectedDashboardFingerprint !== currentFingerprint) {
3487
+ return { ok: false, error: 'The dashboard changed after the reviewed run. Run it again before approval.' };
3488
+ }
3489
+ const selected = new Set(input.tileIds);
3490
+ const semanticIds = new Set(loaded.dashboard.layout.items.filter((item) => item.semantic).map((item) => item.i));
3491
+ const invalid = input.tileIds.filter((tileId) => !semanticIds.has(tileId));
3492
+ if (invalid.length > 0)
3493
+ return { ok: false, error: `Only semantic tiles can be approved here: ${invalid.join(', ')}` };
3494
+ const reviewedAt = new Date().toISOString();
3495
+ const dashboard = {
3496
+ ...loaded.dashboard,
3497
+ layout: {
3498
+ ...loaded.dashboard.layout,
3499
+ items: loaded.dashboard.layout.items.map((item) => {
3500
+ if (!selected.has(item.i) || !item.semantic)
3501
+ return item;
3502
+ return {
3503
+ ...item,
3504
+ semantic: { ...item.semantic, snapshotId: input.snapshotId },
3505
+ review: {
3506
+ status: 'approved',
3507
+ sourceFingerprint: item.semantic.definitionFingerprint,
3508
+ preflightReceiptId: input.runId,
3509
+ reviewedAt,
3510
+ reviewedBy: input.reviewer,
3511
+ },
3512
+ trustState: 'review_required',
3513
+ reviewStatus: 'review_required',
3514
+ };
3515
+ }),
3516
+ },
3517
+ };
3518
+ const parsed = parseDashboardDocument(JSON.stringify(dashboard), loaded.path);
3519
+ if (!parsed.document)
3520
+ return { ok: false, error: parsed.errors.map((error) => error.message).join('; ') };
3521
+ writeFileSync(loaded.path, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
3522
+ return { ok: true, dashboard: parsed.document, path: relative(projectRoot, loaded.path) };
3523
+ }
3524
+ function appPublicationReadiness(projectRoot, dashboards, options = {}) {
3525
+ const blockers = [];
3526
+ let governedTiles = 0;
3527
+ const blocks = collectBlockCandidates(projectRoot, { preserveDuplicates: true });
3528
+ for (const dashboard of dashboards) {
3529
+ for (const item of dashboard.layout.items) {
3530
+ if (item.block) {
3531
+ governedTiles += 1;
3532
+ const blockRef = item.block;
3533
+ const boundRevision = item.sourceRevision ?? item.review?.sourceFingerprint;
3534
+ const block = blocks.find((candidate) => ('blockId' in blockRef
3535
+ ? candidate.id === blockRef.blockId || candidate.name === blockRef.blockId
3536
+ : candidate.path === blockRef.ref));
3537
+ if (!block || block.status !== 'certified') {
3538
+ blockers.push({
3539
+ dashboardId: dashboard.id,
3540
+ tileId: item.i,
3541
+ code: 'block_not_certified',
3542
+ message: `${dashboard.id}/${item.i} no longer resolves to a certified block.`,
3543
+ });
3544
+ }
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) {
3554
+ blockers.push({
3555
+ dashboardId: dashboard.id,
3556
+ tileId: item.i,
3557
+ code: 'block_source_changed',
3558
+ message: `${dashboard.id}/${item.i} changed after it was selected; review the current certified block before publication.`,
3559
+ });
3560
+ }
3561
+ }
3562
+ if (item.draftAnalysis) {
3563
+ blockers.push({
3564
+ dashboardId: dashboard.id,
3565
+ tileId: item.i,
3566
+ code: 'exploratory_source',
3567
+ message: `${dashboard.id}/${item.i} references app-scoped exploratory DQL; promote it to governed semantic logic or a certified block first.`,
3568
+ });
3569
+ }
3570
+ if (item.semantic) {
3571
+ governedTiles += 1;
3572
+ if (item.review?.status !== 'approved') {
3573
+ blockers.push({
3574
+ dashboardId: dashboard.id,
3575
+ tileId: item.i,
3576
+ code: 'semantic_review',
3577
+ message: `${dashboard.id}/${item.i} semantic result has not been explicitly approved.`,
3578
+ });
3579
+ }
3580
+ if (!item.semantic.snapshotId || !item.review?.preflightReceiptId) {
3581
+ blockers.push({
3582
+ dashboardId: dashboard.id,
3583
+ tileId: item.i,
3584
+ code: 'semantic_preflight',
3585
+ message: `${dashboard.id}/${item.i} needs a snapshot-bound semantic preflight receipt.`,
3586
+ });
3587
+ }
3588
+ }
3589
+ if (dashboard.version === 2 && (item.block || item.semantic || item.draftAnalysis)) {
3590
+ for (const filter of dashboard.filters ?? []) {
3591
+ if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
3592
+ continue;
3593
+ const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id);
3594
+ // An explicit unsupported binding is a governed exclusion, not a
3595
+ // missing binding. Studio shows the reason on the affected tile and
3596
+ // the filter deliberately leaves that tile unchanged.
3597
+ if (binding?.capability === 'unsupported' && binding.unsupportedReason?.trim())
3598
+ continue;
3599
+ const settled = options.settledPageIds?.has(dashboard.id) ?? false;
3600
+ if (!binding || (!binding.binding && binding.mode !== 'parameter') || (binding.capability === 'preflight_required' && !settled)) {
3601
+ blockers.push({
3602
+ dashboardId: dashboard.id,
3603
+ tileId: item.i,
3604
+ code: 'filter_not_preflighted',
3605
+ message: `${dashboard.id}/${item.i} does not have a preflighted binding for filter ${filter.label ?? filter.id}.`,
3606
+ });
3607
+ }
3608
+ }
3609
+ }
3610
+ }
3611
+ }
3612
+ return { ready: blockers.length === 0, governedTiles, blockers };
3613
+ }
3614
+ function appAiBuildSessionDir(projectRoot) {
3615
+ return join(projectRoot, '.dql', 'local', 'app-ai-builds');
3616
+ }
3617
+ function appBuildDraftDir(projectRoot) {
3618
+ // Read-only migration location for AppBuildDraft v1 JSON files. New drafts
3619
+ // are persisted transactionally in .dql/local/apps.sqlite.
3620
+ return join(projectRoot, '.dql', 'local', 'app-builds');
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
+ }
4073
+ export function createStoredAppBuildDraft(projectRoot, input) {
4074
+ const baseAppId = cleanString(input.baseAppId);
4075
+ const base = baseAppId ? loadAppById(projectRoot, baseAppId) : null;
4076
+ if (baseAppId && !base)
4077
+ throw new Error(`App "${baseAppId}" not found.`);
4078
+ const name = cleanString(input.name) || base?.app.name || cleanString(input.goal) || 'Untitled App';
4079
+ const appId = base?.app.id || cleanString(input.appId) || suggestAppId(name);
4080
+ const id = `build_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4081
+ const pageId = 'overview';
4082
+ const emptyItems = [];
4083
+ const blankPage = {
4084
+ version: 2,
4085
+ id: pageId,
4086
+ metadata: {
4087
+ title: 'Overview',
4088
+ description: cleanString(input.goal) || `Local draft canvas for ${name}`,
4089
+ domain: cleanString(input.domain) || base?.app.domain || 'general',
4090
+ audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
4091
+ visibility: 'private',
4092
+ lifecycle: 'draft',
4093
+ },
4094
+ layout: {
4095
+ kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems,
4096
+ responsive: {
4097
+ wide: { kind: 'grid', cols: 12, rowHeight: 80, items: emptyItems },
4098
+ medium: { kind: 'grid', cols: 6, rowHeight: 80, items: emptyItems },
4099
+ narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: emptyItems },
4100
+ },
4101
+ },
4102
+ };
4103
+ const basePages = base
4104
+ ? findDashboardsForApp(base.appDir)
4105
+ .map((path) => loadDashboardDocument(path).document)
4106
+ .filter((page) => Boolean(page))
4107
+ : [];
4108
+ const template = input.template ?? 'blank';
4109
+ const published = rehydratePublishedAppSources(basePages);
4110
+ const pages = published.pages.length ? published.pages : [applyAppStudioTemplate(blankPage, template, name)];
4111
+ const draft = createAppBuildDraft({
4112
+ id,
4113
+ appId,
4114
+ name,
4115
+ ...(base ? { baseApp: { appId: base.app.id, fingerprint: fingerprintAppDirectory(base.appDir) } } : {}),
4116
+ authoringMode: input.authoringMode === 'manual' ? 'manual' : 'ai',
4117
+ template,
4118
+ sourcePolicy: published.requiresReviewPolicy ? 'include_review_required' : input.sourcePolicy,
4119
+ frame: {
4120
+ goal: cleanString(input.goal) || `Create ${name}`,
4121
+ audience: cleanString(input.audience) || base?.app.audience || 'stakeholders',
4122
+ metrics: [], dimensions: [], filters: [], desiredOutput: 'One responsive analytical page',
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: [],
4127
+ },
4128
+ sources: published.sources,
4129
+ pages,
4130
+ reviewTasks: published.reviewTasks,
4131
+ });
4132
+ writeStoredAppBuildDraft(projectRoot, draft);
4133
+ return draft;
4134
+ }
4135
+ /**
4136
+ * Adds an Ask result to the same local AppBuildDraft used by manual and AI
4137
+ * authoring. Certified answers retain their block identity. Exploratory DQL is
4138
+ * materialized as an app-scoped local artifact and remains review-required;
4139
+ * SQL is never embedded in the dashboard JSON or written to Project source.
4140
+ */
4141
+ export function addAskResultToAppBuildDraft(projectRoot, draftId, input) {
4142
+ const draft = loadStoredAppBuildDraft(projectRoot, draftId);
4143
+ if (!draft)
4144
+ throw new Error('App Build Draft not found.');
4145
+ if (input.expectedRevision !== draft.revision || input.expectedProposalHash !== draft.proposalHash) {
4146
+ throw new Error('APP_BUILD_REVISION_CONFLICT: refresh the local draft before adding this Ask result.');
4147
+ }
4148
+ const page = draft.pages.find((candidate) => candidate.id === cleanString(input.pageId)) ?? draft.pages[0];
4149
+ if (!page)
4150
+ throw new Error('The local App draft needs a page before adding an Ask result.');
4151
+ const question = cleanString(input.question).slice(0, 500);
4152
+ const answer = cleanString(input.answer).slice(0, 40_000);
4153
+ const title = (cleanString(input.title) || titleForGapQuestion(question || 'Ask result')).slice(0, 120);
4154
+ const requestedBlockId = cleanString(input.certifiedBlockId);
4155
+ const dqlSource = cleanString(input.dqlSource);
4156
+ const sql = cleanString(input.sql);
4157
+ if (dqlSource.length > 400_000 || sql.length > 200_000)
4158
+ throw new Error('Ask result source exceeds the local App artifact limit.');
4159
+ const sourceIdentity = requestedBlockId || dqlSource || sql || `${question}\n${answer}`;
4160
+ if (!sourceIdentity)
4161
+ throw new Error('The Ask result did not include reusable evidence.');
4162
+ const identity = createHash('sha256').update(sourceIdentity).digest('hex').slice(0, 16);
4163
+ const tileId = `ask-${identity}`;
4164
+ if (draft.pages.some((candidate) => candidate.layout.items.some((tile) => tile.i === tileId))) {
4165
+ return { ok: true, draft, pageId: page.id, tileId, deduped: true };
4166
+ }
4167
+ const nextY = page.layout.items.reduce((max, tile) => Math.max(max, tile.y + tile.h), 0);
4168
+ const viz = normalizeGeneratedViz(input.visualization);
4169
+ const operations = [];
4170
+ let artifactPath;
4171
+ let artifactWasCreated = false;
4172
+ let tile;
4173
+ if (requestedBlockId) {
4174
+ const block = collectBlockCandidates(projectRoot).find((candidate) => candidate.status === 'certified'
4175
+ && (candidate.id === requestedBlockId || candidate.name === requestedBlockId || candidate.path === requestedBlockId));
4176
+ if (!block)
4177
+ throw new Error(`Certified block "${requestedBlockId}" is no longer available.`);
4178
+ const sourceId = `block:${block.id}`;
4179
+ operations.push({
4180
+ type: 'upsert_source',
4181
+ source: {
4182
+ id: sourceId,
4183
+ kind: 'certified_block',
4184
+ sourceRef: block.id,
4185
+ sourceFingerprint: block.fingerprint,
4186
+ trustState: 'certified',
4187
+ reviewStatus: 'not_required',
4188
+ },
4189
+ });
4190
+ tile = {
4191
+ i: tileId, x: 0, y: nextY, w: 6, h: 4,
4192
+ block: { blockId: block.id },
4193
+ viz: { type: viz === 'table' ? normalizeVizType(block.chartType) : viz },
4194
+ title,
4195
+ sourceClass: 'certified_block',
4196
+ filterBindings: block.filterIds.map((filter) => ({ filter, binding: filter, mode: 'predicate', capability: 'preflight_required' })),
4197
+ sourceEvidence: [{ source: block.id, reason: question ? `Ask answer for: ${question}` : 'Added from a certified Ask answer.', kind: 'certified_block', trustState: 'certified' }],
4198
+ review: { status: 'not_required', sourceFingerprint: block.fingerprint },
4199
+ trustState: 'certified',
4200
+ reviewStatus: 'certified',
4201
+ };
4202
+ }
4203
+ else if (dqlSource || sql) {
4204
+ const sourceId = `review-dql:ask-${identity}`;
4205
+ const sourceRef = `analysis/ask-${identity}.dql`;
4206
+ const source = dqlSource || [
4207
+ `block "ask_${identity}" {`,
4208
+ ` domain = "${escapeDqlString(page.metadata.domain ?? 'general')}"`,
4209
+ ' type = "custom"',
4210
+ ' status = "review"',
4211
+ ' owner = "local-ask"',
4212
+ ` description = "${escapeDqlString(answer || question || title)}"`,
4213
+ question ? ` llmContext = "${escapeDqlString(`Ask result: ${question}`)}"` : '',
4214
+ ' caveats = ["Ask-generated local analysis. Validate joins, grain, filters, and interpretation before promotion."]',
4215
+ ' tags = ["app-scoped", "ask-generated", "needs-review"]',
4216
+ '',
4217
+ ' query = """',
4218
+ sql,
4219
+ ' """',
4220
+ '',
4221
+ ' visualization {',
4222
+ ` chart = "${escapeDqlString(normalizeGeneratedViz(input.visualization))}"`,
4223
+ ' }',
4224
+ '}',
4225
+ '',
4226
+ ].filter(Boolean).join('\n');
4227
+ const fingerprint = `sha256:${createHash('sha256').update(source).digest('hex')}`;
4228
+ artifactPath = join(projectRoot, '.dql', 'local', 'app-builds', draft.id, sourceRef);
4229
+ operations.push(...(draft.sourcePolicy === 'include_review_required'
4230
+ ? []
4231
+ : [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]), {
4232
+ type: 'upsert_source',
4233
+ source: {
4234
+ id: sourceId,
4235
+ kind: 'review_dql',
4236
+ sourceRef,
4237
+ qualifiedIdentity: title,
4238
+ sourceFingerprint: fingerprint,
4239
+ trustState: 'review_required',
4240
+ reviewStatus: 'required',
4241
+ },
4242
+ }, {
4243
+ type: 'set_review_task',
4244
+ task: {
4245
+ id: `review-ask-${identity}`,
4246
+ message: `Validate the joins, grain, filters, and business interpretation for ${title}.`,
4247
+ status: 'open',
4248
+ sourceId,
4249
+ pageId: page.id,
4250
+ tileId,
4251
+ },
4252
+ });
4253
+ tile = {
4254
+ i: tileId, x: 0, y: nextY, w: 6, h: 4,
4255
+ draftAnalysis: { ref: sourceRef, artifactFingerprint: fingerprint },
4256
+ viz: { type: viz },
4257
+ title,
4258
+ sourceClass: 'exploratory_analysis',
4259
+ sourceEvidence: [{ source: sourceRef, reason: question ? `Ask generated this private analysis for: ${question}` : 'Ask generated this private analysis.', kind: 'review_dql', trustState: 'review_required' }],
4260
+ review: { status: 'required', sourceFingerprint: fingerprint },
4261
+ trustState: 'review_required',
4262
+ reviewStatus: 'review_required',
4263
+ };
4264
+ if (!existsSync(artifactPath)) {
4265
+ mkdirSync(dirname(artifactPath), { recursive: true });
4266
+ writeFileSync(artifactPath, source, 'utf-8');
4267
+ artifactWasCreated = true;
4268
+ }
4269
+ }
4270
+ else {
4271
+ const sourceId = `text:ask-${identity}`;
4272
+ operations.push(...(draft.sourcePolicy === 'include_review_required'
4273
+ ? []
4274
+ : [{ type: 'set_source_policy', sourcePolicy: 'include_review_required' }]), { type: 'upsert_source', source: { id: sourceId, kind: 'text', sourceRef: `ask:${identity}`, trustState: 'review_required', reviewStatus: 'required' } }, { type: 'set_review_task', task: { id: `review-ask-${identity}`, message: `Review the Ask narrative ${title} before publication.`, status: 'open', sourceId, pageId: page.id, tileId } });
4275
+ tile = {
4276
+ i: tileId, x: 0, y: nextY, w: 12, h: 2,
4277
+ text: { markdown: answer || question },
4278
+ viz: { type: 'text' },
4279
+ title,
4280
+ sourceClass: 'narrative',
4281
+ sourceEvidence: [{ source: `ask:${identity}`, reason: 'Saved from Ask as an editable review-required narrative.', kind: 'text', trustState: 'review_required' }],
4282
+ review: { status: 'required' },
4283
+ trustState: 'review_required',
4284
+ reviewStatus: 'review_required',
4285
+ };
4286
+ }
4287
+ operations.push({ type: 'add_tile', pageId: page.id, tile });
4288
+ try {
4289
+ const next = applyAppBuildDraftOperations(draft, draft.revision, operations);
4290
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision, operations });
4291
+ return { ok: true, draft: next, pageId: page.id, tileId, deduped: false };
4292
+ }
4293
+ catch (error) {
4294
+ if (artifactWasCreated && artifactPath)
4295
+ rmSync(artifactPath, { force: true });
4296
+ throw error;
4297
+ }
4298
+ }
4299
+ function applyAppStudioTemplate(page, template, appName) {
4300
+ if (template === 'blank')
4301
+ return page;
4302
+ const intro = template === 'executive_brief'
4303
+ ? `# ${appName}\n\nState the decision this brief supports, then add governed evidence below.`
4304
+ : template === 'operational_dashboard'
4305
+ ? `# ${appName}\n\nMonitor current performance, changes, and the details that require action.`
4306
+ : `# ${appName}\n\nDocument the question, findings, caveats, and evidence for this investigation.`;
4307
+ const items = [{
4308
+ i: 'template-introduction', x: 0, y: 0, w: 12, h: 2,
4309
+ title: template === 'investigation' ? 'Investigation question' : 'Executive context',
4310
+ sectionId: 'exec_summary',
4311
+ text: { markdown: intro },
4312
+ viz: { type: 'text' },
4313
+ sourceClass: 'narrative',
4314
+ trustState: 'draft_ready',
4315
+ reviewStatus: 'draft_ready',
4316
+ }];
4317
+ const sections = template === 'investigation'
4318
+ ? [
4319
+ { id: 'exec_summary', title: 'Question', kind: 'exec_summary', order: 0 },
4320
+ { id: 'insight', title: 'Findings and comparisons', kind: 'insight', order: 1 },
4321
+ { id: 'appendix', title: 'Caveats and evidence', kind: 'appendix', order: 2 },
4322
+ ]
4323
+ : [
4324
+ { id: 'exec_summary', title: 'Executive summary', kind: 'exec_summary', order: 0 },
4325
+ { id: 'kpi_band', title: 'Key metrics', kind: 'kpi_band', order: 1 },
4326
+ { id: 'insight', title: template === 'executive_brief' ? 'Decision evidence' : 'Trends and drivers', kind: 'insight', order: 2 },
4327
+ { id: 'appendix', title: 'Detail and evidence', kind: 'appendix', order: 3 },
4328
+ ];
4329
+ return {
4330
+ ...page,
4331
+ sections,
4332
+ layout: {
4333
+ ...page.layout,
4334
+ items,
4335
+ responsive: responsiveLayoutsForDashboardItems(items),
4336
+ },
4337
+ };
4338
+ }
4339
+ function loadStoredAppBuildDraft(projectRoot, id) {
4340
+ const clean = cleanString(id);
4341
+ if (!clean || !/^[a-z0-9_:-]+$/i.test(clean))
4342
+ return null;
4343
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4344
+ try {
4345
+ const stored = storage.getAppBuildDraft(clean);
4346
+ if (stored) {
4347
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, stored);
4348
+ if (migrated && stored.version !== migrated.version)
4349
+ storage.saveAppBuildDraft(migrated);
4350
+ return migrated;
4351
+ }
4352
+ const legacyPath = join(appBuildDraftDir(projectRoot), `${clean}.json`);
4353
+ if (!existsSync(legacyPath))
4354
+ return null;
4355
+ const migrated = migrateLegacyAppBuildDraft(projectRoot, JSON.parse(readFileSync(legacyPath, 'utf-8')));
4356
+ if (!migrated)
4357
+ return null;
4358
+ storage.saveAppBuildDraft(migrated);
4359
+ return migrated;
4360
+ }
4361
+ catch {
4362
+ return null;
4363
+ }
4364
+ finally {
4365
+ storage.close();
4366
+ }
4367
+ }
4368
+ export function writeStoredAppBuildDraft(projectRoot, draft, input = {}) {
4369
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4370
+ try {
4371
+ storage.saveAppBuildDraft(draft, input);
4372
+ }
4373
+ finally {
4374
+ storage.close();
4375
+ }
4376
+ }
4377
+ function migrateLegacyAppBuildDraft(projectRoot, value) {
4378
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4379
+ return null;
4380
+ const legacy = value;
4381
+ if (legacy.version === 3)
4382
+ return legacy;
4383
+ if (legacy.version === 2)
4384
+ return migrateV2AppBuildDraft(projectRoot, legacy);
4385
+ if (legacy.version !== 1 || typeof legacy.id !== 'string' || typeof legacy.appId !== 'string')
4386
+ return null;
4387
+ const frame = (legacy.frame && typeof legacy.frame === 'object' ? legacy.frame : {});
4388
+ const clarificationQuestions = Array.isArray(frame.clarificationQuestions)
4389
+ ? frame.clarificationQuestions.flatMap((raw) => {
4390
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
4391
+ return [];
4392
+ const question = raw;
4393
+ if (typeof question.id !== 'string' || typeof question.question !== 'string')
4394
+ return [];
4395
+ const choices = Array.isArray(question.choices)
4396
+ ? question.choices.map((choice, index) => typeof choice === 'string'
4397
+ ? { id: slugify(choice) || `choice-${index + 1}`, label: choice }
4398
+ : choice).filter((choice) => Boolean(choice && typeof choice === 'object' && 'id' in choice && 'label' in choice))
4399
+ : [];
4400
+ return [{ id: question.id, question: question.question, choices, required: question.required !== false }];
4401
+ })
4402
+ : [];
4403
+ const migrated = createAppBuildDraft({
4404
+ id: legacy.id,
4405
+ appId: legacy.appId,
4406
+ name: typeof legacy.name === 'string' ? legacy.name : (typeof frame.goal === 'string' ? frame.goal : legacy.appId),
4407
+ authoringMode: legacy.authoringMode === 'manual' ? 'manual' : 'ai',
4408
+ sourcePolicy: legacy.sourcePolicy === 'include_review_required' ? 'include_review_required' : 'governed_only',
4409
+ frame: {
4410
+ goal: typeof frame.goal === 'string' ? frame.goal : `Create ${legacy.appId}`,
4411
+ decision: typeof frame.decision === 'string' ? frame.decision : undefined,
4412
+ audience: typeof frame.audience === 'string' ? frame.audience : undefined,
4413
+ metrics: Array.isArray(frame.metrics) ? frame.metrics.filter((item) => typeof item === 'string') : [],
4414
+ dimensions: Array.isArray(frame.dimensions) ? frame.dimensions.filter((item) => typeof item === 'string') : [],
4415
+ filters: Array.isArray(frame.filters) ? frame.filters.filter((item) => typeof item === 'string') : [],
4416
+ desiredOutput: typeof frame.desiredOutput === 'string' ? frame.desiredOutput : undefined,
4417
+ clarificationQuestions,
4418
+ },
4419
+ sources: Array.isArray(legacy.sources) ? legacy.sources : [],
4420
+ pages: Array.isArray(legacy.pages) ? legacy.pages : [],
4421
+ reviewTasks: Array.isArray(legacy.reviewTasks) ? legacy.reviewTasks : [],
4422
+ now: typeof legacy.createdAt === 'string' ? legacy.createdAt : undefined,
4423
+ });
4424
+ return {
4425
+ ...migrated,
4426
+ revision: typeof legacy.revision === 'number' ? legacy.revision : migrated.revision,
4427
+ updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : migrated.updatedAt,
4428
+ };
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
+ }
4552
+ export function deleteStoredAppBuildDraft(projectRoot, draft) {
4553
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4554
+ let deleted;
4555
+ try {
4556
+ deleted = storage.deleteAppBuildDraft(draft.id);
4557
+ }
4558
+ finally {
4559
+ storage.close();
4560
+ }
4561
+ if (!deleted)
4562
+ throw new Error(`App Build Draft not found: ${draft.id}`);
4563
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
4564
+ const recoveryId = `draft-${stamp}-${draft.id}`;
4565
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
4566
+ const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', draft.id);
4567
+ const recoveryArtifactsDir = join(recoveryDir, 'artifacts');
4568
+ let artifactsMoved = false;
4569
+ try {
4570
+ mkdirSync(recoveryDir, { recursive: true });
4571
+ if (existsSync(localArtifactsDir)) {
4572
+ renameSync(localArtifactsDir, recoveryArtifactsDir);
4573
+ artifactsMoved = true;
4574
+ }
4575
+ writeFileSync(join(recoveryDir, 'recovery.json'), JSON.stringify({
4576
+ version: 1,
4577
+ kind: 'app_build_draft',
4578
+ recoveryId,
4579
+ deletedAt: new Date().toISOString(),
4580
+ draft: deleted.draft,
4581
+ operations: deleted.operations,
4582
+ }, null, 2) + '\n', 'utf-8');
4583
+ }
4584
+ catch (error) {
4585
+ if (artifactsMoved && existsSync(recoveryArtifactsDir)) {
4586
+ mkdirSync(dirname(localArtifactsDir), { recursive: true });
4587
+ renameSync(recoveryArtifactsDir, localArtifactsDir);
4588
+ }
4589
+ const restore = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4590
+ try {
4591
+ restore.restoreAppBuildDraft(deleted.draft, deleted.operations);
4592
+ }
4593
+ finally {
4594
+ restore.close();
4595
+ }
4596
+ rmSync(recoveryDir, { recursive: true, force: true });
4597
+ throw error;
4598
+ }
4599
+ return { ok: true, id: draft.id, recoveryId, recoverableUntilRestart: false };
4600
+ }
4601
+ function restoreStoredAppBuildDraft(projectRoot, recoveryId) {
4602
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', recoveryId);
4603
+ const recoveryPath = join(recoveryDir, 'recovery.json');
4604
+ if (!existsSync(recoveryPath))
4605
+ return { ok: false, status: 404, error: 'App recovery bundle not found.' };
4606
+ let payload;
4607
+ try {
4608
+ payload = JSON.parse(readFileSync(recoveryPath, 'utf-8'));
4609
+ }
4610
+ catch {
4611
+ return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
4612
+ }
4613
+ if (payload.kind !== 'app_build_draft' || !payload.draft)
4614
+ return { ok: false, status: 400, error: 'App draft recovery bundle is invalid.' };
4615
+ const recoveredArtifactsDir = join(recoveryDir, 'artifacts');
4616
+ const localArtifactsDir = join(projectRoot, '.dql', 'local', 'app-builds', payload.draft.id);
4617
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
4618
+ let artifactsRestored = false;
4619
+ try {
4620
+ if (storage.getAppBuildDraft(payload.draft.id))
4621
+ return { ok: false, status: 409, error: `App Build Draft already exists: ${payload.draft.id}` };
4622
+ if (existsSync(localArtifactsDir))
4623
+ return { ok: false, status: 409, error: `App Build Draft artifacts already exist: ${payload.draft.id}` };
4624
+ if (existsSync(recoveredArtifactsDir)) {
4625
+ mkdirSync(dirname(localArtifactsDir), { recursive: true });
4626
+ renameSync(recoveredArtifactsDir, localArtifactsDir);
4627
+ artifactsRestored = true;
4628
+ }
4629
+ storage.restoreAppBuildDraft(payload.draft, payload.operations ?? []);
2462
4630
  }
2463
4631
  catch (error) {
2464
- if (existsSync(backupAppDir) && !existsSync(loaded.appDir))
2465
- renameSync(backupAppDir, loaded.appDir);
4632
+ if (artifactsRestored && existsSync(localArtifactsDir))
4633
+ renameSync(localArtifactsDir, recoveredArtifactsDir);
2466
4634
  throw error;
2467
4635
  }
2468
- const paths = [relative(projectRoot, appPath), ...dashboards.map((dashboard) => relative(projectRoot, dashboard.sourcePath))];
2469
- return { ok: true, app: parsedApp.document, paths, removedLocalTiles, readiness };
4636
+ finally {
4637
+ storage.close();
4638
+ }
4639
+ rmSync(recoveryDir, { recursive: true, force: true });
4640
+ return { ok: true, id: payload.draft.id, restoredPath: '.dql/local/apps.sqlite', draft: payload.draft };
2470
4641
  }
2471
- export function approveAppSemanticTiles(projectRoot, appId, dashboardId, input) {
2472
- const loaded = loadDashboardForApp(projectRoot, appId, dashboardId);
2473
- if (!loaded)
2474
- return { ok: false, error: `Dashboard "${dashboardId}" not found in App "${appId}".` };
2475
- const currentFingerprint = `sha256:${createHash('sha256').update(JSON.stringify(loaded.dashboard)).digest('hex')}`;
2476
- if (!input.expectedDashboardFingerprint || input.expectedDashboardFingerprint !== currentFingerprint) {
2477
- return { ok: false, error: 'The dashboard changed after the reviewed run. Run it again before approval.' };
4642
+ export function preflightStoredAppBuildDraft(projectRoot, draft) {
4643
+ const errors = [];
4644
+ const blocks = collectBlockCandidates(projectRoot);
4645
+ if (draft.state === 'clarification_required')
4646
+ errors.push('Resolve required Build Frame clarifications before publication.');
4647
+ if (draft.pages.length === 0)
4648
+ errors.push('App Build Draft needs at least one page.');
4649
+ for (const requirement of draft.requirements.filter((item) => appBuildRequirementBlocksPublication(draft, item))) {
4650
+ const coverage = draft.coverage.find((item) => item.requirementId === requirement.id);
4651
+ if (!coverage || coverage.status !== 'covered')
4652
+ errors.push(`Required App question is unresolved: ${requirement.question}`);
4653
+ }
4654
+ for (const task of draft.reviewTasks.filter((item) => appBuildReviewTaskBlocksPublication(draft, item))) {
4655
+ errors.push(`Review task is still open: ${task.message}`);
4656
+ }
4657
+ if (draft.baseApp) {
4658
+ const base = loadAppById(projectRoot, draft.baseApp.appId);
4659
+ if (!base || fingerprintAppDirectory(base.appDir) !== draft.baseApp.fingerprint) {
4660
+ errors.push('The published App changed after this local draft was created. Refresh the draft before publishing.');
4661
+ }
4662
+ }
4663
+ for (const page of draft.pages) {
4664
+ const parsed = parseDashboardDocument(JSON.stringify(page), `<app-build:${draft.id}/${page.id}>`);
4665
+ if (!parsed.document)
4666
+ errors.push(...parsed.errors.map((error) => error.message));
4667
+ }
4668
+ const analysisPages = draft.pages.filter((page) => page.layout.items.some((tile) => !tile.text && !tile.aiPin));
4669
+ const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
4670
+ for (const page of analysisPages) {
4671
+ const receipt = receipts.find((candidate) => candidate.pageId === page.id);
4672
+ if (!receipt || receipt.revision !== draft.revision)
4673
+ errors.push(`Run a current settled preview for page ${page.metadata.title || page.id} before Project publication.`);
4674
+ }
4675
+ for (const source of draft.sources) {
4676
+ if (source.kind === 'block' || source.kind === 'certified_block') {
4677
+ const block = blocks.find((candidate) => candidate.id === source.sourceRef || candidate.name === source.sourceRef || candidate.path === source.sourceRef);
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
+ }
4693
+ }
4694
+ if (source.kind === 'governed_semantic' || source.kind === 'semantic_query') {
4695
+ if (source.reviewStatus !== 'approved' || !source.snapshotId || !source.receiptId) {
4696
+ errors.push(`Source ${source.id} requires snapshot-bound semantic approval before Project publication.`);
4697
+ }
4698
+ }
4699
+ if ((source.kind === 'block' && source.trustState !== 'certified') || source.kind === 'review_block' || source.kind === 'review_dql' || source.kind === 'exploratory_sql') {
4700
+ errors.push(`Source ${source.id} is review-required and cannot be published to the Project.`);
4701
+ }
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') {
4703
+ errors.push(`Source ${source.id} requires include_review_required policy.`);
4704
+ }
2478
4705
  }
2479
- const selected = new Set(input.tileIds);
2480
- const semanticIds = new Set(loaded.dashboard.layout.items.filter((item) => item.semantic).map((item) => item.i));
2481
- const invalid = input.tileIds.filter((tileId) => !semanticIds.has(tileId));
2482
- if (invalid.length > 0)
2483
- return { ok: false, error: `Only semantic tiles can be approved here: ${invalid.join(', ')}` };
2484
- const reviewedAt = new Date().toISOString();
2485
- const dashboard = {
2486
- ...loaded.dashboard,
2487
- layout: {
2488
- ...loaded.dashboard.layout,
2489
- items: loaded.dashboard.layout.items.map((item) => {
2490
- if (!selected.has(item.i) || !item.semantic)
2491
- return item;
2492
- return {
2493
- ...item,
2494
- semantic: { ...item.semantic, snapshotId: input.snapshotId },
2495
- review: {
2496
- status: 'approved',
2497
- sourceFingerprint: item.semantic.definitionFingerprint,
2498
- preflightReceiptId: input.runId,
2499
- reviewedAt,
2500
- reviewedBy: input.reviewer,
2501
- },
2502
- trustState: 'review_required',
2503
- reviewStatus: 'review_required',
2504
- };
2505
- }),
4706
+ const settledPageIds = new Set(receipts.filter((receipt) => receipt.revision === draft.revision).map((receipt) => receipt.pageId));
4707
+ const readiness = appPublicationReadiness(projectRoot, draft.pages, { settledPageIds });
4708
+ errors.push(...readiness.blockers.map((blocker) => blocker.message));
4709
+ return Array.from(new Set(errors));
4710
+ }
4711
+ function preflightAppBuildRequestErrors(projectRoot, draft, verifyPreview) {
4712
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
4713
+ const analysisPages = draft.pages
4714
+ .map((page) => ({
4715
+ pageId: page.id,
4716
+ tileIds: page.layout.items.filter((tile) => !tile.text && !tile.aiPin).map((tile) => tile.i),
4717
+ }))
4718
+ .filter((page) => page.tileIds.length > 0);
4719
+ const receipts = draft.previewReceipts ?? (draft.previewReceipt ? [draft.previewReceipt] : []);
4720
+ if (analysisPages.length > 0 && !verifyPreview) {
4721
+ errors.push('The runtime cannot verify the settled App preview receipts.');
4722
+ }
4723
+ for (const page of analysisPages) {
4724
+ const receipt = receipts.find((candidate) => candidate.pageId === page.pageId);
4725
+ if (!receipt || !verifyPreview)
4726
+ continue;
4727
+ const verified = verifyPreview({
4728
+ runId: receipt.id,
4729
+ draftId: draft.id,
4730
+ dashboardId: page.pageId,
4731
+ tileIds: page.tileIds,
4732
+ });
4733
+ if (!verified.ok
4734
+ || verified.snapshotId !== receipt.snapshotId
4735
+ || verified.resultFingerprint !== receipt.resultFingerprint) {
4736
+ errors.push(verified.ok
4737
+ ? `The saved preview receipt for ${page.pageId} no longer matches the settled run.`
4738
+ : verified.error);
4739
+ }
4740
+ }
4741
+ return Array.from(new Set(errors));
4742
+ }
4743
+ function appBuildRequirementBlocksPublication(draft, requirement) {
4744
+ if (!requirement.required)
4745
+ return false;
4746
+ if (draft.authoringMode === 'manual' && draft.frame.metrics.length === 0 && requirement.measures.length === 0)
4747
+ return false;
4748
+ return true;
4749
+ }
4750
+ function appBuildReviewTaskBlocksPublication(draft, task) {
4751
+ if (task.status !== 'open')
4752
+ return false;
4753
+ if (task.sourceId && draft.sources.some((source) => source.id === task.sourceId))
4754
+ return true;
4755
+ if (task.tileId && draft.pages.some((page) => page.layout.items.some((tile) => tile.i === task.tileId)))
4756
+ return true;
4757
+ if (task.pageId && draft.pages.some((page) => page.id === task.pageId))
4758
+ return true;
4759
+ // v2 AI plans previously persisted general guidance as open tasks without an
4760
+ // attached source, page, or tile. Keep those reminders readable, but do not
4761
+ // turn them into an unexplained hard publication gate.
4762
+ return false;
4763
+ }
4764
+ function hasCurrentAppBuildPreflightReceipt(draft) {
4765
+ return Boolean(draft.preflightReceipt
4766
+ && draft.preflightReceipt.revision === draft.revision
4767
+ && draft.preflightReceipt.proposalHash === draft.proposalHash
4768
+ && draft.preflightReceipt.sourceFingerprint === appBuildSourceFingerprint(draft));
4769
+ }
4770
+ export function markStoredAppBuildDraftPreflighted(projectRoot, draft) {
4771
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
4772
+ if (errors.length)
4773
+ throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
4774
+ const now = new Date().toISOString();
4775
+ const next = {
4776
+ ...draft,
4777
+ state: 'preflight_ready',
4778
+ preflightReceipt: {
4779
+ id: `app_preflight_${createHash('sha256').update(`${draft.id}:${draft.revision}:${draft.proposalHash}`).digest('hex').slice(0, 16)}`,
4780
+ revision: draft.revision,
4781
+ proposalHash: draft.proposalHash,
4782
+ sourceFingerprint: appBuildSourceFingerprint(draft),
4783
+ createdAt: now,
2506
4784
  },
4785
+ updatedAt: now,
2507
4786
  };
2508
- const parsed = parseDashboardDocument(JSON.stringify(dashboard), loaded.path);
2509
- if (!parsed.document)
2510
- return { ok: false, error: parsed.errors.map((error) => error.message).join('; ') };
2511
- writeFileSync(loaded.path, JSON.stringify(parsed.document, null, 2) + '\n', 'utf-8');
2512
- return { ok: true, dashboard: parsed.document, path: relative(projectRoot, loaded.path) };
4787
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
4788
+ return next;
2513
4789
  }
2514
- function appPublicationReadiness(dashboards) {
2515
- const blockers = [];
2516
- let governedTiles = 0;
2517
- for (const dashboard of dashboards) {
2518
- for (const item of dashboard.layout.items) {
2519
- if (item.block)
2520
- governedTiles += 1;
2521
- if (item.draftAnalysis) {
2522
- blockers.push({
2523
- dashboardId: dashboard.id,
2524
- tileId: item.i,
2525
- code: 'exploratory_source',
2526
- message: `${dashboard.id}/${item.i} references app-scoped exploratory DQL; promote it to governed semantic logic or a certified block first.`,
2527
- });
4790
+ function appBuildSourceFingerprint(draft) {
4791
+ const sources = [...draft.sources]
4792
+ .sort((left, right) => left.id.localeCompare(right.id))
4793
+ .map((source) => ({
4794
+ id: source.id,
4795
+ kind: source.kind,
4796
+ sourceRef: source.sourceRef,
4797
+ snapshotId: source.snapshotId,
4798
+ sourceFingerprint: source.sourceFingerprint,
4799
+ receiptId: source.receiptId,
4800
+ reviewStatus: source.reviewStatus,
4801
+ }));
4802
+ return `sha256:${createHash('sha256').update(JSON.stringify(sources)).digest('hex')}`;
4803
+ }
4804
+ export function publishStoredAppBuildDraft(projectRoot, draft) {
4805
+ const errors = preflightStoredAppBuildDraft(projectRoot, draft);
4806
+ if (errors.length)
4807
+ throw new Error(`App Build preflight failed: ${errors.join(' ')}`);
4808
+ if (!hasCurrentAppBuildPreflightReceipt(draft)) {
4809
+ throw new Error('App Build Draft needs a current preflight receipt before publication.');
4810
+ }
4811
+ const destination = resolveAppPackageDir(projectRoot, draft.appId);
4812
+ if (!draft.baseApp && existsSync(destination))
4813
+ throw new Error(`App already exists: ${draft.appId}`);
4814
+ if (draft.baseApp && (!existsSync(destination) || draft.baseApp.appId !== draft.appId)) {
4815
+ throw new Error('Published App base no longer exists at the expected path.');
4816
+ }
4817
+ const stage = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-${Date.now()}`);
4818
+ const dashboardDir = join(stage, 'dashboards');
4819
+ const owner = `${process.env.USER ?? 'owner'}@local`;
4820
+ const domain = draft.pages[0]?.metadata.domain || 'general';
4821
+ const name = draft.name.trim() || draft.appId;
4822
+ const containsSemantic = draft.sources.some((source) => source.kind === 'governed_semantic' || source.kind === 'semantic_query');
4823
+ const hasCertifiedAnalysis = draft.sources.some((source) => (source.kind === 'block' || source.kind === 'certified_block') && source.trustState === 'certified');
4824
+ const lifecycle = hasCertifiedAnalysis && !containsSemantic ? 'certified' : 'review';
4825
+ const app = {
4826
+ version: 1,
4827
+ id: draft.appId,
4828
+ name,
4829
+ description: draft.frame.decision || `Governed analytical App for ${name}`,
4830
+ visibility: 'shared',
4831
+ publicationIntent: 'shared_project',
4832
+ ownerDomain: domain,
4833
+ usesDomains: [domain],
4834
+ requiredExports: [],
4835
+ domain,
4836
+ audience: draft.frame.audience,
4837
+ lifecycle,
4838
+ owners: [owner],
4839
+ members: [{ userId: owner, displayName: owner, roles: ['owner', 'analyst'] }],
4840
+ roles: [
4841
+ { id: 'owner', displayName: 'Owner' },
4842
+ { id: 'analyst', displayName: 'Analyst' },
4843
+ { id: 'viewer', displayName: 'Viewer' },
4844
+ ],
4845
+ policies: [
4846
+ { id: 'viewers-read', domain, minClassification: 'internal', allowedRoles: ['viewer', 'analyst', 'owner'], accessLevel: 'read', enabled: true },
4847
+ { id: 'analyst-execute', domain, minClassification: 'internal', allowedRoles: ['analyst', 'owner'], accessLevel: 'execute', enabled: true },
4848
+ { id: 'owner-admin', domain, minClassification: 'restricted', allowedRoles: ['owner'], accessLevel: 'admin', enabled: true },
4849
+ ],
4850
+ rlsBindings: [],
4851
+ schedules: [],
4852
+ homepage: { type: 'dashboard', id: draft.pages[0].id },
4853
+ };
4854
+ const committedPages = draft.pages.map((page) => ({
4855
+ ...page,
4856
+ version: 2,
4857
+ metadata: { ...page.metadata, visibility: 'shared', lifecycle },
4858
+ }));
4859
+ mkdirSync(dashboardDir, { recursive: true });
4860
+ mkdirSync(join(stage, 'notebooks'), { recursive: true });
4861
+ mkdirSync(join(stage, 'drafts'), { recursive: true });
4862
+ try {
4863
+ writeFileSync(join(stage, 'dql.app.json'), JSON.stringify(app, null, 2) + '\n', 'utf-8');
4864
+ for (const page of committedPages) {
4865
+ writeFileSync(join(dashboardDir, `${page.id}.dqld`), JSON.stringify(page, null, 2) + '\n', 'utf-8');
4866
+ }
4867
+ writeFileSync(join(stage, 'README.md'), appReadme(app, draft.frame.audience ?? '', [], committedPages[0].id), 'utf-8');
4868
+ mkdirSync(dirname(destination), { recursive: true });
4869
+ if (draft.baseApp) {
4870
+ const backup = join(projectRoot, '.dql', 'local', 'app-build-staging', `${draft.id}-backup-${Date.now()}`);
4871
+ renameSync(destination, backup);
4872
+ try {
4873
+ renameSync(stage, destination);
4874
+ rmSync(backup, { recursive: true, force: true });
2528
4875
  }
2529
- if (item.semantic) {
2530
- governedTiles += 1;
2531
- if (item.review?.status !== 'approved') {
2532
- blockers.push({
2533
- dashboardId: dashboard.id,
2534
- tileId: item.i,
2535
- code: 'semantic_review',
2536
- message: `${dashboard.id}/${item.i} semantic result has not been explicitly approved.`,
2537
- });
2538
- }
2539
- if (!item.semantic.snapshotId || !item.review?.preflightReceiptId) {
2540
- blockers.push({
2541
- dashboardId: dashboard.id,
2542
- tileId: item.i,
2543
- code: 'semantic_preflight',
2544
- message: `${dashboard.id}/${item.i} needs a snapshot-bound semantic preflight receipt.`,
2545
- });
2546
- }
4876
+ catch (error) {
4877
+ if (!existsSync(destination) && existsSync(backup))
4878
+ renameSync(backup, destination);
4879
+ throw error;
2547
4880
  }
2548
4881
  }
4882
+ else {
4883
+ renameSync(stage, destination);
4884
+ }
2549
4885
  }
2550
- return { ready: blockers.length === 0, governedTiles, blockers };
4886
+ catch (error) {
4887
+ rmSync(stage, { recursive: true, force: true });
4888
+ throw error;
4889
+ }
4890
+ const next = {
4891
+ ...draft,
4892
+ revision: draft.revision + 1,
4893
+ state: 'project_published',
4894
+ publishedFingerprint: fingerprintAppDirectory(destination),
4895
+ updatedAt: new Date().toISOString(),
4896
+ proposalHash: draft.proposalHash,
4897
+ };
4898
+ writeStoredAppBuildDraft(projectRoot, next, { expectedRevision: draft.revision });
4899
+ const created = collectAppsList(projectRoot).find((entry) => entry.id === draft.appId);
4900
+ if (!created)
4901
+ throw new Error(`App was committed but could not be reloaded: ${draft.appId}`);
4902
+ return {
4903
+ ok: true,
4904
+ app: created,
4905
+ draft: next,
4906
+ paths: ['dql.app.json', 'README.md', ...committedPages.map((page) => `dashboards/${page.id}.dqld`)].map((path) => `apps/${draft.appId}/${path}`),
4907
+ };
2551
4908
  }
2552
- function appAiBuildSessionDir(projectRoot) {
2553
- return join(projectRoot, '.dql', 'local', 'app-ai-builds');
4909
+ /** @deprecated Compatibility adapter for callers that still use the old name. */
4910
+ export function commitStoredAppBuildDraft(projectRoot, draft) {
4911
+ return publishStoredAppBuildDraft(projectRoot, draft);
2554
4912
  }
2555
4913
  function writeAppAiBuildSession(projectRoot, session) {
2556
4914
  const dir = appAiBuildSessionDir(projectRoot);
@@ -2646,7 +5004,7 @@ function appAskTileContext(tile) {
2646
5004
  sourceEvidence: tile.sourceEvidence,
2647
5005
  };
2648
5006
  }
2649
- function buildAppContextEnvelope(app, appDir, focus) {
5007
+ function buildAppContextEnvelope(projectRoot, app, appDir, focus) {
2650
5008
  const dashboards = findDashboardsForApp(appDir)
2651
5009
  .map((path) => loadDashboardDocument(path).document)
2652
5010
  .filter((document) => Boolean(document))
@@ -2671,6 +5029,7 @@ function buildAppContextEnvelope(app, appDir, focus) {
2671
5029
  caveats: app.caveats ?? [],
2672
5030
  },
2673
5031
  dashboards,
5032
+ drafts: loadAppDraftContext(projectRoot, appDir),
2674
5033
  focus,
2675
5034
  };
2676
5035
  }
@@ -2683,6 +5042,9 @@ function filterBindingsForBlockSource(projectRoot, block) {
2683
5042
  const parameterSection = sectionBody(source, 'parameterPolicy');
2684
5043
  const bindings = Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"([^"]+)"/gm))
2685
5044
  .map((match) => ({ filter: match[1], binding: match[2], mode: 'predicate' }));
5045
+ const allowedFilterBindings = matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/)
5046
+ .filter((filter) => !bindings.some((binding) => binding.filter === filter))
5047
+ .map((filter) => ({ filter, binding: filter, mode: 'predicate' }));
2686
5048
  const parameterNames = Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"dynamic"/gm))
2687
5049
  .map((match) => match[1]);
2688
5050
  const parameterBindings = parameterNames.map((param) => ({
@@ -2691,7 +5053,7 @@ function filterBindingsForBlockSource(projectRoot, block) {
2691
5053
  mode: 'parameter',
2692
5054
  paramNames: [param],
2693
5055
  }));
2694
- return uniqueFilterBindings([...bindings, ...parameterBindings]);
5056
+ return uniqueFilterBindings([...bindings, ...allowedFilterBindings, ...parameterBindings]);
2695
5057
  }
2696
5058
  function sectionBody(source, sectionName) {
2697
5059
  const match = new RegExp(`${sectionName}\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm').exec(source);
@@ -2857,7 +5219,7 @@ function promoteSharedDashboardItem(item) {
2857
5219
  reviewStatus,
2858
5220
  };
2859
5221
  }
2860
- export function deleteAppPackage(projectRoot, id) {
5222
+ export function deleteAppPackage(projectRoot, id, input = {}) {
2861
5223
  const cleanId = cleanString(id);
2862
5224
  if (!cleanId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanId)) {
2863
5225
  return { ok: false, status: 400, error: 'A valid App id is required.' };
@@ -2865,27 +5227,130 @@ export function deleteAppPackage(projectRoot, id) {
2865
5227
  const loaded = loadAppById(projectRoot, cleanId);
2866
5228
  if (!loaded)
2867
5229
  return { ok: false, status: 404, error: `App "${cleanId}" not found.` };
5230
+ const currentFingerprint = fingerprintAppDirectory(loaded.appDir);
5231
+ if (!input.expectedFingerprint) {
5232
+ return { ok: false, status: 409, error: 'App deletion requires the fingerprint shown during confirmation.' };
5233
+ }
5234
+ if (input.expectedFingerprint !== currentFingerprint) {
5235
+ return { ok: false, status: 409, error: 'This App changed since deletion was requested. Refresh and review it again.' };
5236
+ }
2868
5237
  const relativeAppDir = relative(projectRoot, loaded.appDir).replaceAll('\\', '/');
2869
5238
  const isProjectApp = /^apps\/[^/]+$/.test(relativeAppDir);
2870
5239
  const isDomainApp = /^domains\/(?:[^/]+\/)*apps\/[^/]+$/.test(relativeAppDir);
2871
5240
  if ((!isProjectApp && !isDomainApp) || relativeAppDir.startsWith('../')) {
2872
5241
  return { ok: false, status: 400, error: 'DQL refused to delete an App outside this project.' };
2873
5242
  }
2874
- const trashRoot = join(projectRoot, '.dql', 'trash', 'apps');
5243
+ const trashRoot = join(projectRoot, '.dql', 'local', 'trash', 'apps');
2875
5244
  mkdirSync(trashRoot, { recursive: true });
2876
5245
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2877
- let trashDir = join(trashRoot, `${stamp}-${cleanId}`);
5246
+ let recoveryId = `${stamp}-${cleanId}`;
5247
+ let trashDir = join(trashRoot, recoveryId);
2878
5248
  let suffix = 2;
2879
- while (existsSync(trashDir))
2880
- trashDir = join(trashRoot, `${stamp}-${cleanId}-${suffix++}`);
2881
- renameSync(loaded.appDir, trashDir);
5249
+ while (existsSync(trashDir)) {
5250
+ recoveryId = `${stamp}-${cleanId}-${suffix++}`;
5251
+ trashDir = join(trashRoot, recoveryId);
5252
+ }
5253
+ mkdirSync(trashDir, { recursive: true });
5254
+ const packageDir = join(trashDir, 'package');
5255
+ renameSync(loaded.appDir, packageDir);
5256
+ let localArchive;
5257
+ try {
5258
+ const dbPath = defaultLocalAppsDbPath(projectRoot);
5259
+ if (existsSync(dbPath)) {
5260
+ const storage = new LocalAppStorage(dbPath);
5261
+ try {
5262
+ localArchive = storage.archiveAndDeleteAppState(cleanId);
5263
+ }
5264
+ finally {
5265
+ storage.close();
5266
+ }
5267
+ writeFileSync(join(trashDir, 'local-state.json'), JSON.stringify(localArchive, null, 2) + '\n', 'utf-8');
5268
+ }
5269
+ writeFileSync(join(trashDir, 'recovery.json'), JSON.stringify({
5270
+ version: 1,
5271
+ recoveryId,
5272
+ appId: cleanId,
5273
+ originalPath: relativeAppDir,
5274
+ appFingerprint: currentFingerprint,
5275
+ deletedAt: new Date().toISOString(),
5276
+ }, null, 2) + '\n', 'utf-8');
5277
+ }
5278
+ catch (error) {
5279
+ if (localArchive) {
5280
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
5281
+ try {
5282
+ storage.restoreAppState(localArchive);
5283
+ }
5284
+ finally {
5285
+ storage.close();
5286
+ }
5287
+ }
5288
+ if (!existsSync(loaded.appDir) && existsSync(packageDir))
5289
+ renameSync(packageDir, loaded.appDir);
5290
+ rmSync(trashDir, { recursive: true, force: true });
5291
+ throw error;
5292
+ }
5293
+ const localRowsArchived = localArchive
5294
+ ? Object.values(localArchive.rows).reduce((count, rows) => count + rows.length, 0)
5295
+ : 0;
2882
5296
  return {
2883
5297
  ok: true,
2884
5298
  id: cleanId,
2885
5299
  deletedPath: relativeAppDir,
2886
5300
  trashPath: relative(projectRoot, trashDir).replaceAll('\\', '/'),
5301
+ recoveryId,
5302
+ localRowsArchived,
5303
+ sourceControlImpact: 'delete',
2887
5304
  };
2888
5305
  }
5306
+ export function restoreAppPackage(projectRoot, recoveryId) {
5307
+ const cleanRecoveryId = cleanString(recoveryId);
5308
+ if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
5309
+ return { ok: false, status: 400, error: 'A valid recovery id is required.' };
5310
+ }
5311
+ const recoveryDir = join(projectRoot, '.dql', 'local', 'trash', 'apps', cleanRecoveryId);
5312
+ const manifestPath = join(recoveryDir, 'recovery.json');
5313
+ const packageDir = join(recoveryDir, 'package');
5314
+ if (!existsSync(manifestPath) || !existsSync(packageDir))
5315
+ return { ok: false, status: 404, error: 'App recovery bundle not found.' };
5316
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
5317
+ if (!manifest.appId || !manifest.originalPath || manifest.originalPath.startsWith('../')) {
5318
+ return { ok: false, status: 400, error: 'App recovery bundle is invalid.' };
5319
+ }
5320
+ const destination = join(projectRoot, manifest.originalPath);
5321
+ if (existsSync(destination))
5322
+ return { ok: false, status: 409, error: `Cannot restore because ${manifest.originalPath} already exists.` };
5323
+ mkdirSync(dirname(destination), { recursive: true });
5324
+ renameSync(packageDir, destination);
5325
+ try {
5326
+ const localStatePath = join(recoveryDir, 'local-state.json');
5327
+ if (existsSync(localStatePath)) {
5328
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
5329
+ try {
5330
+ storage.restoreAppState(JSON.parse(readFileSync(localStatePath, 'utf-8')));
5331
+ }
5332
+ finally {
5333
+ storage.close();
5334
+ }
5335
+ }
5336
+ }
5337
+ catch (error) {
5338
+ renameSync(destination, packageDir);
5339
+ throw error;
5340
+ }
5341
+ rmSync(recoveryDir, { recursive: true, force: true });
5342
+ return { ok: true, id: manifest.appId, restoredPath: manifest.originalPath };
5343
+ }
5344
+ export function restoreAppRecovery(projectRoot, recoveryId) {
5345
+ const cleanRecoveryId = cleanString(recoveryId);
5346
+ if (!cleanRecoveryId || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cleanRecoveryId)) {
5347
+ return { ok: false, status: 400, error: 'A valid recovery id is required.' };
5348
+ }
5349
+ const draftRecovery = join(projectRoot, '.dql', 'local', 'trash', 'app-drafts', cleanRecoveryId, 'recovery.json');
5350
+ return existsSync(draftRecovery)
5351
+ ? restoreStoredAppBuildDraft(projectRoot, cleanRecoveryId)
5352
+ : restoreAppPackage(projectRoot, cleanRecoveryId);
5353
+ }
2889
5354
  export function createAppPackage(projectRoot, input) {
2890
5355
  const name = cleanString(input.name);
2891
5356
  const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
@@ -2903,16 +5368,23 @@ export function createAppPackage(projectRoot, input) {
2903
5368
  const audience = cleanString(input.audience);
2904
5369
  const subdomain = cleanString(input.subdomain);
2905
5370
  const groups = normalizeTags(input.groups ?? []);
2906
- const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
2907
- const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'review' || input.lifecycle === 'deprecated'
2908
- ? input.lifecycle
2909
- : 'draft';
5371
+ // API-013: authoring always starts as a private draft. Project visibility is
5372
+ // available only through the publication endpoint and its live trust checks.
5373
+ const visibility = 'private';
5374
+ const lifecycle = 'draft';
2910
5375
  const tags = normalizeTags([...(input.tags ?? []), audience ? `audience:${slugify(audience)}` : '']);
2911
5376
  const selectedIds = Array.from(new Set((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean)));
2912
5377
  const blocks = collectBlockCandidates(projectRoot);
2913
5378
  const selectedBlocks = selectedIds
2914
5379
  .map((blockId) => blocks.find((block) => block.id === blockId || block.name === blockId))
2915
5380
  .filter((block) => Boolean(block));
5381
+ const missingBlocks = selectedIds.filter((blockId) => !selectedBlocks.some((block) => block.id === blockId || block.name === blockId));
5382
+ if (missingBlocks.length)
5383
+ return { ok: false, error: `Unknown block selection: ${missingBlocks.join(', ')}` };
5384
+ const uncertifiedBlocks = selectedBlocks.filter((block) => block.status !== 'certified');
5385
+ if (uncertifiedBlocks.length) {
5386
+ return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertifiedBlocks.map((block) => block.name).join(', ')}` };
5387
+ }
2916
5388
  const app = {
2917
5389
  version: 1,
2918
5390
  id,
@@ -2970,7 +5442,7 @@ export function createAppPackage(projectRoot, input) {
2970
5442
  homepage: { type: 'dashboard', id: dashboardId },
2971
5443
  };
2972
5444
  const dashboard = {
2973
- version: 1,
5445
+ version: 2,
2974
5446
  id: dashboardId,
2975
5447
  metadata: {
2976
5448
  title: dashboardTitle,
@@ -2983,11 +5455,13 @@ export function createAppPackage(projectRoot, input) {
2983
5455
  lifecycle,
2984
5456
  tags,
2985
5457
  },
5458
+ filters: dashboardFiltersForBlocks(selectedBlocks),
2986
5459
  layout: {
2987
5460
  kind: 'grid',
2988
5461
  cols: 12,
2989
5462
  rowHeight: 80,
2990
- items: buildDashboardItems(selectedBlocks),
5463
+ items: buildDashboardItems(projectRoot, selectedBlocks),
5464
+ responsive: responsiveLayoutsForDashboardItems(buildDashboardItems(projectRoot, selectedBlocks)),
2991
5465
  },
2992
5466
  };
2993
5467
  const paths = [
@@ -3013,7 +5487,81 @@ export function createAppPackage(projectRoot, input) {
3013
5487
  dashboardId,
3014
5488
  };
3015
5489
  }
3016
- function buildDashboardItems(blocks) {
5490
+ function createAppPackageViaBuildDraft(projectRoot, input) {
5491
+ const name = cleanString(input.name);
5492
+ const domain = cleanString(input.ownerDomain) || cleanString(input.domain);
5493
+ if (!name)
5494
+ return { ok: false, error: 'name is required' };
5495
+ if (!domain)
5496
+ return { ok: false, error: 'domain is required' };
5497
+ const appId = suggestAppId(name);
5498
+ if (existsSync(resolveAppPackageDir(projectRoot, appId)))
5499
+ return { ok: false, error: `App already exists: ${appId}` };
5500
+ const selectedIds = unique((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean));
5501
+ const blocks = collectBlockCandidates(projectRoot);
5502
+ const selectedBlocks = selectedIds
5503
+ .map((id) => blocks.find((block) => block.id === id || block.name === id))
5504
+ .filter((block) => Boolean(block));
5505
+ const missing = selectedIds.filter((id) => !selectedBlocks.some((block) => block.id === id || block.name === id));
5506
+ if (missing.length)
5507
+ return { ok: false, error: `Unknown block selection: ${missing.join(', ')}` };
5508
+ const uncertified = selectedBlocks.filter((block) => block.status !== 'certified');
5509
+ if (uncertified.length)
5510
+ return { ok: false, error: `Manual App creation accepts certified blocks only: ${uncertified.map((block) => block.name).join(', ')}` };
5511
+ const draft = createStoredAppBuildDraft(projectRoot, {
5512
+ appId,
5513
+ name,
5514
+ goal: name,
5515
+ audience: cleanString(input.audience),
5516
+ domain,
5517
+ authoringMode: 'manual',
5518
+ sourcePolicy: 'governed_only',
5519
+ });
5520
+ const items = buildDashboardItems(projectRoot, selectedBlocks);
5521
+ const page = {
5522
+ ...draft.pages[0],
5523
+ id: slugify(cleanString(input.dashboardTitle) || 'Overview') || 'overview',
5524
+ metadata: {
5525
+ ...draft.pages[0].metadata,
5526
+ title: cleanString(input.dashboardTitle) || 'Overview',
5527
+ description: cleanString(input.purpose) || `Starter dashboard for ${name}`,
5528
+ domain,
5529
+ audience: cleanString(input.audience) || undefined,
5530
+ },
5531
+ filters: dashboardFiltersForBlocks(selectedBlocks),
5532
+ layout: {
5533
+ ...draft.pages[0].layout,
5534
+ items,
5535
+ responsive: responsiveLayoutsForDashboardItems(items),
5536
+ },
5537
+ };
5538
+ const operations = [
5539
+ ...(page.id === draft.pages[0].id ? [] : [{ type: 'remove_page', pageId: draft.pages[0].id }]),
5540
+ { type: 'upsert_page', page },
5541
+ ...selectedBlocks.map((block) => ({
5542
+ type: 'upsert_source',
5543
+ source: {
5544
+ id: `block:${block.id}`,
5545
+ kind: 'certified_block',
5546
+ sourceRef: block.id,
5547
+ sourceFingerprint: block.fingerprint,
5548
+ trustState: 'certified',
5549
+ reviewStatus: 'not_required',
5550
+ },
5551
+ })),
5552
+ ];
5553
+ const revised = applyAppBuildDraftOperations(draft, draft.revision, operations);
5554
+ writeStoredAppBuildDraft(projectRoot, revised, { expectedRevision: draft.revision, operations });
5555
+ try {
5556
+ const preflighted = markStoredAppBuildDraftPreflighted(projectRoot, revised);
5557
+ const committed = publishStoredAppBuildDraft(projectRoot, preflighted);
5558
+ return { ok: true, app: committed.app, paths: committed.paths, dashboardId: page.id };
5559
+ }
5560
+ catch (error) {
5561
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
5562
+ }
5563
+ }
5564
+ function buildDashboardItems(projectRoot, blocks) {
3017
5565
  let x = 0;
3018
5566
  let y = 0;
3019
5567
  let rowH = 0;
@@ -3027,13 +5575,51 @@ function buildDashboardItems(blocks) {
3027
5575
  y += rowH || size.h;
3028
5576
  rowH = 0;
3029
5577
  }
3030
- const item = dashboardItemForBlock(block, chartType, x, y, size, index);
5578
+ const item = dashboardItemForBlock(block, chartType, x, y, size, index, filterBindingsForBlockSource(projectRoot, block));
3031
5579
  x += size.w;
3032
5580
  rowH = Math.max(rowH, size.h);
3033
5581
  return item;
3034
5582
  });
3035
5583
  }
3036
- function dashboardItemForBlock(block, chartType, x, y, size, index) {
5584
+ function dashboardFiltersForBlocks(blocks) {
5585
+ return unique(blocks.flatMap((block) => block.filterIds)).map((id) => ({
5586
+ id,
5587
+ label: id.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()),
5588
+ type: /(?:^|_)(?:top_n|limit|count|amount|number|minimum|maximum)(?:_|$)/i.test(id)
5589
+ ? 'number'
5590
+ : /(?:date|time|period|month|quarter|year)/i.test(id)
5591
+ ? 'date'
5592
+ : /(?:^|_)(?:name|id|search|email)(?:_|$)/i.test(id)
5593
+ ? 'search'
5594
+ : 'select',
5595
+ bindsTo: id,
5596
+ }));
5597
+ }
5598
+ function responsiveLayoutsForDashboardItems(items) {
5599
+ const project = (cols) => {
5600
+ let x = 0;
5601
+ let y = 0;
5602
+ let rowHeight = 0;
5603
+ return [...items].sort((left, right) => left.y - right.y || left.x - right.x).map((item) => {
5604
+ const width = cols === 1 ? 1 : Math.min(cols, Math.max(2, Math.ceil(item.w / 2)));
5605
+ if (x + width > cols) {
5606
+ x = 0;
5607
+ y += rowHeight || item.h;
5608
+ rowHeight = 0;
5609
+ }
5610
+ const next = { ...item, x, y, w: width };
5611
+ x += width;
5612
+ rowHeight = Math.max(rowHeight, item.h);
5613
+ return next;
5614
+ });
5615
+ };
5616
+ return {
5617
+ wide: { kind: 'grid', cols: 12, rowHeight: 80, items },
5618
+ medium: { kind: 'grid', cols: 6, rowHeight: 80, items: project(6) },
5619
+ narrow: { kind: 'grid', cols: 1, rowHeight: 80, items: project(1) },
5620
+ };
5621
+ }
5622
+ function dashboardItemForBlock(block, chartType, x, y, size, index, filterBindings = []) {
3037
5623
  return {
3038
5624
  i: slugify(block.name) || `tile-${index + 1}`,
3039
5625
  x,
@@ -3043,7 +5629,12 @@ function dashboardItemForBlock(block, chartType, x, y, size, index) {
3043
5629
  block: { blockId: block.name },
3044
5630
  viz: { type: chartType },
3045
5631
  display: displayForBlockHint(block, chartType),
5632
+ sourceClass: 'certified_block',
5633
+ review: { status: 'not_required', sourceFingerprint: block.fingerprint },
5634
+ trustState: 'certified',
5635
+ reviewStatus: 'certified',
3046
5636
  title: block.name,
5637
+ ...(filterBindings.length ? { filterBindings } : {}),
3047
5638
  };
3048
5639
  }
3049
5640
  function vizRank(chartType) {
@@ -3209,8 +5800,13 @@ function visualizationFitsResult(viz, columns, rows) {
3209
5800
  const hasTime = columns.some((column) => /\b(date|time|week|month|quarter|year|season|period)\b/i.test(column.name));
3210
5801
  if (viz === 'table' || viz === 'pivot' || viz === 'text' || viz === 'heading')
3211
5802
  return true;
5803
+ // An empty row sample means "cannot judge", not "does not fit". Returning
5804
+ // false here silently rewrote every requested visualization to `table`
5805
+ // whenever no rows were supplied — which is the normal case when a tile is
5806
+ // configured before its first run, so authored and AI-chosen charts were
5807
+ // routinely discarded. Keep the request; the renderer copes with the shape.
3212
5808
  if (rows.length === 0)
3213
- return false;
5809
+ return true;
3214
5810
  if (viz === 'single_value' || viz === 'kpi' || viz === 'gauge')
3215
5811
  return rows.length === 1 && measures.length >= 1;
3216
5812
  if (viz === 'line' || viz === 'area')
@@ -3399,7 +5995,7 @@ function appReadme(app, audience, blocks, dashboardId = 'overview') {
3399
5995
  '',
3400
5996
  ].join('\n');
3401
5997
  }
3402
- function collectBlockCandidates(projectRoot) {
5998
+ function collectBlockCandidates(projectRoot, options = {}) {
3403
5999
  const blocks = [];
3404
6000
  const seen = new Set();
3405
6001
  const scanDir = (dir) => {
@@ -3415,7 +6011,12 @@ function collectBlockCandidates(projectRoot) {
3415
6011
  seen.add(filePath);
3416
6012
  const source = readFileSync(filePath, 'utf-8');
3417
6013
  const stat = statSyncSafe(filePath);
3418
- const name = matchString(source, /block\s+"([^"]+)"/) ?? entry.name.replace(/\.dql$/, '');
6014
+ const name = matchString(source, /block\s+"([^"]+)"/);
6015
+ // The App source picker is an executable-block catalog. Domain terms,
6016
+ // skills, and other .dql context remain available to planning, but they
6017
+ // must not appear as addable dashboard data sources.
6018
+ if (!name)
6019
+ continue;
3419
6020
  const tags = matchArray(source, /tags\s*=\s*\[([^\]]*)\]/);
3420
6021
  blocks.push({
3421
6022
  id: name,
@@ -3425,10 +6026,13 @@ function collectBlockCandidates(projectRoot) {
3425
6026
  owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
3426
6027
  tags,
3427
6028
  path: relative(projectRoot, filePath).replaceAll('\\', '/'),
6029
+ fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
3428
6030
  lastModified: stat?.mtime.toISOString() ?? new Date(0).toISOString(),
3429
6031
  description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
3430
6032
  llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
3431
6033
  chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? matchString(source, /chart\.(\w+)\s*\(/) ?? undefined,
6034
+ filterIds: declaredAppFilterIds(source),
6035
+ dimensionIds: declaredAppDimensionIds(source),
3432
6036
  score: 0,
3433
6037
  reasons: [],
3434
6038
  });
@@ -3441,7 +6045,37 @@ function collectBlockCandidates(projectRoot) {
3441
6045
  };
3442
6046
  scanDir(join(projectRoot, 'blocks'));
3443
6047
  scanDir(join(projectRoot, 'domains'));
3444
- return blocks;
6048
+ if (options.preserveDuplicates)
6049
+ return blocks.sort((left, right) => left.path.localeCompare(right.path));
6050
+ const byId = new Map();
6051
+ for (const block of blocks) {
6052
+ const current = byId.get(block.id);
6053
+ if (!current) {
6054
+ byId.set(block.id, block);
6055
+ continue;
6056
+ }
6057
+ const blockRank = (block.status === 'certified' ? 4 : 0) + (block.path.startsWith('domains/') ? 2 : 0);
6058
+ const currentRank = (current.status === 'certified' ? 4 : 0) + (current.path.startsWith('domains/') ? 2 : 0);
6059
+ if (blockRank > currentRank || (blockRank === currentRank && block.lastModified > current.lastModified))
6060
+ byId.set(block.id, block);
6061
+ }
6062
+ return Array.from(byId.values());
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
+ }
6068
+ function declaredAppFilterIds(source) {
6069
+ const filterSection = sectionBody(source, 'filterBindings');
6070
+ const parameterSection = sectionBody(source, 'parameterPolicy');
6071
+ return unique([
6072
+ ...matchArray(source, /allowedFilters\s*=\s*\[([^\]]*)\]/),
6073
+ ...Array.from(filterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"[^"]+"/gm)).map((match) => match[1]),
6074
+ ...Array.from(parameterSection.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*"(?:dynamic|optional|business)"/gm)).map((match) => match[1]),
6075
+ ]);
6076
+ }
6077
+ function declaredAppDimensionIds(source) {
6078
+ return unique(matchArray(source, /dimensions\s*=\s*\[([^\]]*)\]/));
3445
6079
  }
3446
6080
  function readdirSyncSafe(dir) {
3447
6081
  try {
@@ -4111,10 +6745,13 @@ function resolveSelectedBlock(projectRoot, selected, sourceBlockId) {
4111
6745
  owner: matchString(source, /owner\s*=\s*"([^"]+)"/),
4112
6746
  tags: matchArray(source, /tags\s*=\s*\[([^\]]*)\]/),
4113
6747
  path: normalizedPath,
6748
+ fingerprint: `sha256:${createHash('sha256').update(source).digest('hex')}`,
4114
6749
  lastModified: statSyncSafe(join(projectRoot, normalizedPath))?.mtime.toISOString() ?? new Date(0).toISOString(),
4115
6750
  description: matchString(source, /description\s*=\s*"((?:[^"\\]|\\.)*)"/) ?? '',
4116
6751
  llmContext: matchString(source, /llmContext\s*=\s*"((?:[^"\\]|\\.)*)"/),
4117
6752
  chartType: matchString(source, /chart\s*=\s*"([^"]+)"/) ?? undefined,
6753
+ filterIds: declaredAppFilterIds(source),
6754
+ dimensionIds: declaredAppDimensionIds(source),
4118
6755
  score: 0,
4119
6756
  reasons: [],
4120
6757
  };
@@ -5051,14 +7688,29 @@ function uniqueAppPageId(base, existingIds) {
5051
7688
  * deliberately stricter than the metadata snapshot: page, notebook, draft, and
5052
7689
  * App-document edits all invalidate approval before the atomic directory swap.
5053
7690
  */
7691
+ /**
7692
+ * Fingerprint the parts of an App package a write can actually conflict with:
7693
+ * the manifest and the dashboard documents.
7694
+ *
7695
+ * Hashing every byte under the App directory made the guard far too sensitive —
7696
+ * a notebook run output, a README edit, or an attached notebook would
7697
+ * invalidate a page proposal that could not possibly conflict with them, and
7698
+ * the commit failed with an unexplained 409.
7699
+ */
5054
7700
  function fingerprintAppDirectory(appDir) {
5055
7701
  const files = [];
5056
7702
  const visit = (directory) => {
5057
7703
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
5058
7704
  const absolutePath = join(directory, entry.name);
5059
- if (entry.isDirectory())
7705
+ if (entry.isDirectory()) {
7706
+ // Only `dashboards/` participates; notebooks, drafts and generated prose do not.
7707
+ if (directory === appDir && entry.name !== 'dashboards')
7708
+ continue;
5060
7709
  visit(absolutePath);
7710
+ }
5061
7711
  else if (entry.isFile() || entry.isSymbolicLink()) {
7712
+ if (directory === appDir && entry.name !== 'dql.app.json')
7713
+ continue;
5062
7714
  files.push({ path: absolutePath, symbolicLink: entry.isSymbolicLink() });
5063
7715
  }
5064
7716
  }
@@ -5679,6 +8331,32 @@ function readNotebookRunSnapshot(absNotebookPath) {
5679
8331
  function normalizeNotebookRole(value) {
5680
8332
  return value === 'source' || value === 'analysis' ? value : 'supporting';
5681
8333
  }
8334
+ /** Longest draft SQL carried into a prompt; drafts are context, not the answer. */
8335
+ const APP_DRAFT_SQL_PROMPT_LIMIT = 1200;
8336
+ /**
8337
+ * The app's drafts, with enough of each to be usable as context.
8338
+ *
8339
+ * `listAppDrafts` reads the same files for the Drafts panel but keeps only a
8340
+ * name and review status, which is why the drafts were visible in the UI and
8341
+ * invisible to the copilot even once the envelope carried them.
8342
+ */
8343
+ function loadAppDraftContext(projectRoot, appDir) {
8344
+ return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
8345
+ const source = readFileSync(file, 'utf-8');
8346
+ const path = relative(projectRoot, file).replaceAll('\\', '/');
8347
+ const query = matchString(source, /query\s*=\s*"""([\s\S]*?)"""/)?.trim();
8348
+ return {
8349
+ name: matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(path),
8350
+ path,
8351
+ status: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'review',
8352
+ description: matchString(source, /description\s*=\s*"([^"]*)"/) ?? undefined,
8353
+ question: matchString(source, /question\s*=\s*"([^"]*)"/) ?? undefined,
8354
+ sql: query
8355
+ ? query.length > APP_DRAFT_SQL_PROMPT_LIMIT ? `${query.slice(0, APP_DRAFT_SQL_PROMPT_LIMIT)}\n-- …truncated` : query
8356
+ : undefined,
8357
+ };
8358
+ }).sort((a, b) => a.name.localeCompare(b.name));
8359
+ }
5682
8360
  function listAppDrafts(projectRoot, appDir) {
5683
8361
  return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
5684
8362
  const source = readFileSync(file, 'utf-8');
@@ -5788,6 +8466,7 @@ function loadDashboardForApp(projectRoot, appId, dashboardId) {
5788
8466
  }
5789
8467
  return null;
5790
8468
  }
8469
+ export { writeDashboard as writeDashboardForTest };
5791
8470
  function writeDashboard(projectRoot, appId, dashboardId, payload) {
5792
8471
  // Validate against the dashboard schema before touching disk.
5793
8472
  const { document, errors } = parseDashboardDocument(JSON.stringify(payload), '<incoming>');
@@ -5803,6 +8482,22 @@ function writeDashboard(projectRoot, appId, dashboardId, payload) {
5803
8482
  return { ok: false, error: `App "${appId}" not found` };
5804
8483
  const appDir = loaded.appDir;
5805
8484
  const dashboardPath = join(appDir, 'dashboards', `${dashboardId}.dqld`);
8485
+ // Dashboard ids are unique only within an App — `overview` is a common name —
8486
+ // so a caller holding one App's document and another App's id would silently
8487
+ // overwrite the wrong file. Only accept a title change to an existing page,
8488
+ // never a wholesale replacement of a different App's page of the same name.
8489
+ if (existsSync(dashboardPath)) {
8490
+ const existing = loadDashboardDocument(dashboardPath).document;
8491
+ const incomingBlocks = new Set(document.layout.items.map((item) => item.i));
8492
+ const existingBlocks = new Set((existing?.layout.items ?? []).map((item) => item.i));
8493
+ const overlap = [...incomingBlocks].filter((tileId) => existingBlocks.has(tileId)).length;
8494
+ if (existingBlocks.size > 0 && incomingBlocks.size > 0 && overlap === 0) {
8495
+ return {
8496
+ ok: false,
8497
+ error: `Refusing to overwrite "${appId}/${dashboardId}": the incoming page shares no tiles with the one on disk, so it is very likely a different App's page of the same name.`,
8498
+ };
8499
+ }
8500
+ }
5806
8501
  mkdirSync(dirname(dashboardPath), { recursive: true });
5807
8502
  writeFileSync(dashboardPath, JSON.stringify(document, null, 2) + '\n', 'utf-8');
5808
8503
  return { ok: true, path: dashboardPath };