[Claude #7] **unit_executed**: u4 — stale-detect short-circuit (compare `index.html` mtime+sha256 against last manifest entry; mark `skipped_unchanged` when both match) **files_changed**: - MOD `D:\ad-hoc\kei\design_agent\scripts\generate_frame_previews.py` (88 → 110 lines per `Measure-Object -Line`; u4 net add = 22 lines, ≤ Stage 2 u4 estimate of 45) **diff_summary**: - Added `_sha256_file(path: Path) -> str` (private, leading underscore) — buffered `hashlib.sha256()` digest of `index.html` content, 65 KiB chunked read. Stdlib only. - Added `is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool` — the Stage 2 u4 short-circuit predicate. Returns `True` iff: 1. `last_entry` is not `None`, AND 2. `row.has_index` and `row.has_preview` are both `True`, AND 3. `preview.png` mtime `>=` `index.html` mtime (per Stage 2 guardrail: "skip if preview.png mtime >= index.html mtime AND content hash unchanged"), AND 4. `last_entry["index_sha256"]` is non-empty, AND 5. recomputed `_sha256_file(index.html)` equals `last_entry["index_sha256"]`. Otherwise returns `False` — the caller (u6) MUST re-render. `OSError` on `stat()` is caught and treated as "must re-render" (defensive against transient FS races). - **No wiring into `main()`** — predicate is provided in isolation per Stage 2 unit boundary. u6 will load the manifest, call `is_unchanged()` per row, and write back the `skipped_unchanged` / `rendered` status. This keeps u4 ≤ 45-line guardrail and lets u5 (orphan/missing categorization) and u6 (manifest writer + coverage report) layer on top without re-touching u4 surface. - Module-level imports: added `hashlib` (stdlib) and `Dict`, `Optional` to existing `typing` import. No new third-party deps. Selenium remains lazy (u2/u3 contract preserved). - Module docstring updated: `(u1-u3; stale/orphan/manifest added in u4-u6)` → `(u1-u4; orphan/manifest added in u5-u6)`. - `FrameRow` / `discover` / `_build_driver` / `render_one` / `main` / CLI **untouched**. u1/u2/u3 contracts preserved. **verification**: - `(Get-Content scripts/generate_frame_previews.py | Measure-Object -Line).Lines` → **110** (u4 add = 22 lines, ≤ 45 guardrail). - `python scripts/generate_frame_previews.py --dry-run` → `discovered: total=33 with_index_html=20 with_preview_png=4` (unchanged from u1/u2/u3 baseline at commit `56619a0` — u4 does not alter discovery output and does not wire into main). - `$env:PYTHONPATH = '...\scripts'; python -c "import generate_frame_previews as m; print('import_ok', hasattr(m,'is_unchanged'), hasattr(m,'_sha256_file'), hasattr(m,'render_one'), hasattr(m,'_build_driver'), hasattr(m,'discover'), hasattr(m,'main'))"` → `import_ok True True True True True True` (lazy-selenium import preserved — module imports without selenium installed). - Forbidden-pattern audit `rg -n "^(import|from) |anthropic|openai|jinja|phase_z2|slide_measurer|1280|720|1400|900"` → only stdlib imports (`argparse`, `hashlib`, `sys`, `dataclasses`, `pathlib`, `typing`). **No** AI/Jinja/`phase_z2`/`slide_measurer` substring; **no** `1280`/`720`/`1400`/`900` literal. RULE 7 (no hardcoding) holds. - **Predicate behavioral smoke** (`.orchestrator/tmp/u4_smoke.py`, gitignored temp): 6 cases — (1) `None` last_entry → False, (2) matching sha + png-newer-than-idx → True, (3) matching sha but stale png (older than idx) → False, (4) mismatched sha → False, (5) empty/missing `index_sha256` key → False, (6) missing index.html (post-unlink rediscover) → False. All assertions pass; final line `u4_predicate_ok` printed. - Scope guard: `git diff --name-only -- src/slide_measurer.py src/phase_z2_pipeline.py Front/vite.config.ts templates/phase_z2/frames tests figma_to_html_agent/blocks/_preview_manifest.json` → **empty output** (no tracked-file change outside `scripts/generate_frame_previews.py`). - Tracked status: `scripts/generate_frame_previews.py` is the only IMP-13 working-tree change; `figma_to_html_agent/blocks/_preview_manifest.json` is **not** created in u4 (correctly deferred to u6). **remaining_units**: [u5, u6, u7] **follow_up_issue_candidates**: none from this unit. Stage 2 already flagged IMP-04 (`missing_index_html=13`) and `data/figma_previews/index.json` parse failure as separate axes. u4 introduces no new out-of-scope dependency. rewind_target: continue_same