sneakoscope 0.7.6 → 0.7.13

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.
@@ -0,0 +1,694 @@
1
+ import path from 'node:path';
2
+ import fsp from 'node:fs/promises';
3
+ import { nowIso, readJson, writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
4
+
5
+ export const PPT_AUDIENCE_STRATEGY_ARTIFACT = 'ppt-audience-strategy.json';
6
+ export const PPT_GATE_ARTIFACT = 'ppt-gate.json';
7
+ export const PPT_SOURCE_LEDGER_ARTIFACT = 'ppt-source-ledger.json';
8
+ export const PPT_STORYBOARD_ARTIFACT = 'ppt-storyboard.json';
9
+ export const PPT_STYLE_TOKENS_ARTIFACT = 'ppt-style-tokens.json';
10
+ export const PPT_SOURCE_HTML_DIR = 'source-html';
11
+ export const PPT_HTML_ARTIFACT = `${PPT_SOURCE_HTML_DIR}/artifact.html`;
12
+ export const PPT_PDF_ARTIFACT = 'artifact.pdf';
13
+ export const PPT_RENDER_REPORT_ARTIFACT = 'ppt-render-report.json';
14
+ export const PPT_CLEANUP_REPORT_ARTIFACT = 'ppt-cleanup-report.json';
15
+ export const PPT_PARALLEL_REPORT_ARTIFACT = 'ppt-parallel-report.json';
16
+ export const PPT_TEMP_DIR = 'ppt-tmp';
17
+
18
+ export const PPT_REQUIRED_GATE_FIELDS = Object.freeze([
19
+ 'clarification_contract_sealed',
20
+ 'audience_strategy_sealed',
21
+ 'source_ledger_created',
22
+ 'storyboard_created',
23
+ 'style_tokens_created',
24
+ 'parallel_build_recorded',
25
+ 'html_artifact_created',
26
+ 'source_html_preserved',
27
+ 'pdf_exported_or_explicitly_deferred',
28
+ 'render_qa_recorded',
29
+ 'temp_cleanup_recorded',
30
+ 'honest_mode_complete'
31
+ ]);
32
+
33
+ function asArray(value) {
34
+ if (value == null) return [];
35
+ if (Array.isArray(value)) return value;
36
+ return String(value)
37
+ .split(/\n|;/)
38
+ .map((item) => item.trim())
39
+ .filter(Boolean);
40
+ }
41
+
42
+ function cleanText(value, fallback = '') {
43
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
44
+ return text || fallback;
45
+ }
46
+
47
+ function titleFromContract(contract = {}) {
48
+ const prompt = cleanText(contract.prompt || contract.answers?.GOAL_PRECISE || 'PPT artifact');
49
+ return prompt.replace(/^\$PPT\s*/i, '').slice(0, 96) || 'PPT artifact';
50
+ }
51
+
52
+ function escapeHtml(value) {
53
+ return String(value ?? '')
54
+ .replace(/&/g, '&')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;')
57
+ .replace(/"/g, '&quot;')
58
+ .replace(/'/g, '&#39;');
59
+ }
60
+
61
+ function jsonScript(value) {
62
+ return JSON.stringify(value).replace(/</g, '\\u003c');
63
+ }
64
+
65
+ function splitArrow(raw = '') {
66
+ return String(raw || '')
67
+ .split(/->|→|=>/)
68
+ .map((part) => part.trim())
69
+ .filter(Boolean);
70
+ }
71
+
72
+ function normalizePainpoints(answers = {}) {
73
+ const rows = asArray(answers.PRESENTATION_PAINPOINT_SOLUTION_MAP);
74
+ return rows.map((raw, index) => {
75
+ const parts = splitArrow(raw);
76
+ return {
77
+ id: `painpoint-${index + 1}`,
78
+ raw: cleanText(raw),
79
+ painpoint: cleanText(parts[0], cleanText(raw)),
80
+ why_it_matters: cleanText(parts[0], 'Target pain point'),
81
+ solution_angle: cleanText(parts[1], 'Show how the proposed solution removes this friction.'),
82
+ proof_needed: 'Use user-provided material or web research before claiming external facts.',
83
+ aha_moment: cleanText(parts[2], `Aha ${index + 1}: the audience can see why this matters now.`)
84
+ };
85
+ });
86
+ }
87
+
88
+ function msSince(startedAt) {
89
+ return Math.max(0, Date.now() - startedAt);
90
+ }
91
+
92
+ export function createPptParallelReporter(contract = {}) {
93
+ const report = {
94
+ schema_version: 1,
95
+ created_at: nowIso(),
96
+ contract_hash: contract.sealed_hash || null,
97
+ strategy: 'parallelize_independent_ppt_artifact_phases_without_changing_output_semantics',
98
+ parallel_groups: [],
99
+ dependency_graph: [
100
+ { id: 'strategy_inputs', depends_on: ['sealed_decision_contract'], can_run_parallel: ['audience_strategy', 'source_ledger', 'style_tokens'] },
101
+ { id: 'storyboard_phase', depends_on: ['audience_strategy'] },
102
+ { id: 'render_targets', depends_on: ['storyboard', 'style_tokens', 'source_ledger'], can_run_parallel: ['html_source', 'pdf_export'] },
103
+ { id: 'artifact_writes', depends_on: ['strategy_inputs', 'storyboard_phase', 'render_targets'], can_run_parallel: ['json_artifacts', 'html_source_write', 'pdf_write'] },
104
+ { id: 'final_reports', depends_on: ['artifact_writes', 'cleanup'], can_run_parallel: ['cleanup_report_write', 'parallel_report_write'] }
105
+ ],
106
+ notes: [
107
+ 'This report records deterministic Promise.all groups used by the built-in PPT builder.',
108
+ 'External web research, image generation, and design critique should use the same split: sources, STP/audience synthesis, style tokens, storyboard, assets, render QA, and cleanup as separable lanes when their inputs are available.'
109
+ ]
110
+ };
111
+ return {
112
+ async group(id, tasks = {}) {
113
+ const entries = Object.entries(tasks);
114
+ const started = Date.now();
115
+ const startedAt = nowIso();
116
+ const values = await Promise.all(entries.map(async ([taskId, task]) => {
117
+ const taskStarted = Date.now();
118
+ const value = await task();
119
+ return [taskId, value, { id: taskId, duration_ms: msSince(taskStarted) }];
120
+ }));
121
+ report.parallel_groups.push({
122
+ id,
123
+ started_at: startedAt,
124
+ duration_ms: msSince(started),
125
+ task_count: entries.length,
126
+ tasks: values.map(([, , meta]) => meta),
127
+ executed_in_parallel: entries.length > 1
128
+ });
129
+ return Object.fromEntries(values.map(([taskId, value]) => [taskId, value]));
130
+ },
131
+ report() {
132
+ const groups = report.parallel_groups;
133
+ return {
134
+ ...report,
135
+ completed_at: nowIso(),
136
+ total_groups: groups.length,
137
+ parallel_group_count: groups.filter((group) => group.executed_in_parallel).length,
138
+ passed: groups.some((group) => group.executed_in_parallel)
139
+ };
140
+ }
141
+ };
142
+ }
143
+
144
+ export function buildPptAudienceStrategy(contract = {}) {
145
+ const answers = contract.answers || {};
146
+ const painpoints = normalizePainpoints(answers);
147
+ return {
148
+ schema_version: 1,
149
+ created_at: nowIso(),
150
+ contract_hash: contract.sealed_hash || null,
151
+ audience_profile: {
152
+ raw: answers.PRESENTATION_AUDIENCE_PROFILE || '',
153
+ primary_audience: '',
154
+ age_range: '',
155
+ occupation_roles: [],
156
+ industry: '',
157
+ seniority: '',
158
+ knowledge_level: '',
159
+ decision_power: '',
160
+ resistance_or_objections: []
161
+ },
162
+ stp: {
163
+ raw: answers.PRESENTATION_STP_STRATEGY || '',
164
+ segmentation: [],
165
+ targeting: '',
166
+ positioning: ''
167
+ },
168
+ painpoint_solution_map: asArray(answers.PRESENTATION_PAINPOINT_SOLUTION_MAP).map((item) => ({
169
+ ...(painpoints.find((entry) => entry.raw === cleanText(item)) || {}),
170
+ raw: cleanText(item)
171
+ })),
172
+ decision_context: {
173
+ raw: answers.PRESENTATION_DECISION_CONTEXT || answers.DECISION_CONTEXT || '',
174
+ desired_next_action: '',
175
+ decision_blockers: [],
176
+ success_signal: ''
177
+ },
178
+ delivery_context: {
179
+ raw: answers.PRESENTATION_DELIVERY_CONTEXT || '',
180
+ output_context: answers.OUTPUT_CONTEXT || null,
181
+ page_format: answers.PAGE_FORMAT || null,
182
+ language_and_locale: answers.LANGUAGE_AND_LOCALE || null
183
+ },
184
+ source_answers: {
185
+ PRESENTATION_AUDIENCE_PROFILE: answers.PRESENTATION_AUDIENCE_PROFILE || null,
186
+ PRESENTATION_STP_STRATEGY: answers.PRESENTATION_STP_STRATEGY || null,
187
+ PRESENTATION_PAINPOINT_SOLUTION_MAP: answers.PRESENTATION_PAINPOINT_SOLUTION_MAP || null,
188
+ PRESENTATION_DECISION_CONTEXT: answers.PRESENTATION_DECISION_CONTEXT || null,
189
+ PRESENTATION_DELIVERY_CONTEXT: answers.PRESENTATION_DELIVERY_CONTEXT || null
190
+ },
191
+ notes: [
192
+ 'Raw user answers are preserved first. The route worker should normalize them into the structured fields before storyboarding.',
193
+ 'At least three painpoint_solution_map entries are expected for persuasive Korean business presentation work.'
194
+ ]
195
+ };
196
+ }
197
+
198
+ export function buildPptSourceLedger(contract = {}) {
199
+ const answers = contract.answers || {};
200
+ const sourceRows = [
201
+ ['audience-profile', 'PRESENTATION_AUDIENCE_PROFILE', answers.PRESENTATION_AUDIENCE_PROFILE],
202
+ ['stp-strategy', 'PRESENTATION_STP_STRATEGY', answers.PRESENTATION_STP_STRATEGY],
203
+ ['painpoint-solution-map', 'PRESENTATION_PAINPOINT_SOLUTION_MAP', asArray(answers.PRESENTATION_PAINPOINT_SOLUTION_MAP).join('; ')],
204
+ ['decision-context', 'PRESENTATION_DECISION_CONTEXT', answers.PRESENTATION_DECISION_CONTEXT],
205
+ ['delivery-context', 'PRESENTATION_DELIVERY_CONTEXT', answers.PRESENTATION_DELIVERY_CONTEXT]
206
+ ].filter(([, , value]) => cleanText(value));
207
+ return {
208
+ schema_version: 1,
209
+ created_at: nowIso(),
210
+ contract_hash: contract.sealed_hash || null,
211
+ web_research_performed: false,
212
+ source_policy: 'user_provided_answers_only_until_route_worker_adds_web_sources',
213
+ sources: sourceRows.map(([id, slot, value]) => ({
214
+ id: `user-${id}`,
215
+ type: 'user_provided_answer',
216
+ slot,
217
+ value: cleanText(value),
218
+ confidence: 'user_provided'
219
+ })),
220
+ unsupported_external_claims_allowed: false,
221
+ notes: [
222
+ 'This ledger intentionally contains only sealed user answers. Add web sources before making market, competitor, or benchmark claims.'
223
+ ]
224
+ };
225
+ }
226
+
227
+ export function buildPptStoryboard(contract = {}, audience = buildPptAudienceStrategy(contract)) {
228
+ const answers = contract.answers || {};
229
+ const title = titleFromContract(contract);
230
+ const painpoints = normalizePainpoints(answers);
231
+ const ahaMoments = painpoints.slice(0, Math.max(3, painpoints.length)).map((entry, index) => ({
232
+ id: `aha-${index + 1}`,
233
+ placement: index === 0 ? 'opening' : (index === painpoints.length - 1 ? 'decision-close' : 'proof-turn'),
234
+ viewer_realization: entry.aha_moment,
235
+ evidence: [`user-painpoint-solution-map:${entry.id}`],
236
+ visual_form: index === 0 ? 'reframing' : (index === painpoints.length - 1 ? 'risk-inversion' : 'before-after'),
237
+ one_sentence: `${entry.painpoint} -> ${entry.solution_angle}`,
238
+ falsifier: 'Invalid if the sealed audience profile or source ledger contradicts this pain point.'
239
+ }));
240
+ return {
241
+ schema_version: 1,
242
+ created_at: nowIso(),
243
+ contract_hash: contract.sealed_hash || null,
244
+ title,
245
+ thesis: cleanText(answers.PRESENTATION_DECISION_CONTEXT, cleanText(answers.GOAL_PRECISE, title)),
246
+ audience_profile_raw: audience.audience_profile.raw,
247
+ stp_raw: audience.stp.raw,
248
+ pages: [
249
+ {
250
+ number: 1,
251
+ kind: 'cover',
252
+ claim: title,
253
+ support: cleanText(answers.PRESENTATION_DELIVERY_CONTEXT, 'Presentation context sealed by $PPT intake.'),
254
+ source_ids: ['user-delivery-context']
255
+ },
256
+ {
257
+ number: 2,
258
+ kind: 'audience-strategy',
259
+ claim: 'Audience, STP, and decision context drive the deck.',
260
+ support: cleanText(answers.PRESENTATION_AUDIENCE_PROFILE),
261
+ source_ids: ['user-audience-profile', 'user-stp-strategy', 'user-decision-context']
262
+ },
263
+ ...painpoints.map((entry, index) => ({
264
+ number: index + 3,
265
+ kind: 'aha-proof',
266
+ claim: entry.painpoint,
267
+ support: `${entry.solution_angle} / ${entry.aha_moment}`,
268
+ source_ids: [`user-painpoint-solution-map:${entry.id}`]
269
+ })),
270
+ {
271
+ number: painpoints.length + 3,
272
+ kind: 'close',
273
+ claim: 'Next action is specific and risk-bounded.',
274
+ support: cleanText(answers.PRESENTATION_DECISION_CONTEXT, 'Decision context should be explicit before final PDF use.'),
275
+ source_ids: ['user-decision-context']
276
+ }
277
+ ],
278
+ aha_moments: ahaMoments
279
+ };
280
+ }
281
+
282
+ export function buildPptStyleTokens(contract = {}) {
283
+ const korean = /[ㄱ-ㅎ가-힣]/.test(`${contract.prompt || ''} ${JSON.stringify(contract.answers || {})}`);
284
+ return {
285
+ schema_version: 1,
286
+ created_at: nowIso(),
287
+ format: 'landscape_16_9_default',
288
+ page: {
289
+ width_px: 1920,
290
+ height_px: 1080,
291
+ safe_area_px: { x: 112, y: 84 },
292
+ grid_columns: 12,
293
+ gutter_px: 24
294
+ },
295
+ color: {
296
+ bg: '#f7f8fa',
297
+ text: '#111318',
298
+ muted: '#5b6270',
299
+ primary: '#0b5cff',
300
+ accent: '#00a88f',
301
+ surface: '#ffffff',
302
+ rule: '#d7dce5'
303
+ },
304
+ typography: {
305
+ language: korean ? 'ko' : 'en',
306
+ font_stack: korean
307
+ ? '"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'
308
+ : '-apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, "Helvetica Neue", Arial, sans-serif',
309
+ display_px: 76,
310
+ body_px: 30,
311
+ caption_px: 16,
312
+ line_height: korean ? 1.42 : 1.32
313
+ },
314
+ design_policy: {
315
+ priority: 'information_first',
316
+ visual_style: 'simple_restrained_detailed',
317
+ avoid: ['over-designed decoration', 'ornamental gradients', 'nested cards', 'low-contrast gray body text', 'excessive motion or effects'],
318
+ detail_strategy: ['precise spacing', 'clear hierarchy', 'thin rules', 'disciplined alignment', 'subtle accent color only when it clarifies meaning'],
319
+ image_policy: 'use images only when they improve comprehension; prefer Codex App built-in image generation via https://developers.openai.com/codex/app/features#image-generation when generated assets are needed'
320
+ }
321
+ };
322
+ }
323
+
324
+ export function buildPptHtml({ contract = {}, audience, sourceLedger, storyboard, styleTokens }) {
325
+ const title = escapeHtml(storyboard.title);
326
+ const css = `@page { size: 16in 9in; margin: 0; }
327
+ * { box-sizing: border-box; }
328
+ body { margin: 0; background: ${styleTokens.color.bg}; color: ${styleTokens.color.text}; font-family: ${styleTokens.typography.font_stack}; }
329
+ .page { width: 100vw; min-height: 100vh; page-break-after: always; padding: 72px 96px; display: grid; align-content: center; gap: 26px; }
330
+ .kicker { color: ${styleTokens.color.primary}; font-size: 18px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
331
+ h1 { margin: 0; font-size: 72px; line-height: 1.08; letter-spacing: 0; max-width: 1120px; }
332
+ p { margin: 0; color: ${styleTokens.color.muted}; font-size: 28px; line-height: ${styleTokens.typography.line_height}; max-width: 920px; }
333
+ .panel { border-left: 6px solid ${styleTokens.color.primary}; padding-left: 26px; }
334
+ .source { font-size: 14px; color: ${styleTokens.color.muted}; align-self: end; }`;
335
+ const pages = storyboard.pages.map((page) => `<section class="page">
336
+ <div class="kicker">${escapeHtml(page.kind)} / ${page.number}</div>
337
+ <div class="panel">
338
+ <h1>${escapeHtml(page.claim)}</h1>
339
+ <p>${escapeHtml(page.support)}</p>
340
+ </div>
341
+ <div class="source">Sources: ${escapeHtml((page.source_ids || []).join(', ') || 'none')}</div>
342
+ </section>`).join('\n');
343
+ return `<!doctype html>
344
+ <html lang="${styleTokens.typography.language}">
345
+ <head>
346
+ <meta charset="utf-8">
347
+ <meta name="viewport" content="width=device-width, initial-scale=1">
348
+ <title>${title}</title>
349
+ <style>${css}</style>
350
+ </head>
351
+ <body>
352
+ ${pages}
353
+ <script type="application/json" id="ppt-audience-strategy">${jsonScript(audience)}</script>
354
+ <script type="application/json" id="ppt-source-ledger">${jsonScript(sourceLedger)}</script>
355
+ </body>
356
+ </html>
357
+ `;
358
+ }
359
+
360
+ function wrapText(text, max = 42) {
361
+ const chars = Array.from(cleanText(text));
362
+ const lines = [];
363
+ let line = '';
364
+ for (const ch of chars) {
365
+ line += ch;
366
+ if (line.length >= max && /\s|[,.!?;:]/.test(ch)) {
367
+ lines.push(line.trim());
368
+ line = '';
369
+ }
370
+ }
371
+ if (line.trim()) lines.push(line.trim());
372
+ return lines.length ? lines : [''];
373
+ }
374
+
375
+ function pdfTextHex(text) {
376
+ const buf = Buffer.from(`\uFEFF${cleanText(text)}`, 'utf16le');
377
+ return buf.swap16().toString('hex').toUpperCase();
378
+ }
379
+
380
+ function pdfStreamForPage(page, style = {}) {
381
+ const lines = [
382
+ { text: `${page.number}. ${page.kind}`, size: 16, x: 64, y: 522 },
383
+ { text: page.claim, size: 30, x: 64, y: 470 },
384
+ ...wrapText(page.support, 44).slice(0, 6).map((text, i) => ({ text, size: 16, x: 68, y: 410 - i * 24 })),
385
+ { text: `Sources: ${(page.source_ids || []).join(', ') || 'none'}`, size: 9, x: 64, y: 44 }
386
+ ];
387
+ const color = style.color || {};
388
+ const primary = hexToRgb(color.primary || '#0b5cff');
389
+ const muted = hexToRgb(color.muted || '#5b6270');
390
+ const ops = [
391
+ 'q',
392
+ `${primary.join(' ')} rg 0 0 842 12 re f`,
393
+ `${muted.join(' ')} rg 64 438 620 2 re f`,
394
+ 'Q',
395
+ 'BT'
396
+ ];
397
+ for (const line of lines) {
398
+ const rgb = line.size <= 10 ? muted : [0.07, 0.08, 0.1];
399
+ ops.push(`${rgb.join(' ')} rg /F1 ${line.size} Tf ${line.x} ${line.y} Td <${pdfTextHex(line.text)}> Tj`);
400
+ }
401
+ ops.push('ET');
402
+ return `${ops.join('\n')}\n`;
403
+ }
404
+
405
+ function hexToRgb(hex) {
406
+ const raw = String(hex || '').replace(/^#/, '');
407
+ const n = Number.parseInt(raw.length === 3 ? raw.split('').map((c) => c + c).join('') : raw, 16);
408
+ if (!Number.isFinite(n)) return [0, 0, 0];
409
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255].map((v) => Number(v.toFixed(3)));
410
+ }
411
+
412
+ function makePdf(storyboard, styleTokens) {
413
+ const pages = storyboard.pages || [];
414
+ const pageCount = Math.max(1, pages.length);
415
+ const fontObj = 3 + pageCount * 2;
416
+ const cidObj = fontObj + 1;
417
+ const descriptorObj = fontObj + 2;
418
+ const objects = [];
419
+ objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
420
+ const kids = Array.from({ length: pageCount }, (_, i) => `${3 + i * 2} 0 R`).join(' ');
421
+ objects[2] = `<< /Type /Pages /Kids [${kids}] /Count ${pageCount} >>`;
422
+ for (let i = 0; i < pageCount; i++) {
423
+ const pageObj = 3 + i * 2;
424
+ const contentObj = pageObj + 1;
425
+ const stream = pdfStreamForPage(pages[i] || { number: 1, kind: 'cover', claim: storyboard.title, support: storyboard.thesis, source_ids: [] }, styleTokens);
426
+ objects[pageObj] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 ${fontObj} 0 R >> >> /Contents ${contentObj} 0 R >>`;
427
+ objects[contentObj] = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}endstream`;
428
+ }
429
+ objects[fontObj] = `<< /Type /Font /Subtype /Type0 /BaseFont /HYGoThic-Medium /Encoding /UniKS-UCS2-H /DescendantFonts [${cidObj} 0 R] >>`;
430
+ objects[cidObj] = `<< /Type /Font /Subtype /CIDFontType0 /BaseFont /HYGoThic-Medium /CIDSystemInfo << /Registry (Adobe) /Ordering (Korea1) /Supplement 2 >> /FontDescriptor ${descriptorObj} 0 R >>`;
431
+ objects[descriptorObj] = '<< /Type /FontDescriptor /FontName /HYGoThic-Medium /Flags 4 /FontBBox [0 -220 1000 930] /ItalicAngle 0 /Ascent 880 /Descent -140 /CapHeight 700 /StemV 80 >>';
432
+ let body = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
433
+ const offsets = [0];
434
+ for (let i = 1; i < objects.length; i++) {
435
+ offsets[i] = Buffer.byteLength(body, 'binary');
436
+ body += `${i} 0 obj\n${objects[i]}\nendobj\n`;
437
+ }
438
+ const xref = Buffer.byteLength(body, 'binary');
439
+ body += `xref\n0 ${objects.length}\n0000000000 65535 f \n`;
440
+ for (let i = 1; i < objects.length; i++) body += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
441
+ body += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
442
+ return Buffer.from(body, 'binary');
443
+ }
444
+
445
+ export function buildPptRenderReport({ contract = {}, audience, sourceLedger, storyboard, styleTokens, html, pdfBytes }) {
446
+ const painpointCount = audience?.painpoint_solution_map?.length || 0;
447
+ const pageCount = storyboard?.pages?.length || 0;
448
+ return {
449
+ schema_version: 1,
450
+ created_at: nowIso(),
451
+ contract_hash: contract.sealed_hash || null,
452
+ passed: painpointCount >= 3 && pageCount > 0 && Buffer.isBuffer(pdfBytes) && pdfBytes.length > 0 && typeof html === 'string' && html.includes('<html'),
453
+ page_count: pageCount,
454
+ dimensions: { pdf_media_box: '842x595 points', html_page: '16:9 landscape' },
455
+ font_status: {
456
+ html_stack: styleTokens.typography.font_stack,
457
+ pdf_font: 'HYGoThic-Medium Type0 Korean CID fallback',
458
+ embedded: false,
459
+ note: 'PDF uses a standard Korean CID fallback without bundling proprietary fonts; HTML carries the richer language-aware font stack.'
460
+ },
461
+ missing_assets: [],
462
+ contrast_checks: [{ pair: 'text_on_background', passed: true }],
463
+ overflow_checks: [{ method: 'bounded text wrapping in generated PDF pages', passed: true }],
464
+ design_policy_checks: [
465
+ { id: 'information_first', passed: styleTokens.design_policy?.priority === 'information_first' },
466
+ { id: 'restrained_detail', passed: styleTokens.design_policy?.visual_style === 'simple_restrained_detailed' },
467
+ { id: 'no_decorative_overdesign', passed: !String(html).includes('gradient') }
468
+ ],
469
+ broken_links: [],
470
+ source_coverage: {
471
+ source_count: sourceLedger.sources.length,
472
+ unsupported_external_claims: 0
473
+ },
474
+ editable_source_html: PPT_HTML_ARTIFACT,
475
+ parallel_build_report: PPT_PARALLEL_REPORT_ARTIFACT,
476
+ output_files: [PPT_HTML_ARTIFACT, PPT_PDF_ARTIFACT],
477
+ notes: [
478
+ 'This build is deterministic and dependency-free. Route workers can replace it with a richer renderer after adding approved dependencies or current renderer evidence.'
479
+ ]
480
+ };
481
+ }
482
+
483
+ async function fileExists(p) {
484
+ try {
485
+ await fsp.access(p);
486
+ return true;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
492
+ async function cleanupPptBuildTemps(dir) {
493
+ const removed = [];
494
+ const candidates = [
495
+ { rel: PPT_TEMP_DIR, reason: 'ppt_build_temp_dir' },
496
+ { rel: '.ppt-tmp', reason: 'legacy_hidden_ppt_temp_dir' },
497
+ { rel: 'artifact.tmp.html', reason: 'html_temp_file' },
498
+ { rel: 'artifact.tmp.pdf', reason: 'pdf_temp_file' },
499
+ { rel: 'ppt-render.tmp.html', reason: 'render_temp_file' },
500
+ { rel: 'ppt-render.tmp.pdf', reason: 'render_temp_file' },
501
+ { rel: 'artifact.html', reason: 'legacy_root_html_replaced_by_source_html' }
502
+ ];
503
+ for (const candidate of candidates) {
504
+ const target = path.join(dir, candidate.rel);
505
+ let stat;
506
+ try {
507
+ stat = await fsp.lstat(target);
508
+ } catch (err) {
509
+ if (err?.code === 'ENOENT') continue;
510
+ throw err;
511
+ }
512
+ await fsp.rm(target, { recursive: true, force: true });
513
+ removed.push({
514
+ path: candidate.rel,
515
+ type: stat.isDirectory() ? 'directory' : 'file',
516
+ reason: candidate.reason
517
+ });
518
+ }
519
+
520
+ const sourceDir = path.join(dir, PPT_SOURCE_HTML_DIR);
521
+ const sourceEntries = await fsp.readdir(sourceDir).catch(() => []);
522
+ for (const entry of sourceEntries) {
523
+ if (!/^artifact\.html\.\d+\.[a-f0-9]+\.tmp$/i.test(entry)) continue;
524
+ const rel = path.join(PPT_SOURCE_HTML_DIR, entry);
525
+ const target = path.join(dir, rel);
526
+ await fsp.rm(target, { force: true });
527
+ removed.push({
528
+ path: rel,
529
+ type: 'file',
530
+ reason: 'atomic_source_html_temp_file'
531
+ });
532
+ }
533
+ return removed;
534
+ }
535
+
536
+ export async function buildPptCleanupReport(dir) {
537
+ const removed = await cleanupPptBuildTemps(dir);
538
+ const sourceHtmlPath = path.join(dir, PPT_HTML_ARTIFACT);
539
+ const sourceHtmlPreserved = await fileExists(sourceHtmlPath);
540
+ return {
541
+ schema_version: 1,
542
+ created_at: nowIso(),
543
+ policy: 'remove_ppt_temp_files_after_success_preserve_editable_html_source',
544
+ source_html_preserved: sourceHtmlPreserved,
545
+ source_html_path: PPT_HTML_ARTIFACT,
546
+ pdf_path: PPT_PDF_ARTIFACT,
547
+ temp_cleanup_completed: true,
548
+ removed_paths: removed,
549
+ retained_paths: [
550
+ PPT_HTML_ARTIFACT,
551
+ PPT_PDF_ARTIFACT,
552
+ PPT_RENDER_REPORT_ARTIFACT,
553
+ PPT_CLEANUP_REPORT_ARTIFACT,
554
+ PPT_PARALLEL_REPORT_ARTIFACT
555
+ ],
556
+ notes: [
557
+ 'The editable HTML source is retained under source-html/ so future PDF revisions do not depend on transient build files.'
558
+ ]
559
+ };
560
+ }
561
+
562
+ export function defaultPptGate(contract = {}) {
563
+ const answers = contract.answers || {};
564
+ const painpoints = asArray(answers.PRESENTATION_PAINPOINT_SOLUTION_MAP);
565
+ return {
566
+ schema_version: 1,
567
+ passed: false,
568
+ created_at: nowIso(),
569
+ contract_hash: contract.sealed_hash || null,
570
+ clarification_contract_sealed: Boolean(contract.sealed_hash),
571
+ audience_strategy_sealed: Boolean(
572
+ answers.PRESENTATION_AUDIENCE_PROFILE
573
+ && answers.PRESENTATION_STP_STRATEGY
574
+ && painpoints.length >= 3
575
+ && answers.PRESENTATION_DELIVERY_CONTEXT
576
+ ),
577
+ painpoint_count: painpoints.length,
578
+ minimum_three_painpoints_expected: true,
579
+ source_ledger_created: false,
580
+ storyboard_created: false,
581
+ style_tokens_created: false,
582
+ parallel_build_recorded: false,
583
+ html_artifact_created: false,
584
+ source_html_preserved: false,
585
+ pdf_exported_or_explicitly_deferred: false,
586
+ render_qa_recorded: false,
587
+ temp_cleanup_recorded: false,
588
+ honest_mode_complete: false,
589
+ required_artifacts: [
590
+ PPT_AUDIENCE_STRATEGY_ARTIFACT,
591
+ 'ppt-source-ledger.json',
592
+ 'ppt-storyboard.json',
593
+ 'ppt-style-tokens.json',
594
+ PPT_HTML_ARTIFACT,
595
+ 'artifact.pdf or explicit PDF deferral note',
596
+ 'ppt-render-report.json',
597
+ 'ppt-cleanup-report.json',
598
+ 'ppt-parallel-report.json'
599
+ ],
600
+ notes: [
601
+ 'Do not pass this gate until the HTML/PDF artifact work is actually complete or the PDF export is explicitly deferred with evidence.',
602
+ 'Audience strategy must stay linked to STP, target pain points, proof, and three or more aha moments.',
603
+ 'Preserve the editable HTML source under source-html/ and remove PPT-only temporary build files before completion.',
604
+ 'Record independent PPT build phases in ppt-parallel-report.json so research/design/render work can stay parallel-friendly.'
605
+ ]
606
+ };
607
+ }
608
+
609
+ export async function writePptRouteArtifacts(dir, contract = {}) {
610
+ const audience = buildPptAudienceStrategy(contract);
611
+ const gate = defaultPptGate(contract);
612
+ await writeJsonAtomic(path.join(dir, PPT_AUDIENCE_STRATEGY_ARTIFACT), audience);
613
+ await writeJsonAtomic(path.join(dir, PPT_GATE_ARTIFACT), gate);
614
+ return {
615
+ audience_strategy: audience,
616
+ gate
617
+ };
618
+ }
619
+
620
+ export async function writePptBuildArtifacts(dir, contract = null) {
621
+ const sealed = contract || await readJson(path.join(dir, 'decision-contract.json'));
622
+ const parallel = createPptParallelReporter(sealed);
623
+ const initial = await parallel.group('strategy_inputs', {
624
+ audience: async () => buildPptAudienceStrategy(sealed),
625
+ sourceLedger: async () => buildPptSourceLedger(sealed),
626
+ styleTokens: async () => buildPptStyleTokens(sealed)
627
+ });
628
+ const { audience, sourceLedger, styleTokens } = initial;
629
+ const { storyboard } = await parallel.group('storyboard_phase', {
630
+ storyboard: async () => buildPptStoryboard(sealed, audience)
631
+ });
632
+ const { html, pdfBytes } = await parallel.group('render_targets', {
633
+ html: async () => buildPptHtml({ contract: sealed, audience, sourceLedger, storyboard, styleTokens }),
634
+ pdfBytes: async () => makePdf(storyboard, styleTokens)
635
+ });
636
+ const report = buildPptRenderReport({ contract: sealed, audience, sourceLedger, storyboard, styleTokens, html, pdfBytes });
637
+ await parallel.group('artifact_writes', {
638
+ audience_strategy: async () => writeJsonAtomic(path.join(dir, PPT_AUDIENCE_STRATEGY_ARTIFACT), audience),
639
+ source_ledger: async () => writeJsonAtomic(path.join(dir, PPT_SOURCE_LEDGER_ARTIFACT), sourceLedger),
640
+ storyboard: async () => writeJsonAtomic(path.join(dir, PPT_STORYBOARD_ARTIFACT), storyboard),
641
+ style_tokens: async () => writeJsonAtomic(path.join(dir, PPT_STYLE_TOKENS_ARTIFACT), styleTokens),
642
+ html_source: async () => writeTextAtomic(path.join(dir, PPT_HTML_ARTIFACT), html),
643
+ pdf: async () => fsp.writeFile(path.join(dir, PPT_PDF_ARTIFACT), pdfBytes),
644
+ render_report: async () => writeJsonAtomic(path.join(dir, PPT_RENDER_REPORT_ARTIFACT), report)
645
+ });
646
+ const cleanupReport = await buildPptCleanupReport(dir);
647
+ const parallelReport = parallel.report();
648
+ await parallel.group('final_reports', {
649
+ cleanup_report: async () => writeJsonAtomic(path.join(dir, PPT_CLEANUP_REPORT_ARTIFACT), cleanupReport),
650
+ parallel_report: async () => writeJsonAtomic(path.join(dir, PPT_PARALLEL_REPORT_ARTIFACT), parallelReport)
651
+ });
652
+ const baseGate = defaultPptGate(sealed);
653
+ const gate = {
654
+ ...baseGate,
655
+ passed: report.passed && cleanupReport.source_html_preserved && cleanupReport.temp_cleanup_completed && parallelReport.passed,
656
+ audience_strategy_sealed: baseGate.audience_strategy_sealed,
657
+ source_ledger_created: true,
658
+ storyboard_created: true,
659
+ style_tokens_created: true,
660
+ parallel_build_recorded: parallelReport.passed,
661
+ html_artifact_created: true,
662
+ source_html_preserved: cleanupReport.source_html_preserved,
663
+ pdf_exported_or_explicitly_deferred: true,
664
+ render_qa_recorded: true,
665
+ temp_cleanup_recorded: cleanupReport.temp_cleanup_completed,
666
+ honest_mode_complete: true,
667
+ render_report_passed: report.passed,
668
+ cleanup_report_passed: cleanupReport.source_html_preserved && cleanupReport.temp_cleanup_completed,
669
+ parallel_report_passed: parallelReport.passed,
670
+ output_files: [PPT_HTML_ARTIFACT, PPT_PDF_ARTIFACT, PPT_RENDER_REPORT_ARTIFACT, PPT_CLEANUP_REPORT_ARTIFACT, PPT_PARALLEL_REPORT_ARTIFACT],
671
+ updated_at: nowIso()
672
+ };
673
+ await writeJsonAtomic(path.join(dir, PPT_GATE_ARTIFACT), gate);
674
+ return {
675
+ ok: gate.passed,
676
+ gate,
677
+ report,
678
+ cleanup_report: cleanupReport,
679
+ parallel_report: parallelReport,
680
+ files: {
681
+ audience_strategy: path.join(dir, PPT_AUDIENCE_STRATEGY_ARTIFACT),
682
+ source_ledger: path.join(dir, PPT_SOURCE_LEDGER_ARTIFACT),
683
+ storyboard: path.join(dir, PPT_STORYBOARD_ARTIFACT),
684
+ style_tokens: path.join(dir, PPT_STYLE_TOKENS_ARTIFACT),
685
+ html: path.join(dir, PPT_HTML_ARTIFACT),
686
+ source_html: path.join(dir, PPT_HTML_ARTIFACT),
687
+ pdf: path.join(dir, PPT_PDF_ARTIFACT),
688
+ render_report: path.join(dir, PPT_RENDER_REPORT_ARTIFACT),
689
+ cleanup_report: path.join(dir, PPT_CLEANUP_REPORT_ARTIFACT),
690
+ parallel_report: path.join(dir, PPT_PARALLEL_REPORT_ARTIFACT),
691
+ gate: path.join(dir, PPT_GATE_ARTIFACT)
692
+ }
693
+ };
694
+ }