返回 ppt-master
server.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Template and Strategist confirmation UI Server (Steps 3-4)
4
5 Lightweight Flask backend for the Default template choice and interactive
6 Strategist confirmation page. Stage 1 combines template selection with the
7 communication contract; its submit writes ``template_selection.json`` and the
8 stage1-confirmed ``result.json`` in one request. After the agent applies the
9 choice and completes ``template_handoff.json``, final Stage 2 confirms the deck
10 solution and production plan.
11
12 This is the default confirmation surface. The chat fallback is used only when
13 the user explicitly requests chat-only confirmation or the browser launch
14 fails; it preserves the same staged semantics.
15
16 See scripts/docs/confirm_ui.md for the round-trip data contract and schema.
17
18 Usage:
19 python3 scripts/confirm_ui/server.py <project_dir>
20
21 Examples:
22 python3 scripts/confirm_ui/server.py projects/my-project
23 python3 scripts/confirm_ui/server.py projects/my-project --port 5051
24 python3 scripts/confirm_ui/server.py projects/my-project --no-browser
25 python3 scripts/confirm_ui/server.py projects/my-project --daemon
26 python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
27 python3 scripts/confirm_ui/server.py projects/my-project --complete-template-selection
28 python3 scripts/confirm_ui/server.py projects/my-project --reset-template-selection
29
30 Dependencies:
31 flask>=3.0.0
32 """
33
34 import argparse
35 import atexit
36 import hashlib
37 import json
38 import logging
39 import os
40 import re
41 import signal
42 import subprocess
43 import sys
44 import threading
45 import time
46 import urllib.error
47 import urllib.request
48 import webbrowser
49 from pathlib import Path
50 from typing import Optional
51
52 from flask import Flask, jsonify, request, send_from_directory
53
54 # Local — sys.path injection for sibling module (code-style.md §3)
55 _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
56 if str(_SCRIPTS_DIR) not in sys.path:
57 sys.path.insert(0, str(_SCRIPTS_DIR))
58
59 from console_encoding import configure_utf8_stdio # noqa: E402
60 from language_tags import ( # noqa: E402
61 LanguageTagError,
62 language_base,
63 normalize_language_tag,
64 )
65 from server_common import ( # noqa: E402
66 claim_lock as _claim_lock,
67 clear_lock as _clear_lock,
68 find_free_port as _find_free_port,
69 lock_pid as _lock_pid,
70 popen_detached as _popen_detached,
71 process_alive as _process_alive,
72 read_lock as _read_lock,
73 release_lock as _release_lock,
74 validate_port as _validate_port,
75 )
76
77 configure_utf8_stdio()
78
79 logger = logging.getLogger('confirm_ui')
80
81 # Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches
82 # the *.lock entry already in the repo .gitignore. Independent of the live
83 # preview lock so the two surfaces never collide.
84 LOCK_FILE_NAME = '.confirm_ui.lock'
85
86 # Round-trip/session files, all under <project_path>/confirm_ui/.
87 CONFIRM_DIR_NAME = 'confirm_ui'
88 RECOMMENDATION_STAGE_NAMES = {
89 1: 'recommendations.stage1.json',
90 2: 'recommendations.stage2.json',
91 }
92 RESULT_NAME = 'result.json'
93 SESSION_NAME = 'session.json'
94 TEMPLATE_OPTIONS_NAME = 'template_options.json'
95 TEMPLATE_SELECTION_NAME = 'template_selection.json'
96 TEMPLATE_HANDOFF_NAME = 'template_handoff.json'
97 TEMPLATE_SCHEMA_VERSION = 1
98
99 _PALETTE_ROLES = (
100 'background',
101 'secondary_bg',
102 'primary',
103 'accent',
104 'secondary_accent',
105 'body_text',
106 )
107 _TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation')
108 _HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z')
109
110 # Static option universe served at /api/catalogs (canvas synced live from config).
111 _SKILL_DIR = Path(__file__).resolve().parents[2]
112 _TEMPLATES_DIR = _SKILL_DIR / 'templates'
113 _CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
114 _ICON_LIBRARY_DIR = _TEMPLATES_DIR / 'icons'
115 _AI_IMAGE_COMPARISON_DIR = _SKILL_DIR / 'references' / 'ai-image-comparison'
116 _TEMPLATE_LIBRARY_CONFIG = {
117 'brand': ('brands', 'brands_index.json'),
118 'style': ('styles', 'styles_index.json'),
119 'layout': ('layouts', 'layouts_index.json'),
120 'deck': ('decks', 'decks_index.json'),
121 }
122 _TEMPLATE_KIND_LINE_RE = re.compile(
123 r'''kind\s*:\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z][A-Za-z0-9_-]*))'''
124 r'''\s*(?:#.*)?\Z'''
125 )
126 _ICON_PREVIEW_SAMPLES = {
127 'chunk-filled': ('home', 'chart-line', 'users', 'target'),
128 'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'),
129 'tabler-outline': ('home', 'chart-line', 'users', 'bulb'),
130 'phosphor-duotone': ('house', 'chart-line', 'users', 'target'),
131 }
132
133 # Prefer the same memorable entry port as live preview. Normal single-project
134 # execution releases it between Step 4 and Step 6; concurrent projects advance
135 # from this base while explicit ``--port`` remains exact.
136 DEFAULT_PORT = 5050
137 PUBLIC_HOST = '127.0.0.1'
138 STARTUP_TIMEOUT = 10
139
140 # Default --wait budget, kept just under the 600s Bash-tool ceiling so the
141 # parent (waiting) command returns before the calling harness kills it. The
142 # detached child server keeps running on its own --timeout idle budget, so a
143 # slow user can still confirm after the wait returns; the caller re-checks
144 # result.json before falling back to chat.
145 WAIT_TIMEOUT_DEFAULT = 590
146
147 def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
148 """Read a JSON object, retrying briefly around non-atomic external writes."""
149 last_error: Exception = ValueError('unknown JSON read error')
150 for attempt in range(retries + 1):
151 try:
152 data = json.loads(path.read_text(encoding='utf-8-sig'))
153 if isinstance(data, dict):
154 return data
155 raise ValueError(f'{path} top-level JSON value must be an object')
156 except (OSError, json.JSONDecodeError, ValueError) as exc:
157 last_error = exc
158 if attempt < retries:
159 time.sleep(delay)
160 continue
161 raise last_error
162 raise last_error
163
164
165 def _write_json_atomic(path: Path, data: dict) -> None:
166 """Write a JSON object with replace semantics so waiters never see a partial file."""
167 path.parent.mkdir(parents=True, exist_ok=True)
168 tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
169 try:
170 tmp.write_text(
171 json.dumps(data, ensure_ascii=False, indent=2),
172 encoding='utf-8',
173 )
174 os.replace(tmp, path)
175 finally:
176 try:
177 tmp.unlink(missing_ok=True)
178 except OSError:
179 pass
180
181
182 def _json_sha256(data: object) -> str:
183 """Return a stable SHA-256 over one JSON-compatible value."""
184 canonical = json.dumps(
185 data,
186 ensure_ascii=False,
187 sort_keys=True,
188 separators=(',', ':'),
189 )
190 return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
191
192
193 def _safe_template_id(template_id: object) -> bool:
194 """Return whether an index key is one safe directory-segment id."""
195 return (
196 isinstance(template_id, str)
197 and re.fullmatch(r'\w[\w.-]*', template_id) is not None
198 )
199
200
201 def _template_design_spec_path(workspace_root: Path) -> Path:
202 """Return the current or legacy Design Spec for one workspace root."""
203 current = workspace_root / 'templates' / 'design_spec.md'
204 if current.is_file():
205 return current
206 legacy = workspace_root / 'design_spec.md'
207 if legacy.is_file():
208 return legacy
209 raise ValueError(
210 'template workspace is missing templates/design_spec.md '
211 f'or legacy design_spec.md: {workspace_root}'
212 )
213
214
215 def _template_kind_from_spec(spec_path: Path) -> str:
216 """Read one supported top-level ``kind`` from Design Spec frontmatter."""
217 try:
218 lines = spec_path.read_text(encoding='utf-8-sig').splitlines()
219 except OSError as exc:
220 raise ValueError(f'cannot read template Design Spec {spec_path}: {exc}') from exc
221 if not lines or lines[0] != '---':
222 raise ValueError(f'{spec_path} must start with YAML frontmatter')
223 try:
224 frontmatter_end = lines.index('---', 1)
225 except ValueError as exc:
226 raise ValueError(f'{spec_path} has unterminated YAML frontmatter') from exc
227
228 declared_kind = None
229 for line_number, line in enumerate(lines[1:frontmatter_end], start=2):
230 if re.match(r'^\s*kind\s*:', line) is None:
231 continue
232 if line != line.lstrip():
233 raise ValueError(
234 f'{spec_path}:{line_number} kind must be a top-level frontmatter field'
235 )
236 match = _TEMPLATE_KIND_LINE_RE.fullmatch(line)
237 if match is None:
238 raise ValueError(
239 f'{spec_path}:{line_number} has an invalid kind declaration'
240 )
241 if declared_kind is not None:
242 raise ValueError(f'{spec_path} frontmatter declares kind more than once')
243 declared_kind = next(value for value in match.groups() if value is not None)
244
245 if declared_kind is None:
246 raise ValueError(f'{spec_path} frontmatter must declare kind')
247 if declared_kind not in _TEMPLATE_LIBRARY_CONFIG:
248 supported = ', '.join(_TEMPLATE_LIBRARY_CONFIG)
249 raise ValueError(
250 f'{spec_path} frontmatter kind must be one of {supported}; '
251 f'got {declared_kind!r}'
252 )
253 return declared_kind
254
255
256 def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
257 """Read and validate the agent-authored Step-3 template input."""
258 options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
259 data = _read_json_object(options_file)
260 if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
261 raise ValueError(
262 f'{TEMPLATE_OPTIONS_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
263 )
264 if data.get('phase') != 'template':
265 raise ValueError(f'{TEMPLATE_OPTIONS_NAME} phase must be template')
266 if data.get('default_mode') not in {'free_design', 'templates'}:
267 raise ValueError(
268 f'{TEMPLATE_OPTIONS_NAME} default_mode must be free_design or templates'
269 )
270 if 'lang' in data and (
271 not isinstance(data['lang'], str) or not data['lang'].strip()
272 ):
273 raise ValueError(f'{TEMPLATE_OPTIONS_NAME} lang must be a non-empty string')
274 if 'explicit_workspace_roots' not in data:
275 raise ValueError(
276 f'{TEMPLATE_OPTIONS_NAME} must include explicit_workspace_roots'
277 )
278 raw_roots = data['explicit_workspace_roots']
279 if not isinstance(raw_roots, list):
280 raise ValueError(
281 f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots must be an array'
282 )
283 if raw_roots and data['default_mode'] != 'templates':
284 raise ValueError(
285 f'{TEMPLATE_OPTIONS_NAME} default_mode must be templates when '
286 'explicit_workspace_roots is non-empty'
287 )
288
289 roots = []
290 seen = set()
291 for index, raw_root in enumerate(raw_roots):
292 if not isinstance(raw_root, str) or not raw_root.strip():
293 raise ValueError(
294 f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
295 'must be a non-empty string'
296 )
297 candidate = Path(raw_root)
298 if not candidate.is_absolute():
299 raise ValueError(
300 f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
301 'must be an absolute path'
302 )
303 try:
304 root = candidate.resolve()
305 except (OSError, RuntimeError) as exc:
306 raise ValueError(
307 f'cannot resolve explicit workspace root {raw_root}: {exc}'
308 ) from exc
309 canonical = str(root)
310 if canonical in seen:
311 raise ValueError(
312 f'{TEMPLATE_OPTIONS_NAME} contains duplicate workspace root: {canonical}'
313 )
314 if not root.is_dir():
315 raise ValueError(f'explicit workspace root is not a directory: {canonical}')
316 _template_design_spec_path(root)
317 seen.add(canonical)
318 roots.append(root)
319 return data, roots
320
321
322 def _build_template_library() -> tuple[dict, dict[str, dict], dict[str, dict], dict]:
323 """Build indexed library groups without scanning template directories."""
324 library = {}
325 candidates = {}
326 registered_roots = {}
327 index_contracts = {}
328 for kind, (directory_name, index_name) in _TEMPLATE_LIBRARY_CONFIG.items():
329 kind_dir = (_TEMPLATES_DIR / directory_name).resolve()
330 index_path = kind_dir / index_name
331 index_data = _read_json_object(index_path)
332 index_contracts[kind] = index_data
333 group = []
334 for template_id, metadata in index_data.items():
335 if not _safe_template_id(template_id):
336 raise ValueError(
337 f'{index_path} contains unsafe template id: {template_id!r}'
338 )
339 if not isinstance(metadata, dict):
340 raise ValueError(
341 f'{index_path} entry {template_id!r} must be an object'
342 )
343 workspace_root = (kind_dir / template_id).resolve()
344 if workspace_root.parent != kind_dir:
345 raise ValueError(
346 f'{index_path} entry {template_id!r} does not resolve to a '
347 f'direct child of {kind_dir}'
348 )
349 if not workspace_root.is_dir():
350 raise ValueError(
351 f'{index_path} entry {template_id!r} workspace does not exist: '
352 f'{workspace_root}'
353 )
354 spec_path = workspace_root / 'templates' / 'design_spec.md'
355 if not spec_path.is_file():
356 raise ValueError(
357 f'{index_path} entry {template_id!r} is missing {spec_path}'
358 )
359 declared_kind = _template_kind_from_spec(spec_path)
360 if declared_kind != kind:
361 raise ValueError(
362 f'{index_path} entry {template_id!r} declares kind '
363 f'{declared_kind!r}, expected {kind!r}'
364 )
365 summary = metadata.get('summary', '')
366 if not isinstance(summary, str):
367 raise ValueError(
368 f'{index_path} entry {template_id!r} summary must be a string'
369 )
370 key = f'library:{kind}:{template_id}'
371 if key in candidates:
372 raise ValueError(f'duplicate template candidate key: {key}')
373 candidate = {
374 'key': key,
375 'source': 'library',
376 'kind': kind,
377 'id': template_id,
378 'label': template_id,
379 'summary': summary,
380 'workspace_root': str(workspace_root),
381 }
382 canonical_root = candidate['workspace_root']
383 if canonical_root in registered_roots:
384 raise ValueError(
385 f'duplicate registered workspace root: {canonical_root}'
386 )
387 group.append(candidate)
388 candidates[key] = candidate
389 registered_roots[canonical_root] = candidate
390 library[kind] = group
391 return library, candidates, registered_roots, index_contracts
392
393
394 def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
395 """Return the browser contract and its server-owned candidate whitelist."""
396 source, explicit_roots = _read_template_options_input(confirm_dir)
397 library, candidates, registered_roots, index_contracts = _build_template_library()
398 explicit = []
399 suggested_keys = []
400 for root in explicit_roots:
401 canonical_root = str(root)
402 registered = registered_roots.get(canonical_root)
403 if registered is not None:
404 suggested_keys.append(registered['key'])
405 continue
406 digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest()
407 key = f'explicit:{digest}'
408 if key in candidates:
409 raise ValueError(f'duplicate template candidate key: {key}')
410 kind = _template_kind_from_spec(_template_design_spec_path(root))
411 candidate = {
412 'key': key,
413 'source': 'explicit',
414 'kind': kind,
415 'label': root.name or canonical_root,
416 'workspace_root': canonical_root,
417 }
418 explicit.append(candidate)
419 candidates[key] = candidate
420 suggested_keys.append(key)
421
422 # One supplied exact root is an unambiguous convenience default. Multiple
423 # roots are candidates for the single-select controls, not an instruction
424 # to select all of them.
425 preselected_keys = suggested_keys if len(suggested_keys) == 1 else []
426
427 response = {
428 'schema_version': TEMPLATE_SCHEMA_VERSION,
429 'phase': 'template',
430 'default_mode': source['default_mode'],
431 'library': library,
432 'explicit': explicit,
433 'preselected_keys': preselected_keys,
434 }
435 if 'lang' in source:
436 response['lang'] = source['lang'].strip()
437 response['options_sha256'] = _json_sha256({
438 'schema_version': TEMPLATE_SCHEMA_VERSION,
439 'phase': 'template',
440 'default_mode': response['default_mode'],
441 'lang': response.get('lang'),
442 'explicit_workspace_roots': [str(root) for root in explicit_roots],
443 'library_indexes': index_contracts,
444 'library': library,
445 'explicit': explicit,
446 'preselected_keys': preselected_keys,
447 })
448 return response, candidates
449
450
451 def _template_selection_from_candidate(candidate: dict) -> dict:
452 """Project one trusted browser candidate into the persisted selection."""
453 selection = {
454 'source': candidate['source'],
455 'kind': candidate['kind'],
456 }
457 if candidate['source'] == 'library':
458 selection['id'] = candidate['id']
459 selection['workspace_root'] = candidate['workspace_root']
460 return selection
461
462
463 def _template_selection_sha256(
464 mode: str,
465 selections: list[dict],
466 options_sha256: str,
467 ) -> str:
468 """Bind one resolved choice to the candidate/options contract it used."""
469 return _json_sha256({
470 'mode': mode,
471 'selections': selections,
472 'options_sha256': options_sha256,
473 })
474
475
476 def _validate_template_selection(data: dict) -> None:
477 """Validate the server-owned template-selection receipt shape."""
478 expected_fields = {
479 'schema_version',
480 'phase',
481 'status',
482 'mode',
483 'selections',
484 'options_sha256',
485 'selection_sha256',
486 'confirmed_at',
487 }
488 if set(data) != expected_fields:
489 raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields')
490 if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
491 raise ValueError(
492 f'{TEMPLATE_SELECTION_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
493 )
494 if data.get('phase') != 'template':
495 raise ValueError(f'{TEMPLATE_SELECTION_NAME} phase must be template')
496 if data.get('status') != 'confirmed':
497 raise ValueError(f'{TEMPLATE_SELECTION_NAME} status must be confirmed')
498 mode = data.get('mode')
499 if mode not in {'free_design', 'templates'}:
500 raise ValueError(
501 f'{TEMPLATE_SELECTION_NAME} mode must be free_design or templates'
502 )
503 selections = data.get('selections')
504 if not isinstance(selections, list):
505 raise ValueError(f'{TEMPLATE_SELECTION_NAME} selections must be an array')
506 if mode == 'free_design' and selections:
507 raise ValueError('free_design cannot carry template selections')
508 if mode == 'templates' and not selections:
509 raise ValueError('templates mode requires at least one selection')
510
511 options_sha256 = data.get('options_sha256')
512 selection_sha256 = data.get('selection_sha256')
513 if not isinstance(options_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', options_sha256):
514 raise ValueError(f'{TEMPLATE_SELECTION_NAME} options_sha256 is invalid')
515 if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
516 raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 is invalid')
517
518 seen_roots = set()
519 seen_library_kinds = set()
520 explicit_count = 0
521 for index, selection in enumerate(selections):
522 if not isinstance(selection, dict):
523 raise ValueError(
524 f'{TEMPLATE_SELECTION_NAME} selections[{index}] must be an object'
525 )
526 source = selection.get('source')
527 if source not in {'library', 'explicit'}:
528 raise ValueError(
529 f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid source'
530 )
531 kind = selection.get('kind')
532 if kind not in _TEMPLATE_LIBRARY_CONFIG:
533 raise ValueError(
534 f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid kind'
535 )
536 expected_keys = {'source', 'kind', 'workspace_root'}
537 if source == 'library':
538 expected_keys.add('id')
539 if kind in seen_library_kinds:
540 raise ValueError(
541 'template selection allows at most one library workspace '
542 f'for kind {kind!r}'
543 )
544 seen_library_kinds.add(kind)
545 if not _safe_template_id(selection.get('id')):
546 raise ValueError(
547 f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid id'
548 )
549 else:
550 explicit_count += 1
551 if explicit_count > 1:
552 raise ValueError(
553 'template selection allows at most one explicit workspace'
554 )
555 if set(selection) != expected_keys:
556 raise ValueError(
557 f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid fields'
558 )
559 workspace_root = selection.get('workspace_root')
560 if not isinstance(workspace_root, str) or not workspace_root:
561 raise ValueError(
562 f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
563 'workspace_root must be a non-empty string'
564 )
565 root = Path(workspace_root)
566 if not root.is_absolute() or str(root.resolve()) != workspace_root:
567 raise ValueError(
568 f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
569 'workspace_root must be a canonical absolute path'
570 )
571 if workspace_root in seen_roots:
572 raise ValueError(
573 f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root: '
574 f'{workspace_root}'
575 )
576 seen_roots.add(workspace_root)
577 if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']:
578 raise ValueError(
579 f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string'
580 )
581 expected_selection_sha256 = _template_selection_sha256(
582 mode,
583 selections,
584 options_sha256,
585 )
586 if selection_sha256 != expected_selection_sha256:
587 raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match')
588
589
590 def _read_template_selection(selection_file: Path) -> dict:
591 """Read a selection and revalidate it against current indexed options."""
592 data = _read_json_object(selection_file)
593 _validate_template_selection(data)
594 options_file = selection_file.parent / TEMPLATE_OPTIONS_NAME
595 if not _is_newer(selection_file, options_file):
596 raise ValueError(
597 f'{TEMPLATE_SELECTION_NAME} must be confirmed after the current '
598 f'{TEMPLATE_OPTIONS_NAME}'
599 )
600 options, candidates = _build_template_options(selection_file.parent)
601 if data['options_sha256'] != options['options_sha256']:
602 raise ValueError(
603 f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options'
604 )
605 available_selections = {
606 _json_sha256(_template_selection_from_candidate(candidate))
607 for candidate in candidates.values()
608 }
609 for selection in data['selections']:
610 if _json_sha256(selection) not in available_selections:
611 raise ValueError(
612 f'{TEMPLATE_SELECTION_NAME} references an unavailable candidate'
613 )
614 return data
615
616
617 def _validate_template_handoff(data: dict) -> None:
618 """Validate the agent-owned Step-3 completion receipt shape."""
619 expected_fields = {
620 'schema_version',
621 'phase',
622 'status',
623 'mode',
624 'selection_sha256',
625 'completed_at',
626 }
627 if set(data) != expected_fields:
628 raise ValueError(f'{TEMPLATE_HANDOFF_NAME} has invalid fields')
629 if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
630 raise ValueError(
631 f'{TEMPLATE_HANDOFF_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
632 )
633 if data.get('phase') != 'template':
634 raise ValueError(f'{TEMPLATE_HANDOFF_NAME} phase must be template')
635 if data.get('status') != 'ready':
636 raise ValueError(f'{TEMPLATE_HANDOFF_NAME} status must be ready')
637 if data.get('mode') not in {'free_design', 'templates'}:
638 raise ValueError(
639 f'{TEMPLATE_HANDOFF_NAME} mode must be free_design or templates'
640 )
641 selection_sha256 = data.get('selection_sha256')
642 if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
643 raise ValueError(f'{TEMPLATE_HANDOFF_NAME} selection_sha256 is invalid')
644 if not isinstance(data.get('completed_at'), str) or not data['completed_at']:
645 raise ValueError(
646 f'{TEMPLATE_HANDOFF_NAME} completed_at must be a non-empty string'
647 )
648
649
650 def _read_template_handoff(project_path: Path, handoff_file: Path) -> dict:
651 """Read a handoff and bind it to the current selection and installed state."""
652 data = _read_json_object(handoff_file)
653 _validate_template_handoff(data)
654 selection_file = handoff_file.parent / TEMPLATE_SELECTION_NAME
655 selection = _read_template_selection(selection_file)
656 if not _is_newer(handoff_file, selection_file):
657 raise ValueError(
658 f'{TEMPLATE_HANDOFF_NAME} must be completed after the current '
659 f'{TEMPLATE_SELECTION_NAME}'
660 )
661 if data['mode'] != selection['mode']:
662 raise ValueError(f'{TEMPLATE_HANDOFF_NAME} mode does not match selection')
663 if data['selection_sha256'] != selection['selection_sha256']:
664 raise ValueError(
665 f'{TEMPLATE_HANDOFF_NAME} selection_sha256 does not match selection'
666 )
667 if data['mode'] == 'templates':
668 installed_spec = project_path / 'templates' / 'design_spec.md'
669 if not installed_spec.is_file():
670 raise ValueError(
671 f'{TEMPLATE_HANDOFF_NAME} requires installed template spec: '
672 f'{installed_spec}'
673 )
674 return data
675
676
677 def _complete_template_selection(project_path: Path) -> int:
678 """Write the agent-owned handoff after Stage 1 and template application."""
679 confirm_dir = project_path / CONFIRM_DIR_NAME
680 selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
681 if not selection_file.exists():
682 logger.error('%s not found — the user must confirm Stage 1 first', selection_file)
683 return 1
684 try:
685 selection = _read_template_selection(selection_file)
686 except (OSError, json.JSONDecodeError, ValueError) as exc:
687 logger.error('cannot complete template selection: %s', exc)
688 return 1
689
690 result_file = confirm_dir / RESULT_NAME
691 if _result_stage(result_file) != 'stage1':
692 logger.error(
693 'cannot complete template selection before Stage 1 writes a '
694 'stage1-confirmed result'
695 )
696 return 1
697 if selection['mode'] == 'templates':
698 installed_spec = project_path / 'templates' / 'design_spec.md'
699 if not installed_spec.is_file():
700 logger.error(
701 'cannot complete template selection before template apply writes %s',
702 installed_spec,
703 )
704 return 1
705
706 handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
707 if handoff_file.exists():
708 try:
709 existing = _read_template_handoff(project_path, handoff_file)
710 except (OSError, json.JSONDecodeError, ValueError):
711 existing = None
712 if (
713 existing is not None
714 and existing['selection_sha256'] == selection['selection_sha256']
715 and _is_newer(handoff_file, result_file)
716 ):
717 logger.info('template selection already complete: %s', handoff_file)
718 return 0
719
720 handoff = {
721 'schema_version': TEMPLATE_SCHEMA_VERSION,
722 'phase': 'template',
723 'status': 'ready',
724 'mode': selection['mode'],
725 'selection_sha256': selection['selection_sha256'],
726 'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
727 }
728 _validate_template_handoff(handoff)
729 _write_json_atomic(handoff_file, handoff)
730 logger.info('template selection handoff written to %s', handoff_file)
731 return 0
732
733
734 def _reset_template_selection(confirm_dir: Path) -> int:
735 """Remove one-run template-selection artifacts before a fresh UI lifecycle."""
736 removed = []
737 for filename in (
738 TEMPLATE_HANDOFF_NAME,
739 TEMPLATE_SELECTION_NAME,
740 TEMPLATE_OPTIONS_NAME,
741 ):
742 path = confirm_dir / filename
743 try:
744 path.unlink()
745 removed.append(str(path))
746 except FileNotFoundError:
747 continue
748 except OSError as exc:
749 logger.error('cannot remove %s: %s', path, exc)
750 return 1
751 if removed:
752 logger.info('reset template selection artifacts: %s', ', '.join(removed))
753 else:
754 logger.info('template selection artifacts already absent')
755 return 0
756
757
758 def _stage1_ready_error(confirm_dir: Path) -> Optional[str]:
759 """Return why the combined template/Stage-1 page cannot be exposed."""
760 options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
761 if not options_file.exists():
762 return f'{TEMPLATE_OPTIONS_NAME} not found'
763 try:
764 _build_template_options(confirm_dir)
765 except (OSError, json.JSONDecodeError, ValueError) as exc:
766 return f'invalid template options: {exc}'
767
768 stage1_file = confirm_dir / RECOMMENDATION_STAGE_NAMES[1]
769 if not stage1_file.exists():
770 return f'{stage1_file.name} not found'
771 try:
772 stage1_data = _read_json_object(stage1_file)
773 if _recommendation_stage(stage1_data) != 1:
774 return f'{stage1_file.name} does not declare stage1'
775 except (OSError, json.JSONDecodeError, ValueError) as exc:
776 return f'cannot validate Stage 1 recommendations: {exc}'
777
778 result_file = confirm_dir / RESULT_NAME
779 if result_file.exists():
780 if not _is_newer(options_file, result_file):
781 return f'{TEMPLATE_OPTIONS_NAME} must be newer than the prior {RESULT_NAME}'
782 if not _is_newer(stage1_file, result_file):
783 return f'{stage1_file.name} must be newer than the prior {RESULT_NAME}'
784 return None
785
786
787 def _stage2_ready_error(
788 project_path: Path,
789 confirm_dir: Path,
790 recommendations_file: Optional[Path] = None,
791 ) -> Optional[str]:
792 """Return why final Stage 2 cannot follow the confirmed template choice."""
793 selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
794 if not selection_file.exists():
795 return f'{TEMPLATE_SELECTION_NAME} not found after Stage 1 confirmation'
796 try:
797 _read_template_selection(selection_file)
798 except (OSError, json.JSONDecodeError, ValueError) as exc:
799 return f'invalid template selection: {exc}'
800
801 result_file = confirm_dir / RESULT_NAME
802 if _result_stage(result_file) != 'stage1':
803 return f'{RESULT_NAME} does not contain a stage1-confirmed result'
804
805 handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
806 if not handoff_file.exists():
807 return f'{TEMPLATE_HANDOFF_NAME} not found after template application'
808 try:
809 _read_template_handoff(project_path, handoff_file)
810 except (OSError, json.JSONDecodeError, ValueError) as exc:
811 return f'invalid template handoff: {exc}'
812 if not _is_newer(handoff_file, result_file):
813 return f'{TEMPLATE_HANDOFF_NAME} must be newer than the Stage 1 {RESULT_NAME}'
814
815 if recommendations_file is not None and not _is_newer(
816 recommendations_file,
817 handoff_file,
818 ):
819 return f'{recommendations_file.name} must be newer than {TEMPLATE_HANDOFF_NAME}'
820 return None
821
822
823 def _resolve_template_confirmation(
824 payload: dict,
825 candidates: dict[str, dict],
826 options_sha256: str,
827 ) -> dict:
828 """Resolve browser keys against the current server-owned candidate set."""
829 required_fields = {'mode', 'selection_keys'}
830 if set(payload) != required_fields:
831 raise ValueError('template confirmation accepts only mode and selection_keys')
832 mode = payload.get('mode')
833 if mode not in {'free_design', 'templates'}:
834 raise ValueError('mode must be free_design or templates')
835 selection_keys = payload.get('selection_keys')
836 if not isinstance(selection_keys, list):
837 raise ValueError('selection_keys must be an array')
838 if any(not isinstance(key, str) or not key for key in selection_keys):
839 raise ValueError('selection_keys must contain non-empty strings')
840 if len(selection_keys) != len(set(selection_keys)):
841 raise ValueError('selection_keys must not contain duplicates')
842 if mode == 'free_design' and selection_keys:
843 raise ValueError('free_design cannot carry selection_keys')
844 if mode == 'templates' and not selection_keys:
845 raise ValueError('templates mode requires at least one selection key')
846
847 unknown_keys = [key for key in selection_keys if key not in candidates]
848 if unknown_keys:
849 raise ValueError(
850 'selection_keys contains unavailable candidate keys: '
851 + ', '.join(unknown_keys)
852 )
853 selections = sorted([
854 _template_selection_from_candidate(candidates[key])
855 for key in selection_keys
856 ], key=lambda item: (
857 item['source'],
858 item.get('kind', ''),
859 item.get('id', ''),
860 item['workspace_root'],
861 ))
862 selection_sha256 = _template_selection_sha256(
863 mode,
864 selections,
865 options_sha256,
866 )
867 receipt = {
868 'schema_version': TEMPLATE_SCHEMA_VERSION,
869 'phase': 'template',
870 'status': 'confirmed',
871 'mode': mode,
872 'selections': selections,
873 'options_sha256': options_sha256,
874 'selection_sha256': selection_sha256,
875 'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
876 }
877 _validate_template_selection(receipt)
878 return receipt
879
880
881 def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]:
882 """Return why the current Default UI lifecycle cannot launch."""
883 options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
884 result_file = confirm_dir / RESULT_NAME
885 result_stage = _result_stage(result_file)
886 fresh_template_options = _fresh_template_restart(confirm_dir)
887 if (
888 result_file.exists()
889 and result_stage is None
890 and not fresh_template_options
891 ):
892 return (
893 f'{RESULT_NAME} is not a current stage1/final receipt — reset the '
894 f'template selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
895 'starting a new run'
896 )
897 if (
898 result_stage == 'final'
899 and not fresh_template_options
900 ):
901 return (
902 'the previous Confirm UI run is complete — reset the template '
903 f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before starting a new one'
904 )
905 if options_file.exists():
906 try:
907 _build_template_options(confirm_dir)
908 selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
909 if result_stage == 'stage1' and not selection_file.exists():
910 return (
911 f'{TEMPLATE_SELECTION_NAME} not found for the confirmed '
912 'Stage 1 result'
913 )
914 if selection_file.exists():
915 _read_template_selection(selection_file)
916 except (OSError, json.JSONDecodeError, ValueError) as exc:
917 return f'invalid template selection input: {exc}'
918 if result_stage is None or fresh_template_options:
919 stage1_error = _stage1_ready_error(confirm_dir)
920 if stage1_error:
921 return f'Stage 1 is not ready: {stage1_error}'
922 return None
923 return f'{options_file} not found — write template options before launch'
924
925
926 def _server_url(port: int, path: str = '') -> str:
927 """Return the loopback URL shown to users and used by readiness probes."""
928 suffix = path if path.startswith('/') or not path else f'/{path}'
929 return f'http://{PUBLIC_HOST}:{port}{suffix}'
930
931
932 def _wait_for_server_ready(
933 port: int,
934 proc: subprocess.Popen,
935 project_path: Path,
936 timeout: int = STARTUP_TIMEOUT,
937 ) -> bool:
938 """Wait until this project's detached confirm server is accepting requests."""
939 deadline = time.time() + timeout
940 last_error = ''
941 health_url = _server_url(port, '/api/health')
942 while time.time() < deadline:
943 returncode = proc.poll()
944 if returncode is not None:
945 logger.error('confirm UI exited during startup (code=%s)', returncode)
946 return False
947 try:
948 with urllib.request.urlopen(health_url, timeout=1) as resp:
949 data = json.load(resp)
950 if (
951 resp.status == 200
952 and isinstance(data, dict)
953 and data.get('service') == 'confirm_ui'
954 and data.get('project') == str(project_path)
955 and data.get('pid') == proc.pid
956 ):
957 return True
958 last_error = 'health response belongs to another service or project'
959 except (OSError, ValueError, urllib.error.URLError) as exc:
960 last_error = str(exc)
961 time.sleep(0.2)
962 logger.error(
963 'confirm UI did not become ready at %s within %ss%s',
964 health_url,
965 timeout,
966 f' (last error: {last_error})' if last_error else '',
967 )
968 return False
969
970
971 def _launch_background_server(
972 project_path: Path,
973 *,
974 preferred_port: int,
975 exact_port: bool,
976 idle_timeout: int,
977 open_browser: bool,
978 ) -> tuple[subprocess.Popen, int, Path]:
979 """Start the confirm server child and wait until it is reachable."""
980 confirm_dir = project_path / CONFIRM_DIR_NAME
981 confirm_dir.mkdir(parents=True, exist_ok=True)
982 log_path = confirm_dir / 'server.log'
983 port = preferred_port if exact_port else _find_free_port(preferred_port)
984 cmd = [
985 sys.executable,
986 str(Path(__file__).resolve()),
987 str(project_path),
988 '--port',
989 str(port),
990 '--timeout',
991 str(idle_timeout),
992 '--no-browser',
993 ]
994 with log_path.open('a', encoding='utf-8') as log:
995 proc = _popen_detached(
996 cmd,
997 stdout=log,
998 stderr=subprocess.STDOUT,
999 stdin=subprocess.DEVNULL,
1000 logger=logger,
1001 )
1002 logger.info('log: %s', log_path)
1003 if not _wait_for_server_ready(port, proc, project_path):
1004 if proc.poll() is None:
1005 proc.terminate()
1006 raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}')
1007 _sync_session_state(confirm_dir, server_port=port, event='server-ready')
1008 url = _server_url(port)
1009 logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
1010 if open_browser:
1011 webbrowser.open(url)
1012 return proc, port, log_path
1013
1014
1015 def _live_lock(lock_file: Path) -> Optional[dict]:
1016 """Return a live lock; stale entries are overwritten by the recovered child."""
1017 existing = _read_lock(lock_file)
1018 if not existing:
1019 return None
1020 if _process_alive(_lock_pid(existing)):
1021 return existing
1022 return None
1023
1024
1025 def _preferred_recovery_port(lock_file: Path, fallback: int) -> int:
1026 """Prefer a stale lock's port so an already-open browser can reconnect."""
1027 existing = _read_lock(lock_file)
1028 try:
1029 port = int((existing or {}).get('port', 0) or 0)
1030 return _validate_port(port) if port else fallback
1031 except (TypeError, ValueError):
1032 return fallback
1033
1034
1035 def _open_browser_async(url: str, delay: float = 0.4) -> None:
1036 """Open the browser after Flask has had a moment to bind its socket."""
1037 def _open() -> None:
1038 time.sleep(delay)
1039 webbrowser.open(url)
1040
1041 threading.Thread(target=_open, daemon=True).start()
1042
1043
1044 def _wait_for_result(
1045 result_file: Path,
1046 proc: subprocess.Popen,
1047 started_at: float,
1048 timeout: int,
1049 expected_stage: str,
1050 ) -> int:
1051 """Wait until this launch writes a fresh result file or the server exits."""
1052 logger.info('waiting for browser confirmation...')
1053 deadline = None if timeout <= 0 else time.time() + timeout
1054 while True:
1055 if result_file.exists():
1056 try:
1057 if result_file.stat().st_mtime >= started_at:
1058 actual_stage = _result_stage(result_file)
1059 if actual_stage != expected_stage:
1060 logger.error(
1061 'confirmation stage mismatch: expected %s, found %s',
1062 expected_stage,
1063 actual_stage or 'invalid/absent',
1064 )
1065 return 2
1066 logger.info('confirmation received: %s', result_file)
1067 try:
1068 proc.wait(timeout=3)
1069 except subprocess.TimeoutExpired:
1070 pass
1071 return 0
1072 except OSError:
1073 pass
1074
1075 returncode = proc.poll()
1076 if returncode is not None:
1077 logger.error('confirm UI exited before a fresh result was written')
1078 return returncode or 1
1079
1080 if deadline is not None and time.time() >= deadline:
1081 logger.error(
1082 'timed out waiting for browser confirmation — the page is still '
1083 'open; re-check %s before falling back to chat', result_file,
1084 )
1085 return 124
1086
1087 time.sleep(0.5)
1088
1089
1090 def _result_stage(result_file: Path) -> Optional[str]:
1091 """Return the current result stage (Stage 1 or final), or None."""
1092 if not result_file.is_file():
1093 return None
1094 try:
1095 data = _read_json_object(result_file)
1096 except (OSError, json.JSONDecodeError, ValueError):
1097 return None
1098 stage = _stage_key(data.get('stage'))
1099 status = data.get('status')
1100 if stage == 'stage1' and status == 'stage1-confirmed':
1101 return stage
1102 if stage == 'final' and status == 'confirmed':
1103 return stage
1104 return None
1105
1106
1107 def _stage_key(value: object) -> Optional[str]:
1108 """Normalize the two current recommendation/result stage names."""
1109 if value is None:
1110 return None
1111 raw = str(value).strip().lower()
1112 if raw == 'stage1':
1113 return 'stage1'
1114 if raw == 'stage2':
1115 return 'stage2'
1116 if raw == 'final':
1117 return 'final'
1118 return None
1119
1120
1121 def _recommendation_stage(data: dict) -> int:
1122 """Return a recommendation payload's declared stage."""
1123 stage = _stage_key(data.get('stage'))
1124 if stage == 'stage1':
1125 return 1
1126 if stage == 'stage2':
1127 return 2
1128 return 0
1129
1130
1131 def _stage_name(number: Optional[int]) -> Optional[str]:
1132 """Return the canonical stage key for a stage number."""
1133 if number == 1:
1134 return 'stage1'
1135 if number == 2:
1136 return 'stage2'
1137 return None
1138
1139
1140 def _result_stage_number(stage: Optional[str]) -> int:
1141 """Return result progression: stage1=1, final=2."""
1142 if stage == 'stage1':
1143 return 1
1144 if stage == 'final':
1145 return 2
1146 return 0
1147
1148
1149 def _expected_recommendation_stage(result_stage: Optional[str]) -> int:
1150 """Return the recommendation stage that follows the current result."""
1151 if result_stage in {'stage1', 'final'}:
1152 return 2
1153 return 1
1154
1155
1156 def _stage_recommendations_path(confirm_dir: Path, stage_number: int) -> Path:
1157 """Return the stage-specific recommendation path for one handoff."""
1158 return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number]
1159
1160
1161 def _is_newer(path: Path, baseline: Path) -> bool:
1162 """Return whether ``path`` was authored after ``baseline``."""
1163 try:
1164 return path.stat().st_mtime_ns > baseline.stat().st_mtime_ns
1165 except OSError:
1166 return False
1167
1168
1169 def _fresh_template_restart(confirm_dir: Path) -> bool:
1170 """Return whether fresh template options start a new UI session."""
1171 return _is_newer(
1172 confirm_dir / TEMPLATE_OPTIONS_NAME,
1173 confirm_dir / RESULT_NAME,
1174 )
1175
1176
1177 def _active_recommendations_path(confirm_dir: Path) -> Path:
1178 """Resolve the recommendation file for the current two-stage session."""
1179 if _fresh_template_restart(confirm_dir):
1180 return _stage_recommendations_path(confirm_dir, 1)
1181 result_stage = _result_stage(confirm_dir / RESULT_NAME)
1182 expected_stage = _expected_recommendation_stage(result_stage)
1183 return _stage_recommendations_path(confirm_dir, expected_stage)
1184
1185
1186 def _read_active_recommendations(
1187 confirm_dir: Path,
1188 *,
1189 retries: int = 2,
1190 ) -> tuple[Path, dict]:
1191 """Read and validate the recommendation payload active for this stage."""
1192 rec_file = _active_recommendations_path(confirm_dir)
1193 data = _read_json_object(rec_file, retries=retries)
1194 for stage_number, filename in RECOMMENDATION_STAGE_NAMES.items():
1195 if rec_file.name != filename:
1196 continue
1197 actual_stage = _recommendation_stage(data)
1198 if actual_stage != stage_number:
1199 raise ValueError(
1200 f'{filename} must declare stage={_stage_name(stage_number)}, '
1201 f'found {_stage_name(actual_stage) or "absent"}'
1202 )
1203 if stage_number == 2:
1204 result_file = confirm_dir / RESULT_NAME
1205 if _result_stage(result_file) == 'stage1':
1206 if not _is_newer(rec_file, result_file):
1207 raise ValueError(
1208 f'{filename} must be authored after the current '
1209 'Stage-1 confirmation'
1210 )
1211 production_error = _stage2_production_recommendations_error(data)
1212 if production_error:
1213 raise ValueError(production_error)
1214 break
1215 return rec_file, data
1216
1217
1218 def _template_confirmation_required(project_path: Path) -> bool:
1219 """Return whether the confirmed project state has an active template."""
1220 confirm_dir = project_path / CONFIRM_DIR_NAME
1221 handoff = _read_template_handoff(
1222 project_path,
1223 confirm_dir / TEMPLATE_HANDOFF_NAME,
1224 )
1225 return handoff['mode'] == 'templates'
1226
1227
1228 def _template_stage2_error(
1229 recommendations: dict,
1230 *,
1231 template_required: bool,
1232 ) -> Optional[str]:
1233 """Require the natural-language template plan in template Stage 2."""
1234 if template_required and 'template_application' not in recommendations:
1235 return (
1236 'template Stage 2 recommendations must include '
1237 'template_application.value'
1238 )
1239 return None
1240
1241
1242 def _localized_text_present(candidate: dict, field: str) -> bool:
1243 """Return whether a candidate carries non-empty localized prose."""
1244 return any(
1245 isinstance(candidate.get(key), str) and bool(candidate[key].strip())
1246 for key in (field, f'{field}_zh', f'{field}_en', f'{field}_ja')
1247 )
1248
1249
1250 def _recommended_image_usage(recommendations: dict):
1251 """Return the Stage 2 image-source recommendation in either schema."""
1252 recommend = recommendations.get('recommend')
1253 usage = recommend.get('image_usage') if isinstance(recommend, dict) else None
1254 if usage is None:
1255 usage = recommendations.get('image_usage')
1256 if isinstance(usage, dict):
1257 usage = usage.get('value')
1258 return usage
1259
1260
1261 def _uses_ai_images(recommendations: dict) -> bool:
1262 """Return whether Stage 2 proposes AI-generated images."""
1263 usage = _recommended_image_usage(recommendations)
1264 return 'ai' in usage if isinstance(usage, list) else usage == 'ai'
1265
1266
1267 def _stage2_production_recommendations_error(
1268 recommendations: dict,
1269 ) -> Optional[str]:
1270 """Require every production control in current Stage 2 recommendations."""
1271 recommend = recommendations.get('recommend')
1272 if not isinstance(recommend, dict):
1273 recommend = {}
1274 for field in ('formula_policy', 'generation_mode'):
1275 value = recommend.get(field)
1276 if not isinstance(value, str) or not value.strip():
1277 return (
1278 'Stage 2 recommendations must include non-empty '
1279 f'recommend.{field}'
1280 )
1281 refine_spec = recommendations.get('refine_spec')
1282 if (
1283 not isinstance(refine_spec, dict)
1284 or not isinstance(refine_spec.get('value'), bool)
1285 ):
1286 return 'Stage 2 recommendations must include refine_spec.value as a boolean'
1287 if _uses_ai_images(recommendations):
1288 image_ai_path = recommend.get('image_ai_path')
1289 if not isinstance(image_ai_path, str) or not image_ai_path.strip():
1290 return (
1291 'Stage 2 recommendations must include non-empty '
1292 'recommend.image_ai_path when image_usage includes ai'
1293 )
1294 return None
1295
1296
1297 def _stage2_production_result_error(result: dict) -> Optional[str]:
1298 """Require every user-confirmed production control in the final payload."""
1299 for field in ('formula_policy', 'generation_mode'):
1300 value = result.get(field)
1301 if not isinstance(value, str) or not value.strip():
1302 return f'final Stage 2 payload must include non-empty {field}'
1303 if not isinstance(result.get('refine_spec'), bool):
1304 return 'final Stage 2 payload must include refine_spec as a boolean'
1305 if _uses_ai_images(result):
1306 image_ai_path = result.get('image_ai_path')
1307 if not isinstance(image_ai_path, str) or not image_ai_path.strip():
1308 return (
1309 'final Stage 2 payload must include non-empty image_ai_path '
1310 'when image_usage includes ai'
1311 )
1312 return None
1313
1314
1315 def _palette_error(color: object, label: str) -> Optional[str]:
1316 """Validate one complete user-facing palette."""
1317 if not isinstance(color, dict):
1318 return f'{label} must be an object'
1319 palette = color.get('palette')
1320 if not isinstance(palette, dict):
1321 palette = color
1322 for role in _PALETTE_ROLES:
1323 value = palette.get(role)
1324 if role == 'body_text' and value is None:
1325 value = palette.get('text')
1326 if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value.strip()):
1327 return f'{label}.palette.{role} must be a HEX color'
1328 return None
1329
1330
1331 def _positive_number(value: object) -> bool:
1332 """Return whether a JSON value is a positive finite number."""
1333 try:
1334 number = float(value)
1335 except (TypeError, ValueError):
1336 return False
1337 return number > 0 and number != float('inf')
1338
1339
1340 def _is_english_language(language: object) -> bool:
1341 """Return whether a recommendation language is an English locale."""
1342 if not isinstance(language, str):
1343 return False
1344 try:
1345 return language_base(language) == 'en'
1346 except LanguageTagError:
1347 return False
1348
1349
1350 def _recommendation_language(recommendations: dict) -> object:
1351 """Return the deck's main language without conflating it with UI ``lang``."""
1352 value = (
1353 recommendations.get('primary_language')
1354 or recommendations.get('content_language')
1355 or recommendations.get('language')
1356 )
1357 if isinstance(value, dict):
1358 return value.get('value') or value.get('id') or value.get('code') or ''
1359 return value
1360
1361
1362 def _primary_language_error(recommendations: dict) -> Optional[str]:
1363 """Require and canonicalize the staged content-language source of truth."""
1364 return _canonicalize_primary_language(recommendations, required=True)
1365
1366
1367 def _canonicalize_primary_language(
1368 recommendations: dict,
1369 *,
1370 required: bool,
1371 ) -> Optional[str]:
1372 """Write a canonical primary language into one recommendation/result object."""
1373 value = _recommendation_language(recommendations)
1374 if not isinstance(value, str) or not value.strip():
1375 if required:
1376 return (
1377 'Stage 1 recommendations must declare a valid primary_language '
1378 'BCP-47 tag; lang controls only the Confirm UI language'
1379 )
1380 return None
1381 try:
1382 recommendations['primary_language'] = normalize_language_tag(value)
1383 except LanguageTagError as exc:
1384 return f'invalid primary_language: {exc}'
1385 return None
1386
1387
1388 def _typography_font_value(
1389 font: dict,
1390 field: str,
1391 *,
1392 english_primary: bool,
1393 ) -> object:
1394 """Return a canonical typography font value, accepting its language-aware alias."""
1395 legacy_field = 'latin' if field == 'english' or english_primary else 'cjk'
1396 value = font.get(field)
1397 if not isinstance(value, str) or not value.strip():
1398 value = font.get(legacy_field)
1399 return value
1400
1401
1402 def _typography_error(
1403 typography: object,
1404 label: str,
1405 *,
1406 require_sizes: bool,
1407 main_language: object = '',
1408 ) -> Optional[str]:
1409 """Validate one complete user-facing typography recommendation or choice."""
1410 if not isinstance(typography, dict):
1411 return f'{label} must be an object'
1412 english_primary = _is_english_language(main_language)
1413 for role in ('heading', 'body'):
1414 font = typography.get(role)
1415 if not isinstance(font, dict):
1416 return f'{label}.{role} must be an object'
1417 fields = (('primary', 'latin' if english_primary else 'cjk'),)
1418 if not english_primary:
1419 fields += (('english', 'latin'),)
1420 for field, legacy_field in fields:
1421 value = _typography_font_value(
1422 font,
1423 field,
1424 english_primary=english_primary,
1425 )
1426 if not isinstance(value, str) or not value.strip():
1427 return (
1428 f'{label}.{role}.{field} '
1429 f'(or legacy {legacy_field}) must be non-empty'
1430 )
1431 if not isinstance(font.get('css'), str) or not font['css'].strip():
1432 return f'{label}.{role}.css must be non-empty'
1433 if not _positive_number(typography.get('body_size')):
1434 return f'{label}.body_size must be a positive number'
1435 if not require_sizes:
1436 return None
1437 sizes = typography.get('sizes')
1438 if not isinstance(sizes, dict):
1439 return f'{label}.sizes must be an object'
1440 for role in _TYPOGRAPHY_SIZE_ROLES:
1441 if not _positive_number(sizes.get(role)):
1442 return f'{label}.sizes.{role} must be a positive number'
1443 return None
1444
1445
1446 def _typography_signature(
1447 typography: dict,
1448 *,
1449 main_language: object,
1450 ) -> tuple[str, ...]:
1451 """Return the language-relevant font choices that distinguish one candidate."""
1452 english_primary = _is_english_language(main_language)
1453 fields = ('primary',) if english_primary else ('primary', 'english')
1454 values = []
1455 for role in ('heading', 'body'):
1456 font = typography[role]
1457 for field in fields:
1458 value = _typography_font_value(
1459 font,
1460 field,
1461 english_primary=english_primary,
1462 )
1463 values.append(str(value).strip().casefold())
1464 return tuple(values)
1465
1466
1467 def _typography_candidates_fixed_error(
1468 candidates: list,
1469 *,
1470 main_language: object,
1471 ) -> Optional[str]:
1472 """Reject contradictions in an explicitly fixed typography contract."""
1473 fixed = [
1474 isinstance(candidate, dict) and candidate.get('fixed') is True
1475 for candidate in candidates
1476 ]
1477 if any(fixed):
1478 if not all(fixed):
1479 return 'typography.fixed must be true on every candidate or omitted'
1480 signatures = [
1481 _typography_signature(
1482 candidate,
1483 main_language=main_language,
1484 )
1485 for candidate in candidates
1486 ]
1487 if any(signature != signatures[0] for signature in signatures[1:]):
1488 return 'fixed typography candidates must repeat the same font combination'
1489 return None
1490 return None
1491
1492
1493 def _candidate_list(spec: object) -> list:
1494 """Return candidates from the current or legacy recommendation shape."""
1495 if not isinstance(spec, dict):
1496 return []
1497 candidates = spec.get('candidates')
1498 if not isinstance(candidates, list):
1499 candidates = spec.get('options')
1500 return candidates if isinstance(candidates, list) else []
1501
1502
1503 def _stage2_design_directions_error(
1504 recommendations: dict,
1505 *,
1506 main_language: object = '',
1507 ) -> Optional[str]:
1508 """Require three complete coordinated Stage 2 design systems."""
1509 main_language = main_language or _recommendation_language(recommendations)
1510 directions = recommendations.get('design_directions')
1511 if isinstance(directions, dict):
1512 candidates = _candidate_list(directions)
1513 if len(candidates) < 3:
1514 return 'Stage 2 design_directions must include at least 3 candidates'
1515 typography_candidates = []
1516 for index, candidate in enumerate(candidates, start=1):
1517 label = f'design_directions.candidates[{index - 1}]'
1518 if not isinstance(candidate, dict):
1519 return f'{label} must be an object'
1520 if not _localized_text_present(candidate, 'name'):
1521 return f'{label} requires a non-empty localized name'
1522 for field in ('visual_style', 'icons'):
1523 if not isinstance(candidate.get(field), str) or not candidate[field].strip():
1524 return f'{label}.{field} must be non-empty'
1525 error = _palette_error(candidate.get('color'), f'{label}.color')
1526 if error:
1527 return error
1528 error = _typography_error(
1529 candidate.get('typography'),
1530 f'{label}.typography',
1531 require_sizes=False,
1532 main_language=main_language,
1533 )
1534 if error:
1535 return error
1536 typography_candidates.append(candidate['typography'])
1537 if _uses_ai_images(recommendations):
1538 image_strategy = candidate.get('image_strategy')
1539 if not isinstance(image_strategy, dict) or not str(
1540 image_strategy.get('rendering') or ''
1541 ).strip():
1542 return f'{label}.image_strategy.rendering must be non-empty'
1543 return _typography_candidates_fixed_error(
1544 typography_candidates,
1545 main_language=main_language,
1546 )
1547
1548 # Legacy staged files remain readable, but they must still provide three
1549 # complete color combinations and at least one complete typography choice.
1550 colors = _candidate_list(recommendations.get('color'))
1551 if len(colors) < 3:
1552 return 'Stage 2 recommendations must include 3 complete color candidates'
1553 for index, color in enumerate(colors):
1554 error = _palette_error(color, f'color.candidates[{index}]')
1555 if error:
1556 return error
1557 typography = _candidate_list(recommendations.get('typography'))
1558 if not typography:
1559 return 'Stage 2 recommendations must include typography candidates'
1560 if main_language and len(typography) < 3:
1561 return 'Stage 2 recommendations must include 3 typography candidates'
1562 for index, candidate in enumerate(typography):
1563 error = _typography_error(
1564 candidate,
1565 f'typography.candidates[{index}]',
1566 require_sizes=False,
1567 main_language=main_language,
1568 )
1569 if error:
1570 return error
1571 if main_language:
1572 return _typography_candidates_fixed_error(
1573 typography,
1574 main_language=main_language,
1575 )
1576 return None
1577
1578
1579 def _stage2_custom_candidates_error(recommendations: dict) -> Optional[str]:
1580 """Require visible AI-authored custom alternatives in new Stage 2 files."""
1581 candidates = recommendations.get('custom_candidates')
1582 if not isinstance(candidates, dict):
1583 return 'Stage 2 recommendations must include custom_candidates'
1584
1585 for field in ('mode', 'visual_style'):
1586 candidate = candidates.get(field)
1587 if not isinstance(candidate, dict):
1588 return f'custom_candidates.{field} must be an object'
1589 for prose_field in ('name', 'behavior'):
1590 if not _localized_text_present(candidate, prose_field):
1591 return (
1592 f'custom_candidates.{field} requires non-empty localized '
1593 f'{prose_field}'
1594 )
1595
1596 if not _uses_ai_images(recommendations):
1597 return None
1598
1599 image_candidate = candidates.get('image_strategy')
1600 if not isinstance(image_candidate, dict):
1601 return 'custom_candidates.image_strategy must be an object when image_usage includes ai'
1602 if image_candidate.get('rendering') != 'custom':
1603 return 'custom_candidates.image_strategy.rendering must be custom'
1604 for prose_field in ('name', 'visual', 'mood', 'behavior'):
1605 if not _localized_text_present(image_candidate, prose_field):
1606 return (
1607 'custom_candidates.image_strategy requires non-empty localized '
1608 f'{prose_field}'
1609 )
1610 return None
1611
1612
1613 def _submission_stage_error(
1614 confirm_dir: Path,
1615 submitted_stage: Optional[str],
1616 *,
1617 recommendations_file: Path,
1618 recommendations: dict,
1619 template_required: bool,
1620 ) -> Optional[str]:
1621 """Reject a confirmation that does not match the staged recommendation."""
1622 rec_stage_number = _recommendation_stage(recommendations)
1623 if rec_stage_number == 0:
1624 return 'recommendations must declare stage1 or stage2'
1625
1626 if rec_stage_number == 1:
1627 language_error = _primary_language_error(recommendations)
1628 if language_error:
1629 return language_error
1630
1631 if rec_stage_number == 2:
1632 try:
1633 previous_result = _read_json_object(confirm_dir / RESULT_NAME)
1634 except (OSError, json.JSONDecodeError, ValueError):
1635 previous_result = {}
1636 language_source = (
1637 previous_result
1638 if _recommendation_language(previous_result)
1639 else recommendations
1640 )
1641 language_error = _canonicalize_primary_language(
1642 language_source,
1643 required=True,
1644 )
1645 if language_error:
1646 return language_error
1647 main_language = _recommendation_language(language_source)
1648 recommendation_error = _template_stage2_error(
1649 recommendations,
1650 template_required=template_required,
1651 )
1652 if recommendation_error:
1653 return recommendation_error
1654 recommendation_error = _stage2_custom_candidates_error(recommendations)
1655 if recommendation_error:
1656 return recommendation_error
1657 recommendation_error = _stage2_design_directions_error(
1658 recommendations,
1659 main_language=main_language,
1660 )
1661 if recommendation_error:
1662 return recommendation_error
1663
1664 allowed_submissions = {
1665 1: {'stage1'},
1666 2: {'final'},
1667 }
1668 if submitted_stage not in allowed_submissions[rec_stage_number]:
1669 expected = 'final' if rec_stage_number == 2 else 'stage1'
1670 return (
1671 f'confirmation stage mismatch: {recommendations_file.name} is '
1672 f'{_stage_name(rec_stage_number)}, so the submitted stage must be '
1673 f'{expected}'
1674 )
1675
1676 previous_stage = (
1677 None
1678 if _fresh_template_restart(confirm_dir)
1679 else _result_stage(confirm_dir / RESULT_NAME)
1680 )
1681 allowed_predecessors = {
1682 1: {None},
1683 2: {'stage1'},
1684 }
1685 if previous_stage not in allowed_predecessors[rec_stage_number]:
1686 expected_previous = 'stage1' if rec_stage_number == 2 else 'no prior result'
1687 return (
1688 f'confirmation predecessor mismatch: {_stage_name(rec_stage_number)} '
1689 f'requires a confirmed {expected_previous} result, found '
1690 f'{previous_stage or "absent"}'
1691 )
1692 return None
1693
1694
1695 def _custom_selection_error(result: dict) -> Optional[str]:
1696 """Require behavior prose whenever a creative custom choice is selected."""
1697 if result.get('mode') == 'custom' and not str(
1698 result.get('mode_behavior') or ''
1699 ).strip():
1700 return 'mode=custom requires non-empty mode_behavior'
1701 if result.get('visual_style') == 'custom' and not str(
1702 result.get('visual_style_behavior') or ''
1703 ).strip():
1704 return 'visual_style=custom requires non-empty visual_style_behavior'
1705 image_strategy = result.get('image_strategy')
1706 if isinstance(image_strategy, dict) and image_strategy.get('rendering') == 'custom':
1707 behavior = image_strategy.get('behavior') or image_strategy.get('custom')
1708 if not str(behavior or '').strip():
1709 return 'image_strategy.rendering=custom requires non-empty behavior'
1710 return None
1711
1712
1713 def _stage2_solution_error(
1714 result: dict,
1715 *,
1716 main_language: object = '',
1717 ) -> Optional[str]:
1718 """Reject a Stage 2/final payload with an incomplete design system."""
1719 production_error = _stage2_production_result_error(result)
1720 if production_error:
1721 return production_error
1722
1723 color = result.get('color')
1724 color_error = _palette_error(color, 'color')
1725 color_custom = (
1726 isinstance(color, dict)
1727 and color.get('name') == 'custom'
1728 and str(color.get('custom') or '').strip()
1729 )
1730 if color_error and not color_custom:
1731 return color_error
1732
1733 typography = result.get('typography')
1734 typography_error = _typography_error(
1735 typography,
1736 'typography',
1737 require_sizes=True,
1738 main_language=main_language,
1739 )
1740 if typography_error:
1741 return typography_error
1742
1743 if _uses_ai_images(result):
1744 image_strategy = result.get('image_strategy')
1745 if not isinstance(image_strategy, dict) or not str(
1746 image_strategy.get('rendering') or ''
1747 ).strip():
1748 return 'image_usage includes ai, so image_strategy.rendering must be non-empty'
1749 rendering = image_strategy['rendering'].strip()
1750 if rendering != 'custom' and rendering not in _ai_rendering_ids():
1751 return f'image_strategy.rendering is not a known preset: {rendering}'
1752 return None
1753
1754
1755 def _normalize_custom_selections(result: dict) -> None:
1756 """Keep custom prose only for the creative choices actually selected."""
1757 if result.get('mode') != 'custom':
1758 result.pop('mode_behavior', None)
1759 if result.get('visual_style') != 'custom':
1760 result.pop('visual_style_behavior', None)
1761
1762 image_strategy = result.get('image_strategy')
1763 if not isinstance(image_strategy, dict):
1764 return
1765 legacy_behavior = image_strategy.pop('custom', None)
1766 if image_strategy.get('rendering') == 'custom':
1767 if not image_strategy.get('behavior') and legacy_behavior:
1768 image_strategy['behavior'] = legacy_behavior
1769 return
1770 image_strategy.pop('behavior', None)
1771
1772
1773 def _expected_result_stage(confirm_dir: Path) -> str:
1774 """Return the result stage expected from the current recommendations."""
1775 try:
1776 _, recommendations = _read_active_recommendations(confirm_dir)
1777 except (OSError, json.JSONDecodeError, ValueError):
1778 return 'final'
1779 return {
1780 1: 'stage1',
1781 2: 'final',
1782 }.get(_recommendation_stage(recommendations), 'final')
1783
1784
1785 def _file_version(path: Path) -> Optional[float]:
1786 """Return a cheap file version for polling state, or None when absent."""
1787 try:
1788 return path.stat().st_mtime
1789 except OSError:
1790 return None
1791
1792
1793 def _read_session(confirm_dir: Path) -> dict:
1794 """Read session.json if present, returning an object."""
1795 session_file = confirm_dir / SESSION_NAME
1796 if not session_file.exists():
1797 return {}
1798 try:
1799 return _read_json_object(session_file)
1800 except (OSError, json.JSONDecodeError, ValueError):
1801 return {}
1802
1803
1804 def _build_session_state(
1805 confirm_dir: Path,
1806 *,
1807 server_port: Optional[int] = None,
1808 event: Optional[str] = None,
1809 ) -> dict:
1810 """Derive the resumable Confirm UI state from disk artifacts."""
1811 previous = _read_session(confirm_dir)
1812 rec_file = _active_recommendations_path(confirm_dir)
1813 result_file = confirm_dir / RESULT_NAME
1814
1815 rec_stage_number = 0
1816 rec_stage = None
1817 rec_error = None
1818 if rec_file.exists():
1819 try:
1820 rec_file, rec_data = _read_active_recommendations(confirm_dir)
1821 rec_stage_number = _recommendation_stage(rec_data)
1822 rec_stage = _stage_name(rec_stage_number)
1823 except (OSError, json.JSONDecodeError, ValueError) as exc:
1824 rec_error = str(exc)
1825
1826 fresh_restart = _fresh_template_restart(confirm_dir)
1827 result_stage = None if fresh_restart else _result_stage(result_file)
1828 result_stage_number = _result_stage_number(result_stage)
1829
1830 if result_stage == 'final':
1831 expected_stage_number = None
1832 status = 'done'
1833 current_stage = 'final'
1834 elif result_stage == 'stage1':
1835 expected_stage_number = 2
1836 ready = rec_stage_number == 2
1837 status = 'ready_user' if ready else 'waiting_agent'
1838 current_stage = 'stage2' if ready else 'stage1'
1839 else:
1840 expected_stage_number = 1
1841 ready = rec_stage_number == 1
1842 status = 'ready_user' if ready else 'waiting_agent'
1843 current_stage = 'stage1'
1844
1845 session = {
1846 'phase': 'strategist',
1847 'status': status,
1848 'current_stage': current_stage,
1849 'expected_stage': _stage_name(expected_stage_number),
1850 'expected_stage_number': expected_stage_number,
1851 'recommendation_stage': rec_stage,
1852 'recommendation_stage_number': rec_stage_number,
1853 'recommendation_file': rec_file.name,
1854 'recommendation_version': _file_version(rec_file),
1855 'recommendation_error': rec_error,
1856 'result_stage': result_stage,
1857 'result_stage_number': result_stage_number,
1858 'result_version': _file_version(result_file),
1859 'server_port': server_port or previous.get('server_port'),
1860 'event': event or previous.get('event') or 'derived',
1861 }
1862
1863 options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
1864 selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
1865 handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
1866 session.update({
1867 'template_options_file': TEMPLATE_OPTIONS_NAME,
1868 'template_options_version': _file_version(options_file),
1869 'template_selection_file': TEMPLATE_SELECTION_NAME,
1870 'template_selection_version': _file_version(selection_file),
1871 'template_handoff_file': TEMPLATE_HANDOFF_NAME,
1872 'template_handoff_version': _file_version(handoff_file),
1873 })
1874
1875 if result_stage == 'final':
1876 session.update({
1877 'template_status': 'ready',
1878 'template_error': None,
1879 })
1880 return session
1881
1882 if result_stage is None:
1883 ready_error = _stage1_ready_error(confirm_dir)
1884 if ready_error:
1885 session.update({
1886 'status': 'error',
1887 'template_status': 'error',
1888 'template_error': ready_error,
1889 })
1890 if not session.get('recommendation_error'):
1891 session['recommendation_error'] = ready_error
1892 return session
1893 session.update({
1894 'template_status': 'ready_user',
1895 'template_error': None,
1896 })
1897 return session
1898
1899 ready_error = _stage2_ready_error(
1900 confirm_dir.parent,
1901 confirm_dir,
1902 rec_file if rec_stage_number == 2 else None,
1903 )
1904 if ready_error:
1905 session.update({
1906 'status': 'waiting_agent',
1907 'current_stage': 'stage1',
1908 'expected_stage': 'stage2',
1909 'expected_stage_number': 2,
1910 'template_status': 'waiting_agent',
1911 'template_error': ready_error,
1912 })
1913 return session
1914 session.update({
1915 'template_status': 'ready',
1916 'template_error': None,
1917 })
1918 return session
1919
1920
1921 def _write_session_state(confirm_dir: Path, session: dict) -> None:
1922 """Persist session.json only when stable state changes."""
1923 previous = _read_session(confirm_dir)
1924 comparable_previous = dict(previous)
1925 comparable_current = dict(session)
1926 comparable_previous.pop('updated_at', None)
1927 comparable_current.pop('updated_at', None)
1928 if comparable_previous == comparable_current:
1929 return
1930 session = dict(session)
1931 session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
1932 _write_json_atomic(confirm_dir / SESSION_NAME, session)
1933
1934
1935 def _sync_session_state(
1936 confirm_dir: Path,
1937 *,
1938 server_port: Optional[int] = None,
1939 event: Optional[str] = None,
1940 ) -> dict:
1941 """Derive and persist the current session state."""
1942 session = _build_session_state(
1943 confirm_dir,
1944 server_port=server_port,
1945 event=event,
1946 )
1947 _write_session_state(confirm_dir, session)
1948 return session
1949
1950
1951 # Earlier-stage choices are not rendered on later pages, so their values live
1952 # only in browser STATE and would be lost on an in-run refresh. Fold them from
1953 # result.json into Stage-2 recommendations so the same live run resumes from the
1954 # user's actual communication contract and complete deck-solution choices.
1955 _CONTRACT_RECOMMEND_KEYS = (
1956 'canvas',
1957 )
1958 _CONTRACT_VALUE_KEYS = (
1959 'audience',
1960 'communication_intent',
1961 'audience_outcome',
1962 'core_message',
1963 'delivery_context',
1964 'artifact_afterlife',
1965 'content_divergence',
1966 )
1967 _PROACTIVE_EXECUTION_DEFAULTS = {
1968 'proactive_speaker_notes': True,
1969 'proactive_custom_animations': False,
1970 'proactive_narration_audio': False,
1971 }
1972 _LOCKED_RECOMMENDATIONS_KEY = '_locked_recommendations'
1973
1974
1975 def _resolve_proactive_execution_values(
1976 source: dict,
1977 ) -> tuple[dict[str, bool], Optional[str]]:
1978 """Resolve proactive-execution booleans with backward-compatible defaults."""
1979 values = {}
1980 for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items():
1981 if key not in source:
1982 values[key] = default
1983 continue
1984 raw_value = source[key]
1985 if isinstance(raw_value, dict):
1986 if 'value' not in raw_value or not isinstance(raw_value['value'], bool):
1987 return {}, f'{key}.value must be a boolean'
1988 raw_value = raw_value['value']
1989 elif not isinstance(raw_value, bool):
1990 return {}, f'{key} must be a boolean'
1991 values[key] = raw_value
1992 return values, None
1993
1994
1995 def _normalize_proactive_execution_result(
1996 result: dict,
1997 defaults: dict[str, bool],
1998 ) -> Optional[str]:
1999 """Write the independent raw confirmation booleans to the final result."""
2000 values = {}
2001 for key, default in defaults.items():
2002 value = result.get(key, default)
2003 if not isinstance(value, bool):
2004 return f'{key} must be a boolean'
2005 values[key] = value
2006 result.update(values)
2007 return None
2008
2009
2010 def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
2011 """Fold already-confirmed choices into later-stage recommendations."""
2012 try:
2013 res = _read_json_object(result_file)
2014 except (OSError, json.JSONDecodeError, ValueError):
2015 return
2016 if _result_stage(result_file) != 'stage1':
2017 return
2018 recommend = data.setdefault('recommend', {})
2019 if not isinstance(recommend, dict):
2020 recommend = data['recommend'] = {}
2021 main_language = _recommendation_language(res)
2022 if main_language:
2023 try:
2024 data['primary_language'] = normalize_language_tag(main_language)
2025 except LanguageTagError:
2026 # Keep the invalid legacy value visible to the API boundary below,
2027 # which returns a user-facing contract error instead of hiding it.
2028 data['primary_language'] = main_language
2029 for key in _CONTRACT_RECOMMEND_KEYS:
2030 if res.get(key) not in (None, ''):
2031 recommend[key] = res[key]
2032 for key in _CONTRACT_VALUE_KEYS:
2033 if key in res:
2034 data[key] = {'value': res.get(key) or ''}
2035
2036
2037 def _apply_locked_recommendations(
2038 result: dict,
2039 recommendations_file: Path,
2040 previous_result_file: Path,
2041 *,
2042 carry_previous: bool,
2043 ) -> dict:
2044 """Restore profile-locked fields and return locks for staged carry-over."""
2045 # This marker is server-owned; never accept a client-supplied carry-over map.
2046 result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
2047 locked_values = {}
2048 previous = {}
2049 if carry_previous:
2050 try:
2051 previous = _read_json_object(previous_result_file)
2052 except (OSError, json.JSONDecodeError, ValueError):
2053 previous = {}
2054 previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY)
2055 if isinstance(previous_locks, dict):
2056 locked_values.update(previous_locks)
2057 for key in _PROACTIVE_EXECUTION_DEFAULTS:
2058 locked_values.pop(key, None)
2059
2060 try:
2061 recommendations = _read_json_object(recommendations_file)
2062 recommendations_loaded = True
2063 except (OSError, json.JSONDecodeError, ValueError):
2064 recommendations = {}
2065 recommendations_loaded = False
2066
2067 # Stage 1 starts a new contract and therefore replaces any stale locks left
2068 # by an earlier run. Later stages inherit those locks across server restarts.
2069 if recommendations_loaded and _recommendation_stage(recommendations) == 1:
2070 locked_values = {}
2071 for key, field in recommendations.items():
2072 if key in _PROACTIVE_EXECUTION_DEFAULTS:
2073 continue
2074 if not isinstance(field, dict) or field.get('locked') is not True:
2075 continue
2076 if 'value' in field:
2077 locked_values[key] = field['value']
2078 for key, value in locked_values.items():
2079 result[key] = value
2080 return locked_values
2081
2082
2083 def _wait_only_for_result(
2084 result_file: Path,
2085 lock_file: Path,
2086 timeout: int,
2087 target_stage: str = 'final',
2088 ) -> int:
2089 """Attach to an already-running confirm server and wait for a target stage.
2090
2091 No child is launched here: the page is open from the preceding ``--daemon``
2092 launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
2093 Only the stage guard is used (no mtime gate), because intermediate submits
2094 may happen before this wait command is issued.
2095 """
2096 logger.info('waiting for browser confirmation stage=%s...', target_stage)
2097 deadline = None if timeout <= 0 else time.time() + timeout
2098 while True:
2099 result_status = _wait_result_status(result_file, target_stage)
2100 if result_status is not None:
2101 return result_status
2102
2103 confirm_dir = result_file.parent
2104 if target_stage == 'stage1':
2105 readiness_error = _stage1_ready_error(confirm_dir)
2106 else:
2107 recommendations_file = _active_recommendations_path(confirm_dir)
2108 readiness_error = _stage2_ready_error(
2109 confirm_dir.parent,
2110 confirm_dir,
2111 recommendations_file,
2112 )
2113 if readiness_error:
2114 logger.error(
2115 'confirmation stage=%s is not ready: %s',
2116 target_stage,
2117 readiness_error,
2118 )
2119 return 1
2120
2121 lock = _read_lock(lock_file)
2122 pid = _lock_pid(lock)
2123 if not pid or not _process_alive(pid):
2124 logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
2125 return 1
2126
2127 if deadline is not None and time.time() >= deadline:
2128 logger.error(
2129 'timed out waiting for confirmation stage=%s — the page may still '
2130 'be open; re-check %s before falling back to chat', target_stage, result_file,
2131 )
2132 return 124
2133
2134 time.sleep(0.5)
2135
2136
2137 def _wait_result_status(
2138 result_file: Path,
2139 target_stage: str,
2140 ) -> Optional[int]:
2141 """Return a terminal wait status when the persisted result resolves the target."""
2142 if _fresh_template_restart(result_file.parent):
2143 return None
2144 current_stage = _result_stage(result_file)
2145 if current_stage == target_stage:
2146 if target_stage == 'stage1':
2147 try:
2148 _read_template_selection(
2149 result_file.parent / TEMPLATE_SELECTION_NAME,
2150 )
2151 except (OSError, json.JSONDecodeError, ValueError) as exc:
2152 logger.error(
2153 'Stage 1 result has no valid template selection: %s',
2154 exc,
2155 )
2156 return 2
2157 logger.info('confirmation stage=%s received: %s', target_stage, result_file)
2158 return 0
2159 if _result_stage_number(current_stage) > _result_stage_number(target_stage):
2160 logger.error(
2161 'confirmation skipped expected stage=%s and advanced to %s',
2162 target_stage,
2163 current_stage,
2164 )
2165 return 2
2166 return None
2167
2168
2169 def _shutdown_existing(lock_file: Path) -> int:
2170 """Stop a confirm server left running for this project (idempotent).
2171
2172 Step 4 always calls this on exit so the page never lingers on its selected
2173 port. Tries a graceful ``/api/shutdown`` first, falls back to killing the
2174 recorded pid, then clears the lock. A no-op when nothing is running.
2175 """
2176 existing = _read_lock(lock_file)
2177 if not existing:
2178 logger.info('no confirm server running — nothing to stop')
2179 return 0
2180 pid = _lock_pid(existing)
2181 port = existing.get('port')
2182 if not _process_alive(pid):
2183 _clear_lock(lock_file)
2184 logger.info('confirm server already stopped; cleared stale lock')
2185 return 0
2186 # Graceful first: the server flushes and releases its own lock.
2187 if port:
2188 try:
2189 req = urllib.request.Request(
2190 f'http://127.0.0.1:{port}/api/shutdown',
2191 data=b'{"reason": "step4-cleanup"}',
2192 headers={'Content-Type': 'application/json'},
2193 method='POST',
2194 )
2195 urllib.request.urlopen(req, timeout=3)
2196 except OSError:
2197 pass # server may already be exiting; fall through to the kill path
2198 for _ in range(20): # up to ~2s for the graceful exit to land
2199 if not _process_alive(pid):
2200 break
2201 time.sleep(0.1)
2202 if _process_alive(pid):
2203 try:
2204 os.kill(pid, signal.SIGTERM)
2205 except OSError:
2206 pass
2207 _clear_lock(lock_file)
2208 logger.info('confirm server stopped (pid=%s)', pid)
2209 return 0
2210
2211
2212 def _build_catalogs() -> dict:
2213 """Return the static catalog set with the canvas list synced live from
2214 ``config.CANVAS_FORMATS`` — the single source of truth for canvas formats —
2215 so the confirm page can never drift from the pipeline's real formats. The
2216 set of formats and their dimensions come from config; trilingual labels and
2217 use text are kept from catalogs.json (with a plain fallback for any new id).
2218 """
2219 data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8'))
2220 try:
2221 import config # scripts/ is on sys.path (injected at import time)
2222 formats = config.CANVAS_FORMATS
2223 except (ImportError, AttributeError): # missing module/attr → static canvas
2224 return data
2225 existing = {
2226 c.get('id'): c
2227 for c in data.get('canvas', [])
2228 if isinstance(c, dict) and c.get('id')
2229 }
2230 canvas = []
2231 for cid, fmt in formats.items():
2232 entry = dict(existing.get(cid, {}))
2233 entry['id'] = cid
2234 entry['dim'] = fmt.get('dimensions', entry.get('dim', ''))
2235 if not entry.get('label'):
2236 name = fmt.get('name', cid)
2237 entry['label'] = name
2238 entry.setdefault('label_zh', name)
2239 entry.setdefault('label_en', name)
2240 if not entry.get('use_en') and fmt.get('use_case'):
2241 entry['use_en'] = fmt['use_case']
2242 canvas.append(entry)
2243 data['canvas'] = canvas
2244 return data
2245
2246
2247 def _icon_preview_svg(library: str, name: str) -> str:
2248 """Read a trusted sample SVG from the bundled icon templates."""
2249 icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg'
2250 raw = icon_path.read_text(encoding='utf-8')
2251 raw = re.sub(r'<\?xml[^>]*>\s*', '', raw)
2252 raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S)
2253 return raw.strip()
2254
2255
2256 def _build_icon_previews() -> dict:
2257 previews = {}
2258 for library, names in _ICON_PREVIEW_SAMPLES.items():
2259 items = []
2260 for name in names:
2261 try:
2262 items.append({'name': name, 'svg': _icon_preview_svg(library, name)})
2263 except OSError as exc:
2264 logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc)
2265 previews[library] = items
2266 return previews
2267
2268
2269 def _ai_comparison_items(kind: str) -> list[dict[str, str]]:
2270 manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json'
2271 if not manifest.exists():
2272 return []
2273 data = json.loads(manifest.read_text(encoding='utf-8'))
2274 items = []
2275 for item in data.get('items', []):
2276 filename = item.get('filename')
2277 if not isinstance(filename, str) or not filename.endswith('.png'):
2278 continue
2279 if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename):
2280 continue
2281 if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists():
2282 continue
2283 item_id = Path(filename).stem
2284 items.append({
2285 'id': item_id,
2286 'label': item.get('type') or item_id,
2287 'filename': filename,
2288 'purpose': item.get('purpose') or '',
2289 'alt_text': item.get('alt_text') or '',
2290 })
2291 return items
2292
2293
2294 def _ai_rendering_ids() -> set[str]:
2295 """Return the rendering presets exposed by the confirmation UI."""
2296 return {item['id'] for item in _ai_comparison_items('rendering')}
2297
2298
2299 def _build_ai_image_comparison() -> dict:
2300 return {
2301 'rendering': _ai_comparison_items('rendering'),
2302 }
2303
2304
2305 # --- app --------------------------------------------------------------------
2306
2307 def create_app(
2308 project_dir: str,
2309 idle_timeout: int = 900,
2310 lock_file: Optional[Path] = None,
2311 server_port: Optional[int] = None,
2312 ) -> Flask:
2313 """Create and configure the Flask app for a given project directory."""
2314 project_path = Path(project_dir).resolve()
2315 confirm_dir = project_path / CONFIRM_DIR_NAME
2316
2317 app = Flask(__name__, static_folder='static', static_url_path='/static')
2318 app.config['PROJECT_PATH'] = project_path
2319 app.config['CONFIRM_DIR'] = confirm_dir
2320 app.config['LOCK_FILE'] = lock_file
2321 app.config['SERVER_PORT'] = server_port
2322 app.config['LAST_REQUEST_TIME'] = time.time()
2323
2324 @app.before_request
2325 def _update_activity():
2326 app.config['LAST_REQUEST_TIME'] = time.time()
2327
2328 def _exit_with_lock_release(code: int = 0) -> None:
2329 lf = app.config.get('LOCK_FILE')
2330 if lf is not None:
2331 _release_lock(lf)
2332 os._exit(code)
2333
2334 def _idle_watchdog():
2335 if idle_timeout <= 0:
2336 return
2337 while True:
2338 time.sleep(10)
2339 elapsed = time.time() - app.config['LAST_REQUEST_TIME']
2340 if elapsed > idle_timeout:
2341 logger.info('idle for %ds, shutting down', idle_timeout)
2342 _exit_with_lock_release(0)
2343
2344 watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
2345 watchdog.start()
2346
2347 @app.route('/api/shutdown', methods=['POST'])
2348 def shutdown():
2349 data = request.get_json(silent=True) or {}
2350 reason = data.get('reason') or 'shutdown'
2351
2352 def _stop():
2353 time.sleep(0.5) # let HTTP response flush before killing the process
2354 logger.info('shutting down (%s)', reason)
2355 _exit_with_lock_release(0)
2356 threading.Thread(target=_stop, daemon=True).start()
2357 return jsonify({'status': 'ok'})
2358
2359 @app.route('/')
2360 def index():
2361 return send_from_directory(app.static_folder, 'index.html')
2362
2363 @app.route('/api/health')
2364 def health():
2365 """Expose a cheap readiness probe for the daemon launcher."""
2366 rec_file = _active_recommendations_path(confirm_dir)
2367 rec_ok = False
2368 stage = None
2369 if rec_file.exists():
2370 try:
2371 rec_file, rec_data = _read_active_recommendations(
2372 confirm_dir,
2373 retries=0,
2374 )
2375 rec_ok = True
2376 stage = _recommendation_stage(rec_data)
2377 except (OSError, json.JSONDecodeError, ValueError):
2378 rec_ok = False
2379 resp = jsonify({
2380 'status': 'ok',
2381 'service': 'confirm_ui',
2382 'pid': os.getpid(),
2383 'project': str(project_path),
2384 'recommendations': rec_ok,
2385 'stage': stage,
2386 'session': _build_session_state(
2387 confirm_dir,
2388 server_port=app.config.get('SERVER_PORT'),
2389 ),
2390 })
2391 resp.headers['Cache-Control'] = 'no-store'
2392 return resp
2393
2394 @app.route('/api/session')
2395 def get_session():
2396 """Expose the derived template/Strategist wizard state for polling."""
2397 session = _sync_session_state(
2398 confirm_dir,
2399 server_port=app.config.get('SERVER_PORT'),
2400 event='poll',
2401 )
2402 resp = jsonify(session)
2403 resp.headers['Cache-Control'] = 'no-store'
2404 return resp
2405
2406 @app.route('/api/catalogs')
2407 def get_catalogs():
2408 """Serve the option universe; canvas is synced live from config.py so
2409 the static catalogs.json copy can never drift from the real formats."""
2410 try:
2411 resp = jsonify(_build_catalogs())
2412 resp.headers['Cache-Control'] = 'no-store'
2413 return resp
2414 except (OSError, json.JSONDecodeError) as exc:
2415 return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500
2416
2417 @app.route('/api/icon-previews')
2418 def get_icon_previews():
2419 """Serve real sample icons from templates/icons for the icon chooser."""
2420 resp = jsonify(_build_icon_previews())
2421 resp.headers['Cache-Control'] = 'no-store'
2422 return resp
2423
2424 @app.route('/api/ai-image-comparison')
2425 def get_ai_image_comparison_manifest():
2426 """Serve generated-image rendering references for the current UI."""
2427 try:
2428 resp = jsonify(_build_ai_image_comparison())
2429 resp.headers['Cache-Control'] = 'no-store'
2430 return resp
2431 except (OSError, json.JSONDecodeError) as exc:
2432 return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500
2433
2434 @app.route('/ai-image-comparison/<kind>/<filename>')
2435 def get_ai_image_comparison(kind: str, filename: str):
2436 """Serve rendering images for generated-image strategy candidates."""
2437 if kind != 'rendering':
2438 return jsonify({'error': 'invalid comparison kind'}), 404
2439 if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''):
2440 return jsonify({'error': 'invalid comparison filename'}), 404
2441 return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename)
2442
2443 @app.route('/api/recommendations')
2444 def get_recommendations():
2445 """Serve the Strategist-authored recommendations for this project."""
2446 result_file = confirm_dir / RESULT_NAME
2447 if (
2448 _result_stage(result_file) == 'final'
2449 and not _fresh_template_restart(confirm_dir)
2450 ):
2451 return jsonify({
2452 'error': (
2453 'the current Confirm UI run is complete; reset the template '
2454 f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
2455 'starting another'
2456 ),
2457 }), 409
2458 rec_file = _active_recommendations_path(confirm_dir)
2459 if not rec_file.exists():
2460 return jsonify({'error': f'{rec_file.name} not found'}), 404
2461 try:
2462 rec_file, data = _read_active_recommendations(confirm_dir)
2463 except (OSError, json.JSONDecodeError, ValueError) as exc:
2464 return jsonify({
2465 'error': f'invalid current recommendation file: {exc}',
2466 }), 400
2467 rec_stage_number = _recommendation_stage(data)
2468 if rec_stage_number == 1:
2469 stage1_error = _stage1_ready_error(confirm_dir)
2470 if stage1_error:
2471 return jsonify({
2472 'error': f'Stage 1 is not ready: {stage1_error}',
2473 }), 409
2474 try:
2475 template_options, _ = _build_template_options(confirm_dir)
2476 except (OSError, json.JSONDecodeError, ValueError) as exc:
2477 return jsonify({
2478 'error': f'invalid template options: {exc}',
2479 }), 409
2480 data['template_options'] = template_options
2481 template_required = False
2482 else:
2483 stage2_error = _stage2_ready_error(
2484 project_path,
2485 confirm_dir,
2486 rec_file,
2487 )
2488 if stage2_error:
2489 return jsonify({
2490 'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
2491 }), 409
2492 try:
2493 template_required = _template_confirmation_required(project_path)
2494 except (OSError, json.JSONDecodeError, ValueError) as exc:
2495 return jsonify({
2496 'error': f'cannot determine active template mode: {exc}',
2497 }), 409
2498 # Later stages render only downstream sections, so fold earlier confirmed
2499 # choices from result.json back in. An in-run refresh then re-inits from
2500 # the user's choices instead of catalog defaults.
2501 if rec_stage_number >= 2 and result_file.exists():
2502 _merge_confirmed_choices(data, result_file)
2503 language_error = _canonicalize_primary_language(
2504 data,
2505 required=True,
2506 )
2507 if language_error:
2508 return jsonify({'error': language_error}), 409
2509 if not template_required:
2510 data.pop('template_application', None)
2511 if rec_stage_number == 2:
2512 recommendation_error = _template_stage2_error(
2513 data,
2514 template_required=template_required,
2515 )
2516 if recommendation_error:
2517 return jsonify({'error': recommendation_error}), 409
2518 recommendation_error = _stage2_custom_candidates_error(data)
2519 if recommendation_error:
2520 return jsonify({'error': recommendation_error}), 409
2521 recommendation_error = _stage2_design_directions_error(data)
2522 if recommendation_error:
2523 return jsonify({'error': recommendation_error}), 409
2524 if rec_stage_number == 2:
2525 proactive_values, proactive_error = (
2526 _resolve_proactive_execution_values(data)
2527 )
2528 if proactive_error:
2529 return jsonify({'error': proactive_error}), 409
2530 for key, value in proactive_values.items():
2531 data[key] = {'value': value}
2532 # Template application is authored by Strategist from the installed
2533 # workspace and current content. Never expose legacy mode fields as
2534 # user-facing confirmation controls.
2535 recommend = data.get('recommend')
2536 if isinstance(recommend, dict):
2537 recommend.pop('template_reuse_scope', None)
2538 recommend.pop('template_adherence', None)
2539 data.pop('template_reuse_scope', None)
2540 data.pop('template_adherence', None)
2541 # The page polls this endpoint after each confirmation until the AI
2542 # creates the next stage file, so it must never be cached.
2543 resp = jsonify(data)
2544 resp.headers['Cache-Control'] = 'no-store'
2545 return resp
2546
2547 @app.route('/api/confirm', methods=['POST'])
2548 def confirm():
2549 """Persist the user's final choices to result.json for the AI to read."""
2550 payload = request.get_json(silent=True)
2551 if not isinstance(payload, dict):
2552 return jsonify({'error': 'invalid payload'}), 400
2553 confirm_dir.mkdir(parents=True, exist_ok=True)
2554 result = dict(payload)
2555 template_selection_payload = result.pop('template_selection', None)
2556 result_file = confirm_dir / RESULT_NAME
2557 raw_stage = result.get('stage')
2558 stage = _stage_key(raw_stage)
2559 if raw_stage is not None and stage is None:
2560 return jsonify({'error': 'invalid confirmation stage'}), 400
2561 try:
2562 rec_file, current_recommendations = _read_active_recommendations(
2563 confirm_dir,
2564 )
2565 except (OSError, json.JSONDecodeError, ValueError) as exc:
2566 return jsonify({
2567 'error': (
2568 'cannot confirm without valid current recommendations: '
2569 f'{exc}'
2570 ),
2571 }), 409
2572 rec_stage_number = _recommendation_stage(current_recommendations)
2573 selection_receipt = None
2574 selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
2575 write_selection = False
2576 if rec_stage_number == 1:
2577 stage1_error = _stage1_ready_error(confirm_dir)
2578 if stage1_error:
2579 return jsonify({
2580 'error': f'Stage 1 is not ready: {stage1_error}',
2581 }), 409
2582 if not isinstance(template_selection_payload, dict):
2583 return jsonify({
2584 'error': (
2585 'Stage 1 payload must include template_selection with '
2586 'mode and selection_keys'
2587 ),
2588 }), 400
2589 try:
2590 template_options, template_candidates = _build_template_options(
2591 confirm_dir,
2592 )
2593 selection_receipt = _resolve_template_confirmation(
2594 template_selection_payload,
2595 template_candidates,
2596 template_options['options_sha256'],
2597 )
2598 except (OSError, json.JSONDecodeError, ValueError) as exc:
2599 return jsonify({
2600 'error': f'invalid Stage 1 template selection: {exc}',
2601 }), 400
2602 template_required = selection_receipt['mode'] == 'templates'
2603 if selection_file.exists():
2604 try:
2605 existing_selection = _read_template_selection(selection_file)
2606 except (OSError, json.JSONDecodeError, ValueError) as exc:
2607 return jsonify({
2608 'error': (
2609 f'existing template selection is invalid: {exc}; '
2610 'the agent must run --reset-template-selection'
2611 ),
2612 }), 409
2613 if (
2614 existing_selection['selection_sha256']
2615 != selection_receipt['selection_sha256']
2616 ):
2617 return jsonify({
2618 'error': (
2619 'Stage 1 already has a different template selection; '
2620 'the agent must run --reset-template-selection'
2621 ),
2622 }), 409
2623 else:
2624 write_selection = True
2625 else:
2626 if template_selection_payload is not None:
2627 return jsonify({
2628 'error': 'template_selection is accepted only in Stage 1',
2629 }), 400
2630 stage2_error = _stage2_ready_error(
2631 project_path,
2632 confirm_dir,
2633 rec_file,
2634 )
2635 if stage2_error:
2636 return jsonify({
2637 'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
2638 }), 409
2639 try:
2640 template_required = _template_confirmation_required(project_path)
2641 except (OSError, json.JSONDecodeError, ValueError) as exc:
2642 return jsonify({
2643 'error': f'cannot determine active template mode: {exc}',
2644 }), 409
2645 stage_error = _submission_stage_error(
2646 confirm_dir,
2647 stage,
2648 recommendations_file=rec_file,
2649 recommendations=current_recommendations,
2650 template_required=template_required,
2651 )
2652 if stage_error:
2653 return jsonify({'error': stage_error}), 409
2654 custom_error = _custom_selection_error(result)
2655 if custom_error:
2656 return jsonify({'error': custom_error}), 400
2657 previous_result = {}
2658 if rec_stage_number >= 2:
2659 try:
2660 previous_result = _read_json_object(result_file)
2661 except (OSError, json.JSONDecodeError, ValueError):
2662 pass
2663 main_language = None
2664 if rec_stage_number > 0:
2665 language_source = (
2666 previous_result
2667 if _recommendation_language(previous_result)
2668 else current_recommendations
2669 )
2670 if not _recommendation_language(language_source):
2671 language_source = result
2672 language_error = _canonicalize_primary_language(
2673 language_source,
2674 required=True,
2675 )
2676 if language_error:
2677 return jsonify({'error': language_error}), 409
2678 main_language = _recommendation_language(language_source)
2679 if main_language:
2680 result['primary_language'] = main_language
2681 else:
2682 result.pop('primary_language', None)
2683 if rec_stage_number == 2:
2684 solution_error = _stage2_solution_error(
2685 result,
2686 main_language=main_language,
2687 )
2688 if solution_error:
2689 return jsonify({'error': solution_error}), 400
2690 if rec_stage_number == 2:
2691 proactive_defaults, proactive_recommendation_error = (
2692 _resolve_proactive_execution_values(current_recommendations)
2693 )
2694 if proactive_recommendation_error:
2695 return jsonify({
2696 'error': proactive_recommendation_error,
2697 }), 409
2698 proactive_result_error = _normalize_proactive_execution_result(
2699 result,
2700 proactive_defaults,
2701 )
2702 if proactive_result_error:
2703 return jsonify({'error': proactive_result_error}), 400
2704 _normalize_custom_selections(result)
2705 locked_values = _apply_locked_recommendations(
2706 result,
2707 rec_file,
2708 result_file,
2709 carry_previous=rec_stage_number > 1,
2710 )
2711 if rec_stage_number == 1 or not template_required:
2712 result.pop('template_application', None)
2713 locked_values.pop('template_application', None)
2714 result.pop('template_reuse_scope', None)
2715 result.pop('template_adherence', None)
2716 if stage == 'stage1':
2717 result['stage'] = 'stage1'
2718 result['status'] = 'stage1-confirmed'
2719 if locked_values:
2720 result[_LOCKED_RECOMMENDATIONS_KEY] = locked_values
2721 else:
2722 result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
2723 result['stage'] = 'final'
2724 result['status'] = 'confirmed'
2725 result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
2726 if write_selection and selection_receipt is not None:
2727 _write_json_atomic(selection_file, selection_receipt)
2728 _write_json_atomic(result_file, result)
2729 _sync_session_state(
2730 confirm_dir,
2731 server_port=app.config.get('SERVER_PORT'),
2732 event=f'{result["stage"]}-submitted',
2733 )
2734 logger.info('%s confirmation written to %s', result['stage'], result_file)
2735 return jsonify({'status': 'ok'})
2736
2737 return app
2738
2739
2740 def build_parser() -> argparse.ArgumentParser:
2741 parser = argparse.ArgumentParser(
2742 description='PPT Master template and Strategist confirmation UI',
2743 formatter_class=argparse.RawDescriptionHelpFormatter,
2744 )
2745 parser.add_argument('project_dir', help='Path to project directory')
2746 parser.add_argument(
2747 '--port', type=int, default=None,
2748 help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})',
2749 )
2750 parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
2751 parser.add_argument(
2752 '--daemon', action='store_true',
2753 help='Start the server in the background; combine with --wait to block until confirmation',
2754 )
2755 parser.add_argument(
2756 '--wait', action='store_true',
2757 help='With --daemon, wait until the active result.json stage is written',
2758 )
2759 parser.add_argument(
2760 '--wait-only', action='store_true',
2761 help='Attach to the confirm server for this project and wait for an '
2762 'already-open page to write the requested receipt. If it is '
2763 'already persisted, return without recovery; otherwise recover a '
2764 'dead server on the recorded/default port so browser polling can resume.',
2765 )
2766 parser.add_argument(
2767 '--wait-stage', default='final', metavar='{stage1,final}',
2768 help='Wait for this result.json stage (default: final). Use stage1 '
2769 'after opening the combined template/communication page.',
2770 )
2771 parser.add_argument(
2772 '--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
2773 help=f'Seconds the wait caller blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
2774 '0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
2775 )
2776 parser.add_argument(
2777 '--timeout', type=int, default=900,
2778 help='Server idle timeout in seconds (default: 900; 0 = disabled)',
2779 )
2780 parser.add_argument(
2781 '--shutdown', action='store_true',
2782 help='Stop a confirm server left running for this project, then exit '
2783 '(idempotent). Run at the end of Step 4 so the page never lingers '
2784 'on its selected port before live preview starts.',
2785 )
2786 parser.add_argument(
2787 '--complete-template-selection', action='store_true',
2788 help='Agent-only: after Stage 1, bind its template selection to a ready '
2789 'handoff. Template mode requires <project>/templates/design_spec.md.',
2790 )
2791 parser.add_argument(
2792 '--reset-template-selection', action='store_true',
2793 help='Agent-only: remove exactly template_options.json, '
2794 'template_selection.json, and template_handoff.json before a '
2795 'fresh one-run UI lifecycle.',
2796 )
2797 return parser
2798
2799
2800 def main(argv: Optional[list[str]] = None) -> int:
2801 parser = build_parser()
2802 args = parser.parse_args(argv)
2803
2804 logging.basicConfig(
2805 level=logging.INFO,
2806 format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
2807 datefmt='%H:%M:%S',
2808 )
2809
2810 if args.port is not None:
2811 try:
2812 args.port = _validate_port(args.port)
2813 except ValueError as exc:
2814 logger.error('%s', exc)
2815 return 2
2816
2817 project_path = Path(args.project_dir).resolve()
2818 if not project_path.is_dir():
2819 logger.error('%s is not a directory', project_path)
2820 return 1
2821 wait_stage = _stage_key(str(args.wait_stage).strip().lower())
2822 if wait_stage not in {'stage1', 'final'}:
2823 logger.error('--wait-stage must be stage1 or final')
2824 return 2
2825
2826 template_control = (
2827 args.complete_template_selection
2828 or args.reset_template_selection
2829 )
2830 if template_control and (
2831 args.daemon or args.wait or args.wait_only or args.shutdown
2832 ):
2833 logger.error(
2834 '--complete-template-selection/--reset-template-selection cannot be combined '
2835 'with server, wait, or shutdown actions'
2836 )
2837 return 2
2838 if args.complete_template_selection and args.reset_template_selection:
2839 logger.error(
2840 '--complete-template-selection and --reset-template-selection are '
2841 'mutually exclusive'
2842 )
2843 return 2
2844 if args.complete_template_selection:
2845 return _complete_template_selection(project_path)
2846 if args.reset_template_selection:
2847 return _reset_template_selection(project_path / CONFIRM_DIR_NAME)
2848
2849 # Step 4 cleanup: stop any lingering confirm server and exit. Independent of
2850 # recommendation files (the page may never have been confirmed).
2851 if args.shutdown:
2852 return _shutdown_existing(project_path / LOCK_FILE_NAME)
2853
2854 # Staged wait: attach to the server launched by --daemon and block until
2855 # the page writes the requested Strategist receipt.
2856 if args.wait_only:
2857 lock_file = project_path / LOCK_FILE_NAME
2858 confirm_dir = project_path / CONFIRM_DIR_NAME
2859 result_file = confirm_dir / RESULT_NAME
2860 wait_status = _wait_result_status(result_file, wait_stage)
2861 if wait_status is not None:
2862 return wait_status
2863 if wait_stage == 'stage1':
2864 readiness_error = _stage1_ready_error(confirm_dir)
2865 else:
2866 recommendations_file = _active_recommendations_path(confirm_dir)
2867 readiness_error = _stage2_ready_error(
2868 project_path,
2869 confirm_dir,
2870 recommendations_file,
2871 )
2872 if readiness_error:
2873 logger.error(
2874 'confirmation stage=%s is not ready: %s',
2875 wait_stage,
2876 readiness_error,
2877 )
2878 return 1
2879 if not _live_lock(lock_file):
2880 launch_error = _confirmation_launch_error(confirm_dir)
2881 if launch_error:
2882 logger.error('%s', launch_error)
2883 return 1
2884 exact_port = args.port is not None
2885 recovery_port = (
2886 args.port
2887 if exact_port
2888 else _preferred_recovery_port(lock_file, DEFAULT_PORT)
2889 )
2890 try:
2891 _, actual_port, _ = _launch_background_server(
2892 project_path,
2893 preferred_port=recovery_port,
2894 exact_port=exact_port,
2895 idle_timeout=args.timeout,
2896 open_browser=False,
2897 )
2898 except RuntimeError as exc:
2899 logger.error('%s', exc)
2900 return 1
2901 if actual_port != recovery_port and not args.no_browser:
2902 webbrowser.open(_server_url(actual_port))
2903 logger.info(
2904 'recovered confirm UI for wait-only at %s; the browser polling should resume',
2905 _server_url(actual_port),
2906 )
2907 return _wait_only_for_result(
2908 result_file,
2909 lock_file,
2910 args.wait_timeout,
2911 wait_stage,
2912 )
2913
2914 confirm_dir = project_path / CONFIRM_DIR_NAME
2915 launch_error = _confirmation_launch_error(confirm_dir)
2916 if launch_error:
2917 logger.error('%s', launch_error)
2918 return 1
2919
2920 if args.daemon:
2921 lock_file = project_path / LOCK_FILE_NAME
2922 existing = _read_lock(lock_file)
2923 if existing and _process_alive(_lock_pid(existing)):
2924 existing_pid = existing.get('pid', '?')
2925 existing_port = existing.get('port', '?')
2926 logger.error(
2927 'confirm UI is already running for this project '
2928 '(pid=%s, port=%s). Open http://%s:%s',
2929 existing_pid, existing_port, PUBLIC_HOST, existing_port,
2930 )
2931 return 1
2932
2933 confirm_dir = project_path / CONFIRM_DIR_NAME
2934 result_file = confirm_dir / RESULT_NAME
2935 expected_stage = _expected_result_stage(confirm_dir)
2936 started_at = time.time()
2937 try:
2938 proc, port, _ = _launch_background_server(
2939 project_path,
2940 preferred_port=args.port if args.port is not None else DEFAULT_PORT,
2941 exact_port=args.port is not None,
2942 idle_timeout=args.timeout,
2943 open_browser=not args.no_browser,
2944 )
2945 except RuntimeError as exc:
2946 logger.error('%s', exc)
2947 return 1
2948 if args.wait:
2949 return _wait_for_result(
2950 result_file,
2951 proc,
2952 started_at,
2953 args.wait_timeout,
2954 expected_stage,
2955 )
2956 return 0
2957
2958 try:
2959 port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT)
2960 except RuntimeError as exc:
2961 logger.error('%s', exc)
2962 return 1
2963
2964 # Per-project mutual exclusion: refuse duplicate launches. Stale locks
2965 # (dead pid) are overwritten by _claim_lock.
2966 lock_file = project_path / LOCK_FILE_NAME
2967 existing = _claim_lock(lock_file, port)
2968 if existing:
2969 existing_pid = existing.get('pid', '?')
2970 existing_port = existing.get('port', '?')
2971 logger.error(
2972 'confirm UI is already running for this project '
2973 '(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
2974 existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
2975 )
2976 return 1
2977 atexit.register(_release_lock, lock_file)
2978
2979 def _on_sigterm(signum: int, _frame) -> None:
2980 logger.info('received signal %s, exiting', signum)
2981 sys.exit(0)
2982 try:
2983 signal.signal(signal.SIGTERM, _on_sigterm)
2984 except (ValueError, OSError):
2985 pass
2986
2987 app = create_app(
2988 str(project_path),
2989 idle_timeout=args.timeout,
2990 lock_file=lock_file,
2991 server_port=port,
2992 )
2993
2994 url = _server_url(port)
2995 if not args.no_browser:
2996 _open_browser_async(url)
2997
2998 logger.info('running at %s', url)
2999 logger.info('project: %s', project_path)
3000 logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
3001 app.run(host=PUBLIC_HOST, port=port, debug=False)
3002 return 0
3003
3004
3005 if __name__ == '__main__':
3006 raise SystemExit(main())
3007
3007 lines PYTHON