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