Просмотр исходного кода

docs(movie): document and verify Windows workflows

Drew Ritter 2 недель назад
Родитель
Сommit
8a31ffc3ff

+ 2 - 2
docs/superpowers/plans/2026-09-09-proof-movie-windows-completion.md

@@ -2,7 +2,7 @@
 
 
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 
 
-**Status:** Plan for review. Writing this plan does not restart implementation or authorize agent dispatch.
+**Status:** Execution authorized and the three implementation milestones delivered. Final workflow evidence and the remaining candidate-instruction verification gaps are recorded in the [results report](../reports/2026-09-09-proof-movie-os-compatibility.md#reviewed-windows-completion--final-results).
 
 
 **Goal:** Finish the existing movie workflow on native Windows through PowerShell 5.1, PowerShell 7, and Git Bash.
 **Goal:** Finish the existing movie workflow on native Windows through PowerShell 5.1, PowerShell 7, and Git Bash.
 
 
@@ -250,4 +250,4 @@ On the native Mac and existing Linux runner, run only the portable suites `assem
 
 
 At execution, retain focused RED/GREEN evidence and review each of these three milestones. Rerun only checks affected by a fix; final integration must use final product code. If a genuine blocker requires changing an approved interface, dependency, supported route, or validation scope, present that concrete issue before expanding work.
 At execution, retain focused RED/GREEN evidence and review each of these three milestones. Rerun only checks affected by a fix; final integration must use final product code. If a genuine blocker requires changing an approved interface, dependency, supported route, or validation scope, present that concrete issue before expanding work.
 
 
-This plan was written and self-reviewed without starting implementation, remote tests, or agents. The next action is human review of the plan and execution choice; the old controller and workers remain canceled.
+Execution resumed under the reviewed three-milestone scope. Implementation and finite test outcomes are recorded in the results report; the earlier superseded controller/workers were not resumed. Review the documented candidate-instruction gaps before claiming full skill compliance.

+ 124 - 0
docs/superpowers/reports/2026-09-09-proof-movie-os-compatibility.md

@@ -160,3 +160,127 @@ python3 tests/proving-it-works-with-a-movie/probe-windows.py --assert-result .su
 ## Required production follow-through
 ## Required production follow-through
 
 
 General screencast freshness is a **required production fix and test in Tasks 8–9**; stalled takes must not pass. Resizing requires a production shell adapter/supervisor that handles completion framing through resize/redraw, with damaged/missing framing remaining unknown/interrupted. Further work includes broader ownership failure injection, the full OS support matrix, and assembly/narration/verification portability. This probe does not establish those results. The Task 1 mechanism gate and independent review must finish before the bulk port begins.
 General screencast freshness is a **required production fix and test in Tasks 8–9**; stalled takes must not pass. Resizing requires a production shell adapter/supervisor that handles completion framing through resize/redraw, with damaged/missing framing remaining unknown/interrupted. Further work includes broader ownership failure injection, the full OS support matrix, and assembly/narration/verification portability. This probe does not establish those results. The Task 1 mechanism gate and independent review must finish before the bulk port begins.
+
+
+## Reviewed Windows completion — final results
+
+The earlier sections record historical feasibility work, not the current
+acceptance gate. The reviewed three-milestone implementation is now present.
+The final native workflows passed; the four-session instruction comparison
+has two explicitly incomplete verification outcomes below. No PR, push,
+merge, further OS provisioning, or additional eval platform was performed.
+
+Ballmer: native Windows build 26200, x64, ordinary Medium token
+`S-1-16-8192`, prepared CPython 3.12.14. Actual invoking-shell transcripts
+identify PS5.1 `5.1.26100.9168`, PS7 `7.6.6`, and Git Bash
+`5.3.9(1)-release`, with executable/PID before uv. This is evidence for that
+tested host, not every Windows release or architecture.
+
+| Invoking → recorded shell | Five tools + checker + rendered audio | Duration | Retained artifacts |
+|---|---|---:|---|
+| PS5.1 → PS5.1 | PASS | 27.000 s | [Movie](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell51/movie O'Brien λ & [take]/movie.mp4>), [contact sheet](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell51/movie O'Brien λ & [take]/evidence/checker/contact-sheet.png>), [rendered audio](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell51/movie O'Brien λ & [take]/evidence/rendered-audio.json>) |
+| PS7 → PS7 | PASS | 26.878 s | [Movie](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell7/movie O'Brien λ & [take]/movie.mp4>), [contact sheet](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell7/movie O'Brien λ & [take]/evidence/checker/contact-sheet.png>), [rendered audio](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/powershell7/movie O'Brien λ & [take]/evidence/rendered-audio.json>) |
+| Git Bash → Git Bash | PASS | 27.167 s | [Movie](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/gitbash/movie O'Brien λ & [take]/movie.mp4>), [contact sheet](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/gitbash/movie O'Brien λ & [take]/evidence/checker/contact-sheet.png>), [rendered audio](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/gitbash/movie O'Brien λ & [take]/evidence/rendered-audio.json>) |
+
+All movies are 1600×900 H.264/AAC. Each combines a card, still, real CDP
+counter clicks, two native terminal takes, and a labeled existing movie
+segment retaining its own 440 Hz stereo tone. Inputs exercise the path
+`movie O'Brien λ & [take]`, UTF-8 BOM/CRLF scenes, and separate assembly work.
+Title narration lasts 3.855–3.971 s against one second of visuals; still
+visuals last four seconds against 1.498–1.695 s of narration.
+
+Each workflow used real local Piper synthesis with verification, then a new
+output directory reusing downloaded model caches. The process PATH excludes
+`llm`, and cloud keys were removed only from that process. All 15 narrated
+final-movie intervals passed local ASR comparison (0% length drift, worst
+changed-word run at most two). Each source-tone interval separately passed
+frequency/amplitude comparison with its own source reference. Fresh/cached
+WAV verification is not substituted for final-movie transcription.
+
+The exported terminal samples show red→green→blue in order and visible
+command completion after the second take's exit key. Six take duration
+errors are 0.028–0.094 s; maximum completed-screenshot gaps are
+0.625–1.109 s. Frame hashes confirm the documented completed-sample grid,
+with no future sample used. Contact sheets and decoded movie frames were
+inspected; short color states missed by the contact-sheet selection are
+present in the actual movies.
+
+Evidence index: [timing/frame inspection](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/timing-and-frame-inspection.json>),
+[controller final-movie inspection](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/controller-final-movies.json>),
+[final source SHA-256 manifest](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/final-source-sha256.json>).
+The manifest pins the tested recorder, helpers, five tools, and fixture;
+Task 3 applies to base `ce9b3fcf`, following media `5621d1d6` and recorder
+`ce9b3fcf`. The retained source hashes, commands, and source-labeled earlier
+results establish provenance without claiming an older artifact tested a
+later failure-path change. Remote artifacts remain at
+`C:\Users\drew\movie-windows-completion\final-task3`.
+
+### Capture targets and reused regression evidence
+
+Named-window `gdigrab` captured readable pixels from the task-owned Windows
+application: [window image](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/desktop-target/title.png>) and
+[commands/results](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/desktop-target/result.json>). Visible padding and
+capture dimensions are retained; no DPI/root-cause claim is inferred.
+Full-desktop capture returned zero but showed wallpaper only, including a
+late frame: [desktop image](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/desktop-target/capture-check.png>).
+That target remains **unverified for application capture in this session**.
+Read-only evidence showed WinSta0/Default and an active console session;
+no lock, service, credential, or machine-setting changes were made.
+
+Chrome and Edge title-card checks and the unchanged media regressions reuse
+`.superpowers/evidence/windows-completion/media-5621d1d6`. The Mac and Linux
+portable assertions passed 35 per host; native-only Job tests are excluded
+from that portable result. Final strict browser/narration checks passed
+4/4 and 9/9 per host. Real fresh and cached-model voice checks on both hosts
+reuse `.superpowers/evidence/windows-completion/{macos,linux}-voice`.
+The narration/browser/subtitle code exercised there is unchanged.
+
+The existing terminal capture gate, native outcome/control/ownership
+regressions (57 tests), and finalization corrections (7 focused tests) retain
+their source labels under `capture-gate`, `terminal-b8a723f4`, and
+`terminal-ce9b3fcf`. Task 3 adds the demonstrated Unicode-cwd startup fix and
+the previously reviewed identity-error handle cleanup. Focused native tests
+passed 4/4. Local changed-policy suites passed 12 terminal and two ownership
+assertions; their 13 and two native-only skips do not establish native
+coverage. The native final workflows separately exercise all three shells.
+
+### Four instruction sessions — comparison complete, full compliance partial
+
+All four loaded the explicit candidate plugin and successful
+`using-superpowers` SessionStart bootstrap, invoked the movie Skill, and used
+actual PowerShell or Bash tools. The scenario, model (`claude-sonnet-5`,
+existing `sonnet` alias), low effort, supplied tools/caches, product code, and
+stop-at-first-instruction-gap criterion were held constant. Only the candidate
+Markdown changed. No diagnostic safe mode or extra implementation agent was
+used. Permissions/context were scoped to the task plugin, fixtures,
+artifacts, and owned processes.
+
+| Session | Observed result |
+|---|---|
+| PowerShell baseline | Demonstrated documentation failure: inferred the native route from source, failed special-path `Start-Process` quoting, BOM request input, and guessed an `operation: result` request. Stopped through owned cleanup after the gap; not a completed workflow. |
+| Git Bash baseline | Demonstrated documentation failure: inferred the native route from source and passed `/c/...` to native Python, producing `C:\c\...`/file-not-found. Stopped through the launcher's Job cleanup after the gap; not a completed workflow. |
+| PowerShell candidate | **Partial.** New foreground-task, UTF-8 request, consecutive-ID, two-take, wait-only result, local voice, five-tool, caption/contact-sheet, cleanup, and honest capture-boundary instructions worked. It claimed complete verification but omitted transcription of the rendered final audio. |
+| Git Bash candidate | **Incomplete due to harness turn cap.** Built a movie and ran media/checker/evidence steps, then returned `error_max_turns` (41 reported turns, exit 1) before a final response or rendered-final-audio transcription. This is not a full skill pass or an inferred instruction failure. |
+
+Launch limits were $4, 40 turns, and 900 seconds per session. The PowerShell
+baseline's interrupted result reported 54 turns/$1.4456, and the completed
+PowerShell candidate reported 44 turns; the requested cap must not be
+mistaken for the harness's actual reported count. Git Bash candidate cost
+was $0.832 and elapsed time 223.6 s. No limit was silently scored as success.
+Transcripts and launcher result records are retained under
+[the stable instruction evidence](</Users/drewritter/.paseo/worktrees/2mmrq9t5/movie-os-compatibility/.superpowers/evidence/windows-completion/final-task3/instructions/>). Further corrective
+instruction work is left to review; no extra sessions were launched.
+
+### Acceptance disposition
+
+| Reviewed acceptance area | Disposition |
+|---|---|
+| Three native invoking/recorded-shell workflows | PASS on the recorded host |
+| Repeatable mixed fixture, paths, encodings, timing | PASS |
+| Terminal control, continuity, native outcomes, ordered automatic states | PASS with retained focused regression evidence |
+| Fresh/cached-model local voice per workflow | PASS |
+| Finished picture, hard captions, checker and rendered audio | PASS for all three independent acceptance movies |
+| Browser cards / capture targets | PASS Chrome, Edge, and named-window pixels; full-desktop target explicitly unverified |
+| Focused negative cases and ownership | PASS with source-labeled earlier evidence plus Task 3 focused corrections |
+| Existing Mac/Linux behavior | PASS reused unchanged portable/media/voice evidence |
+| Four instruction comparisons | Executed; **full candidate skill compliance remains INCOMPLETE** for the two specific outcomes above |

+ 3 - 3
docs/superpowers/specs/2026-09-09-proof-movie-windows-completion-design.md

@@ -1,10 +1,10 @@
 # Finish Windows support for the movie skill
 # Finish Windows support for the movie skill
 
 
-**Status:** Adversarial review complete; revised design ready for human review. Implementation remains stopped. See the [findings and scoped recheck](2026-09-09-proof-movie-windows-completion-review.md).
+**Status:** Reviewed execution completed through the three implementation milestones. Native workflow results and the remaining candidate-instruction verification gaps are recorded in the [results report](../reports/2026-09-09-proof-movie-os-compatibility.md#reviewed-windows-completion--final-results).
 
 
 **Baseline:** `feat/movie-os-compatibility` at `442a48d9`, based on the movie import at `f6617db1`.
 **Baseline:** `feat/movie-os-compatibility` at `442a48d9`, based on the movie import at `f6617db1`.
 
 
-**Implementation plan:** [Three-milestone completion plan](../plans/2026-09-09-proof-movie-windows-completion.md), written for review. Execution has not resumed.
+**Implementation plan:** [Three-milestone completion plan](../plans/2026-09-09-proof-movie-windows-completion.md). Execution was authorized and resumed.
 
 
 This replaces the remaining scope of the [earlier OS compatibility design](2026-09-09-proof-movie-os-compatibility-design.md) and its [12-task implementation plan](../plans/2026-09-09-proof-movie-os-compatibility.md). Completed fixes and retained evidence remain useful. Unfinished tasks in that plan are not instructions to resume work.
 This replaces the remaining scope of the [earlier OS compatibility design](2026-09-09-proof-movie-os-compatibility-design.md) and its [12-task implementation plan](../plans/2026-09-09-proof-movie-os-compatibility.md). Completed fixes and retained evidence remain useful. Unfinished tasks in that plan are not instructions to resume work.
 
 
@@ -122,4 +122,4 @@ Expected edits are the existing five scripts, at most the browser/Windows owners
 
 
 Done means the three native Windows workflow runs and this checklist have concrete results, documented limitations are visible, and the user has a complete diff to review. Missing Windows route evidence remains unfinished work. Pushing, opening a PR, and merging are separate from this specification request.
 Done means the three native Windows workflow runs and this checklist have concrete results, documented limitations are visible, and the user has a complete diff to review. Missing Windows route evidence remains unfinished work. Pushing, opening a PR, and merging are separate from this specification request.
 
 
-This drafting step starts no agents, installs no tools, and changes no implementation. The earlier agents remain stopped. Review this scope before any execution resumes.
+Execution resumed under this reviewed scope. The results report records each acceptance disposition, including incomplete candidate-instruction verification; the superseded plan and earlier workers were not resumed.

+ 6 - 0
skills/proving-it-works-with-a-movie/SKILL.md

@@ -33,6 +33,12 @@ that quietly fakes one beat is worthless as evidence for any beat.
 
 
 ## The gate — every route, before you hand anything over
 ## The gate — every route, before you hand anything over
 
 
+On native Windows, use the complete PowerShell or Git Bash sequence in
+assembling.md and the native example in recording-a-terminal.md. Invoke all
+five tools with `uv run --script`; Windows does not execute their Unix shebangs.
+
+The Unix sequence:
+
 ```bash
 ```bash
 # $SKILL_DIR is this skill's own directory - the "Base directory for this
 # $SKILL_DIR is this skill's own directory - the "Base directory for this
 # skill" path printed when it loads. Installed as a plugin that is
 # skill" path printed when it loads. Installed as a plugin that is

+ 66 - 0
skills/proving-it-works-with-a-movie/assembling.md

@@ -102,3 +102,69 @@ the repo. Scratch directories are cleaned by the OS between sessions; losing
 the assembler mid-production means reconstructing it from prose before you
 the assembler mid-production means reconstructing it from prose before you
 can re-cut a single scene. Ask before committing large media; the *pipeline*
 can re-cut a single scene. Ask before committing large media; the *pipeline*
 is small and always worth committing.
 is small and always worth committing.
+
+## Native Windows: the five tools
+
+Use native `uv`, FFmpeg and ffprobe on the test process's PATH. Hard subtitles
+require FFmpeg's `subtitles` filter (libass). Install Chrome or Edge for cards.
+The tools' Python environments are managed by uv; the media scripts require
+Python 3.10+, and the Windows terminal example requires 3.12+. First use can
+download Python, script dependencies, the local Piper voice, and the local
+transcription model. Do that setup before recording. No cloud key is required.
+PowerShell needs neither Git Bash nor WSL, tmux, Docker, or administrator rights.
+
+Keep the whole skill directory together: the scripts import their adjacent
+helpers. Set `skill` to the skill's loaded base directory, and write a scene
+file in `work`. Scene kinds remain `card`, `image`, `frames`, and `movie`;
+`kind: movie` retains the source clip's own audio. Other scenes can have
+`narration`. Use the measured assembly offsets for subtitles.
+
+PowerShell 5.1 and 7 (each native exit code is checked before continuing):
+
+```powershell
+$skill = 'C:/path/to/skills/proving-it-works-with-a-movie'
+$work = "$HOME/movie O'Brien λ & [take]"
+[IO.Directory]::CreateDirectory($work) | Out-Null
+& uv run --script "$skill/scripts/narrate" "$work/scenes.yaml" "$work/narration" --engine piper --verify on
+if ($LASTEXITCODE -ne 0) { throw 'narrate failed' }
+& uv run --script "$skill/scripts/assemble" "$work/scenes.yaml" "$work/cut.mp4" --narration "$work/narration" --work "$work/assembly work"
+if ($LASTEXITCODE -ne 0) { throw 'assemble failed' }
+& uv run --script "$skill/scripts/make-subtitles" "$work/narration/manifest.json" "$work/movie.srt" --offsets-json "$work/assembly work/offsets.json"
+if ($LASTEXITCODE -ne 0) { throw 'make-subtitles failed' }
+& uv run --script "$skill/scripts/burn-subtitles" "$work/cut.mp4" "$work/movie.srt" "$work/movie.mp4"
+if ($LASTEXITCODE -ne 0) { throw 'burn-subtitles failed' }
+& uv run --script "$skill/scripts/check-movie" "$work/movie.mp4" --out "$work/evidence" --json
+if ($LASTEXITCODE -ne 0) { throw 'check-movie failed' }
+```
+
+Git Bash: convert paths to native Windows form before passing them to native
+uv/Python/FFmpeg. In particular, Python can interpret `/c/...` as `C:\c\...`.
+Keep each path quoted; an apostrophe is literal inside Bash double quotes.
+
+```bash
+set -euo pipefail
+skill=$(cygpath -m '/c/path/to/skills/proving-it-works-with-a-movie')
+work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")
+mkdir -p "$work"
+uv run --script "$skill/scripts/narrate" "$work/scenes.yaml" "$work/narration" --engine piper --verify on
+uv run --script "$skill/scripts/assemble" "$work/scenes.yaml" "$work/cut.mp4" --narration "$work/narration" --work "$work/assembly work"
+uv run --script "$skill/scripts/make-subtitles" "$work/narration/manifest.json" "$work/movie.srt" --offsets-json "$work/assembly work/offsets.json"
+uv run --script "$skill/scripts/burn-subtitles" "$work/cut.mp4" "$work/movie.srt" "$work/movie.mp4"
+uv run --script "$skill/scripts/check-movie" "$work/movie.mp4" --out "$work/evidence" --json
+```
+
+Keep `movie.srt` beside `movie.mp4`: the checker discovers that basename.
+Inspect the finished contact sheet and hard captions, then transcribe the
+rendered audio as described in narrating.md. A successful soft-subtitle
+fallback is not proof that captions were burned into the picture.
+
+PowerShell 5.1's `Out-File` defaults to UTF-16. For scene YAML/JSON, request
+JSON, HTML, and SRT, write UTF-8 explicitly:
+
+```powershell
+[IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
+```
+
+Use `-LiteralPath` for PowerShell file operations on paths containing brackets.
+BOM-bearing UTF-8 and CRLF scene input are supported; a console's displayed
+encoding is not a reliable way to check the bytes in a JSON file.

+ 10 - 5
skills/proving-it-works-with-a-movie/examples/film-terminal.py

@@ -36,6 +36,7 @@ import json
 import os
 import os
 import re
 import re
 import shutil
 import shutil
+import shlex
 import socket
 import socket
 import sys
 import sys
 import time
 import time
@@ -201,15 +202,17 @@ class Terminal:
         port, debug_port = free_port(), free_port()
         port, debug_port = free_port(), free_port()
         self.terminal_url = f"http://127.0.0.1:{port}/"
         self.terminal_url = f"http://127.0.0.1:{port}/"
         shell_argv = [str(self.args.shell_exe)] + (["--noprofile", "--norc", "-i"] if self.args.shell_kind == "gitbash" else ["-NoLogo", "-NoProfile", "-NoExit"])
         shell_argv = [str(self.args.shell_exe)] + (["--noprofile", "--norc", "-i"] if self.args.shell_kind == "gitbash" else ["-NoLogo", "-NoProfile", "-NoExit"])
-        # ttyd 1.7.7 leaves its ConPTY cwd pointer uninitialized without -w.
+        # ttyd 1.7.7 needs -w but decodes its argv using the ANSI code page.
+        # Inherit the Unicode cwd through CreateProcessW, then use a relative -w.
         ttyd_argv = [str(self.args.ttyd), "-i", "127.0.0.1", "-p", str(port), "-W", "-m", "1",
         ttyd_argv = [str(self.args.ttyd), "-i", "127.0.0.1", "-p", str(port), "-W", "-m", "1",
-                     "-w", str(self.args.cwd)] + shell_argv
+                     "-w", "."] + shell_argv
         browser_argv = [str(self.args.browser), "--headless=new", "--no-first-run", "--no-default-browser-check",
         browser_argv = [str(self.args.browser), "--headless=new", "--no-first-run", "--no-default-browser-check",
                         "--disable-background-networking", "--remote-debugging-address=127.0.0.1",
                         "--disable-background-networking", "--remote-debugging-address=127.0.0.1",
                         f"--remote-debugging-port={debug_port}", f"--user-data-dir={self.directory / 'profile'}",
                         f"--remote-debugging-port={debug_port}", f"--user-data-dir={self.directory / 'profile'}",
                         "--window-size=1600,900", "about:blank"]
                         "--window-size=1600,900", "about:blank"]
         for name, argv in (("ttyd", ttyd_argv), ("browser", browser_argv)):
         for name, argv in (("ttyd", ttyd_argv), ("browser", browser_argv)):
-            pid = self.job.spawn(argv, self.directory, self.directory / f"{name}.log")
+            cwd = self.args.cwd if name == "ttyd" else self.directory
+            pid = self.job.spawn(argv, cwd, self.directory / f"{name}.log")
             self.launches.append({"name": name, "argv": argv, "pid": pid})
             self.launches.append({"name": name, "argv": argv, "pid": pid})
         write_json(self.directory / "launches.json", self.launches)
         write_json(self.directory / "launches.json", self.launches)
         deadline = time.monotonic() + 20
         deadline = time.monotonic() + 20
@@ -258,9 +261,9 @@ class Terminal:
             code = "import base64,json,os;print('[MOVIE|'+base64.b64encode(json.dumps(dict(session=os.environ['MOVIE_SESSION'],pid=os.environ['MOVIE_SHELL_PID'],shell='gitbash',cwd=os.getcwd())).encode()).decode()+'|END]')"
             code = "import base64,json,os;print('[MOVIE|'+base64.b64encode(json.dumps(dict(session=os.environ['MOVIE_SESSION'],pid=os.environ['MOVIE_SHELL_PID'],shell='gitbash',cwd=os.getcwd())).encode()).decode()+'|END]')"
             # Encode the Python payload to keep the complete framing out of input echo.
             # Encode the Python payload to keep the complete framing out of input echo.
             payload = base64.b64encode(code.encode()).decode()
             payload = base64.b64encode(code.encode()).decode()
-            command = f"export MOVIE_SESSION={self.session} MOVIE_SHELL_PID=$$; '{python}' -c \"import base64;exec(base64.b64decode('{payload}'))\""
+            command = "cd -- " + shlex.quote(str(self.args.cwd).replace("\\", "/")) + " && " + f"export MOVIE_SESSION={self.session} MOVIE_SHELL_PID=$$; '{python}' -c \"import base64;exec(base64.b64decode('{payload}'))\""
         else:
         else:
-            script = "$global:MovieSession='" + self.session + "'; $r=@{session=$MovieSession;pid=$PID;shell=$PSVersionTable.PSVersion.ToString();cwd=(Get-Location).Path}; [Console]::WriteLine('[MOVIE|'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($r|ConvertTo-Json -Compress)))+'|END]')"
+            script = "Set-Location -LiteralPath '" + str(self.args.cwd).replace("'", "''") + "' -ErrorAction Stop; $global:MovieSession='" + self.session + "'; $r=@{session=$MovieSession;pid=$PID;shell=$PSVersionTable.PSVersion.ToString();cwd=(Get-Location).Path}; [Console]::WriteLine('[MOVIE|'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($r|ConvertTo-Json -Compress)))+'|END]')"
             payload = base64.b64encode(script.encode("utf-8")).decode()
             payload = base64.b64encode(script.encode("utf-8")).decode()
             command = ". ([scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + payload + "'))))"
             command = ". ([scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + payload + "'))))"
         write_json(self.directory / "readiness-command.json", {"command": command, "session": self.session})
         write_json(self.directory / "readiness-command.json", {"command": command, "session": self.session})
@@ -270,6 +273,8 @@ class Terminal:
             self.cdp.pump()
             self.cdp.pump()
             matches = [r for r in self.parser.records if r.get("session") == self.session]
             matches = [r for r in self.parser.records if r.get("session") == self.session]
             if matches:
             if matches:
+                if os.path.normcase(os.path.abspath(matches[-1].get("cwd", ""))) != os.path.normcase(str(self.args.cwd.resolve())):
+                    raise RuntimeError("Recorded shell did not enter the requested cwd")
                 self.screenshot("ready.png")
                 self.screenshot("ready.png")
                 return matches[-1]
                 return matches[-1]
             if self.closed:
             if self.closed:

+ 22 - 0
skills/proving-it-works-with-a-movie/narrating.md

@@ -88,3 +88,25 @@ Check the sample for your own jargon before committing to a voice. If a good
 voice mangles one term, spell it phonetically **in the TTS input only**
 voice mangles one term, spell it phonetically **in the TTS input only**
 ("S M evals"), never in the script file a human reads. Keep that
 ("S M evals"), never in the script file a human reads. Keep that
 substitution in the narrate step so the source text stays clean.
 substitution in the narrate step so the source text stays clean.
+
+## Native Windows local voice
+
+Use `uv run --script` with the complete commands in assembling.md, selecting
+`--engine piper --verify on`. The local voice and transcription models can
+be downloaded during setup and reused from their caches. A cached-model
+repeat means synthesizing into a **new output directory**, not reusing the
+same WAV. `--verify on` also transcribes reused WAVs: an earlier `--verify off`
+manifest does not establish verification. An unavailable or failed
+transcription is a failed verification, not a pass.
+
+For a no-cloud-key check, remove the key only from the test process and use
+a process-local PATH without `llm` credential lookup. Leave saved credentials
+untouched. PowerShell: `Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue`;
+Git Bash: `unset OPENAI_API_KEY`. Set local Piper explicitly in either shell.
+
+After assembly, extract and transcribe each narrated interval from the final
+movie, using the actual segment offsets and durations. Compare each interval
+with its own script. A `kind: movie` segment retains its source audio; check
+that interval against the source's reference, not the TTS script. Keep any
+source speech's accurate captions. A known non-speech source tone should be
+labeled as such, and should not be described as verified narration.

+ 140 - 2
skills/proving-it-works-with-a-movie/recording-a-terminal.md

@@ -4,11 +4,149 @@ CLIs, TUIs, installs, test runs, agents at work — a large share of what is
 worth proving happens in a terminal, and none of it is visible to a browser
 worth proving happens in a terminal, and none of it is visible to a browser
 recorder or an OS screen capture you probably can't get permission for.
 recorder or an OS screen capture you probably can't get permission for.
 
 
+## Native Windows: one shell, two takes
+
+`examples/film-terminal.py` serves a native shell through ttyd/ConPTY and an
+owned headless Chrome or Edge page. It needs uv, native Python 3.12+, ttyd,
+and Chrome or Edge. PowerShell itself does not need Bash. Choose the shell
+being recorded with `--shell powershell51|powershell7|gitbash`; the shell
+invoking uv is a separate choice. Use `--shell-exe`, `--ttyd`, and `--browser`
+for explicit executable paths when they are absent from PATH.
+
+Run `serve` in a foreground/background task kept alive by your harness,
+like the visual companion. Keep that task running while later shell tool
+calls submit requests. Do not use a one-shot shell that tears down its
+children on return. Do not install a service. PowerShell `Start-Process
+-ArgumentList` joins arguments into a string and can lose special-path
+quoting; the foreground invocation below keeps arguments separate.
+
+PowerShell 5.1 or 7, in the long-lived recorder task:
+
+```powershell
+$skill = 'C:/path/to/skills/proving-it-works-with-a-movie'
+$work = "$HOME/movie O'Brien λ & [take]"
+& uv run --script "$skill/examples/film-terminal.py" serve --shell powershell51 --directory "$work/session" --cwd "$work"
+```
+
+Git Bash, in the long-lived recorder task:
+
+```bash
+skill=$(cygpath -m '/c/path/to/skills/proving-it-works-with-a-movie')
+work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")
+uv run --script "$skill/examples/film-terminal.py" serve --shell gitbash --directory "$work/session" --cwd "$work"
+```
+
+Create `work` first; use a new session directory each time. Wait for
+`session/control/ready.json` and inspect `session/ready.png`. The ready record
+contains the filmed shell's identity, cwd, geometry, and `next_request_id`.
+Readiness comes through that same filmed terminal; opening a second ttyd
+client would replace the session and is not an observation technique.
+
+For each subsequent PowerShell control call, set `skill` and `work` again
+and define this small request helper. It writes JSON without a BOM and
+preserves each argument:
+
+```powershell
+function Send-MovieRequest([hashtable]$data, [switch]$WaitResult) {
+    $path = "$work/request-$($data.id).json"
+    $json = $data | ConvertTo-Json -Compress
+    [IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
+    $arguments = @('run','--script',"$skill/examples/film-terminal.py",'request',
+        '--directory',"$work/session",'--file',$path)
+    if ($WaitResult) { $arguments += '--wait-result' }
+    & uv @arguments
+    if ($LASTEXITCODE -ne 0) { throw 'Recorder request failed' }
+}
+# First control call: a real command keeps stdin until the later Enter key.
+Send-MovieRequest @{id=1;operation='begin-take';name='take-one'} -WaitResult
+Send-MovieRequest @{id=2;operation='run';command='python -u -c "print(123); input(); print(456)"';native_producer='python';timeout_seconds=120}
+Start-Sleep -Seconds 2
+Send-MovieRequest @{id=3;operation='end-take'} -WaitResult
+```
+
+In a **later control call**, with the same paths/helper and server still alive:
+
+```powershell
+Send-MovieRequest @{id=4;operation='begin-take';name='take-two'} -WaitResult
+Send-MovieRequest @{id=5;operation='key';key='Enter'} -WaitResult
+& uv run --script "$skill/examples/film-terminal.py" result --directory "$work/session" --id 2 --timeout 30
+if ($LASTEXITCODE -ne 0) { throw 'Recorded command did not succeed' }
+Start-Sleep -Seconds 2  # Keep the observed completion readable in the movie.
+Send-MovieRequest @{id=6;operation='end-take'} -WaitResult
+Send-MovieRequest @{id=7;operation='close'} -WaitResult
+```
+
+Equivalent Git Bash control calls use UTF-8 files and native-form paths.
+Set `skill` and `work` in each call. The first call:
+
+```bash
+set -euo pipefail
+recorder="$skill/examples/film-terminal.py"
+printf '%s\n' '{"id":1,"operation":"begin-take","name":"take-one"}' > "$work/1.json"
+printf '%s\n' '{"id":2,"operation":"run","command":"python -u -c \"print(123); input(); print(456)\"","native_producer":"python","timeout_seconds":120}' > "$work/2.json"
+printf '%s\n' '{"id":3,"operation":"end-take"}' > "$work/3.json"
+uv run --script "$recorder" request --directory "$work/session" --file "$work/1.json" --wait-result
+uv run --script "$recorder" request --directory "$work/session" --file "$work/2.json"
+sleep 2
+uv run --script "$recorder" request --directory "$work/session" --file "$work/3.json" --wait-result
+```
+
+The later Git Bash call:
+
+```bash
+set -euo pipefail
+recorder="$skill/examples/film-terminal.py"
+printf '%s\n' '{"id":4,"operation":"begin-take","name":"take-two"}' > "$work/4.json"
+printf '%s\n' '{"id":5,"operation":"key","key":"Enter"}' > "$work/5.json"
+printf '%s\n' '{"id":6,"operation":"end-take"}' > "$work/6.json"
+printf '%s\n' '{"id":7,"operation":"close"}' > "$work/7.json"
+uv run --script "$recorder" request --directory "$work/session" --file "$work/4.json" --wait-result
+uv run --script "$recorder" request --directory "$work/session" --file "$work/5.json" --wait-result
+uv run --script "$recorder" result --directory "$work/session" --id 2 --timeout 30
+sleep 2
+uv run --script "$recorder" request --directory "$work/session" --file "$work/6.json" --wait-result
+uv run --script "$recorder" request --directory "$work/session" --file "$work/7.json" --wait-result
+```
+
+Use one controller and consecutive IDs starting at one. Acknowledgment only
+means accepted. `--wait-result` and the wait-only `result` command return
+nonzero on failed, unknown, interrupted, or missing results. After a client
+wait timeout, retrieve the original ID with `result`; do not submit it again.
+The command's `timeout_seconds` is separate: its expiry ends the session and
+marks the outcome unknown. `inspect` reports the pending command, active
+take, and next ID; it consumes an ID like every other request.
+
+`end-take` stops capture, preserving shell variables, cwd, and a pending
+command. A second `run` is rejected while one is pending. Use intentional
+`key` requests for input: printable characters (such as the fixture's `q`),
+Enter, Escape, arrows, Tab, and Ctrl-C. `close` finalizes a healthy take;
+`cancel` marks it incomplete. Both release only owned processes and mark a
+pending command interrupted. Wait for the shutdown result before declaring
+cleanup successful.
+
+The viewport is fixed at 1600×900, sampled at a target 5 fps. Read the actual
+rows/columns from readiness; resizing fails the session. Hold important
+states at least 1.3 seconds. A screenshot gap above two seconds fails a take.
+Exports use completed samples on the 0.2-second grid and record duplicates
+in `take.json`; sampling does not prove every faster event was observed.
+Each completed `end-take` supplies `kind: frames`, `src`, and `rate: 5` for
+assembling.md. Keep the final movie at a readable resolution and inspect the
+exported frames and finished movie, including command completion.
+
+For native commands followed by logging, set `native_producer` to the
+explicit first-stage executable. This prevents successful `tee`/`Tee-Object`
+from hiding producer failure. Omit it for opaque/mixed scripts; their result
+does not certify every internal command. Preserve raw PowerShell `$?`
+separately from request-attributed errors and native exit status. See
+rendering-from-a-log.md for direct shell logging recipes.
+
+## Unix: tmux and ttyd
+
 The technique: serve the terminal over HTTP with **ttyd**, attach it to a
 The technique: serve the terminal over HTTP with **ttyd**, attach it to a
 **tmux** session, screenshot the page from a browser, and drive the session
 **tmux** session, screenshot the page from a browser, and drive the session
 with `tmux send-keys` from outside. Real characters from a real shell, in a
 with `tmux send-keys` from outside. Real characters from a real shell, in a
-window you fully control. `examples/film-terminal.py` is a working
-implementation of everything below.
+window you fully control. The commands below are the Unix recipe.
+`examples/film-terminal.py` implements the separate native Windows route.
 
 
 ```bash
 ```bash
 # inside the machine/container being filmed
 # inside the machine/container being filmed

+ 39 - 0
skills/proving-it-works-with-a-movie/recording-motion.md

@@ -116,3 +116,42 @@ scene depends on a job outliving the process that started it.
   only; tagging every navigation forces reloads and breaks hash routing.
   only; tagging every navigation forces reloads and breaks hash routing.
 - **Typed fields with parsers**: a value like `Yes`/`No`/`On`/`Off` in a
 - **Typed fields with parsers**: a value like `Yes`/`No`/`On`/`Off` in a
   YAML-backed form field saves as a boolean and can crash the app on camera.
   YAML-backed form field saves as a boolean and can crash the app on camera.
+
+## Native Windows desktop preflight
+
+From an ordinary-user interactive desktop, capture two seconds into a scratch
+path with an argument array, then inspect application pixels in the image:
+
+```powershell
+$check = "$HOME/movie capture check"
+[IO.Directory]::CreateDirectory($check) | Out-Null
+$arguments = @('-nostdin','-y','-f','gdigrab','-framerate','5','-i','desktop',
+    '-t','2',"$check/capture-check.mp4")
+& ffmpeg @arguments
+if ($LASTEXITCODE -ne 0) { throw 'Desktop capture unavailable' }
+$arguments = @('-nostdin','-y','-i',"$check/capture-check.mp4",
+    '-frames:v','1',"$check/capture-check.png")
+& ffmpeg @arguments
+if ($LASTEXITCODE -ne 0) { throw 'Capture image unavailable' }
+```
+
+A zero exit with wallpaper, a blank window, or missing application pixels
+is **not** a successful GUI preflight. Inspect a late frame too if startup
+may be involved. Do not change machine permissions or unlock a session to
+turn an unavailable result into a claim of success.
+
+For one specific application window, the same backend accepts its exact
+window title. Replace the input argument in the capture array with:
+
+```powershell
+$arguments = @('-nostdin','-y','-f','gdigrab','-framerate','5','-i',
+    'title=Your application window title','-t','2',"$check/window-check.mp4")
+& ffmpeg @arguments
+if ($LASTEXITCODE -ne 0) { throw 'Window capture unavailable' }
+```
+
+Inspect that movie's actual application pixels and dimensions. A verified
+named-window capture proves that window was captured; it does not establish
+that the full-desktop target worked. If neither target shows the app, use
+real browser captures or the run's log and state which GUI behavior remains
+unproven. Keep using the same five media tools in assembling.md.

+ 40 - 0
skills/proving-it-works-with-a-movie/rendering-from-a-log.md

@@ -88,3 +88,43 @@ log is a lie.
 silent (`$SKILL_DIR` = this skill's own directory; see SKILL.md). Then open
 silent (`$SKILL_DIR` = this skill's own directory; see SKILL.md). Then open
 the contact sheet and confirm the panels are legible at full size: a reel
 the contact sheet and confirm the panels are legible at full size: a reel
 nobody can read proves nothing.
 nobody can read proves nothing.
+
+## Preserve native producer status on Windows
+
+PowerShell must save a native program's exit status before logging can hide
+it. With a direct capture followed by `Tee-Object`:
+
+```powershell
+$lines = & $producer @producerArguments 2>&1
+$producerOK = $?
+$producerExit = $LASTEXITCODE
+$lines | Tee-Object -FilePath $log
+if (-not $producerOK -or $producerExit -ne 0) {
+    throw "Producer failed: native exit $producerExit"
+}
+```
+
+For cmdlets, `$?` and caught errors are the relevant outcomes; a stale
+`$LASTEXITCODE` from an earlier native program is not their status. Inside
+the Windows recorder, explicitly name a direct or first-pipeline native
+`native_producer` and let `result` check the recorded producer status.
+
+Git Bash must save `PIPESTATUS` immediately, before another command replaces
+it. Temporarily disabling `errexit` allows the status capture to run even
+when the producer fails:
+
+```bash
+set -o pipefail
+set +e
+"$producer" "${producer_arguments[@]}" 2>&1 | tee "$log"
+statuses=("${PIPESTATUS[@]}")
+set -e
+if (( statuses[0] != 0 || statuses[1] != 0 )); then
+    printf 'Producer exit %s; logger exit %s\n' "${statuses[0]}" "${statuses[1]}" >&2
+    exit 1
+fi
+```
+
+A log reel proves the recorded run and its observed result. If desktop
+capture was unavailable, it does not prove unseen GUI behavior. Assemble,
+narrate, subtitle, and check it using the native commands in assembling.md.

+ 32 - 0
skills/proving-it-works-with-a-movie/rendering-stills.md

@@ -48,3 +48,35 @@ contact sheet, and look. A stills movie earns a
 frozen-tail warning when its final card outlasts its last narration by a
 frozen-tail warning when its final card outlasts its last narration by a
 lot — that usually means the closing card is doing too much work, or the
 lot — that usually means the closing card is doing too much work, or the
 last scene should have been two.
 last scene should have been two.
+
+## Native Windows paths and browser stills
+
+Keep the existing image/frame scene kinds and use the five native commands
+in assembling.md. For a real local HTML page, the existing browser helper
+handles a file URI, an isolated profile, a timeout, and owned cleanup. Save
+this small capture script in the movie's pipeline directory:
+
+```python
+# capture-still.py — arguments: skill-scripts-directory, page.html, image.png
+import sys
+from pathlib import Path
+sys.path.insert(0, sys.argv[1])
+from browser_tools import find_browser, render_card
+browser = find_browser(None)
+if browser is None:
+    raise SystemExit('Install Chrome or Edge, or supply an explicit browser')
+render_card(Path(sys.argv[2]), Path(sys.argv[3]), browser=browser,
+            width=1600, height=900)
+```
+
+PowerShell, using native Python and individually quoted arguments:
+
+```powershell
+& python "$work/capture-still.py" "$skill/scripts" "$work/page.html" "$work/still.png"
+if ($LASTEXITCODE -ne 0) { throw 'Browser still failed' }
+```
+
+In Git Bash, use the same arguments with `skill` and `work` converted by
+`cygpath -m` as in assembling.md. Inspect the PNG before making it an image
+scene. A screenshot of one real state establishes that state; claims about
+clicks or motion need the live interaction route in recording-motion.md.

+ 13 - 6
skills/proving-it-works-with-a-movie/scripts/windows_jobs.py

@@ -167,9 +167,12 @@ class WindowsJob:
         handle = self.k.OpenProcess(0x100000 | 0x1000 | int(terminate), False, pid)
         handle = self.k.OpenProcess(0x100000 | 0x1000 | int(terminate), False, pid)
         if not handle:
         if not handle:
             raise ctypes.WinError(ctypes.get_last_error())
             raise ctypes.WinError(ctypes.get_last_error())
-        if creation is not None and self.process_time(handle) != creation:
+        try:
+            if creation is not None and self.process_time(handle) != creation:
+                raise RuntimeError("Process creation time changed; refusing stale PID")
+        except BaseException:
             self.k.CloseHandle(handle)
             self.k.CloseHandle(handle)
-            raise RuntimeError("Process creation time changed; refusing stale PID")
+            raise
         return handle
         return handle
 
 
     def snapshot(self):
     def snapshot(self):
@@ -185,11 +188,15 @@ class WindowsJob:
                     if error.winerror == 87:  # Exited between enumeration and open.
                     if error.winerror == 87:  # Exited between enumeration and open.
                         continue
                         continue
                     raise
                     raise
-                owned = W.BOOL()
-                if not self.k.IsProcessInJob(handle, self.handle, ctypes.byref(owned)) or not owned.value:
+                try:
+                    owned = W.BOOL()
+                    if not self.k.IsProcessInJob(handle, self.handle, ctypes.byref(owned)) or not owned.value:
+                        raise RuntimeError("Process is no longer a member of the owned job")
+                    creation = self.process_time(handle)
+                except BaseException:
                     self.k.CloseHandle(handle)
                     self.k.CloseHandle(handle)
-                    raise RuntimeError("Process is no longer a member of the owned job")
-                processes.append({"pid": pid, "handle": handle, "creation": self.process_time(handle)})
+                    raise
+                processes.append({"pid": pid, "handle": handle, "creation": creation})
             return processes
             return processes
         except BaseException:
         except BaseException:
             for process in processes:
             for process in processes:

+ 34 - 0
tests/proving-it-works-with-a-movie/README.md

@@ -44,3 +44,37 @@ inputs exercise text comparison only. Neither is speech/ASR acceptance.
 The portable subtitle assertion intentionally strengthens the Bash suite's
 The portable subtitle assertion intentionally strengthens the Bash suite's
 whole-file regex: it parses the first cue start and compares it with the actual
 whole-file regex: it parses the first cue start and compares it with the actual
 assembly offset at SRT's millisecond precision.
 assembly offset at SRT's millisecond precision.
+
+### Final native Windows workflow fixture
+
+With native uv, Python 3.12+, FFmpeg/ffprobe (libass), Chrome or Edge, and ttyd
+prepared, run from each invoking shell and record that same shell. Record
+its version/PID before invoking uv; `--shell` alone is not invocation evidence.
+
+```text
+uv run --script tests/proving-it-works-with-a-movie/run-windows-acceptance.py --work "PATH/movie O'Brien λ & [take]" --shell powershell51 --phase prepare
+```
+
+Repeat with `powershell7` or `gitbash` in a separate work directory. The
+fixture clicks a real local browser counter through CDP, captures its states,
+and prepares BOM-bearing UTF-8/CRLF scenes with a card, image, frames, and a
+stereo source-tone movie. Use the native recording-a-terminal.md recipe with
+`fixtures/terminal_app.py`, keeping its command alive across two separate
+control calls/takes. Keep the second take running through `q`, the command's
+successful result, and a readable completion hold.
+
+```text
+uv run --script tests/proving-it-works-with-a-movie/run-windows-acceptance.py --work "PATH/movie O'Brien λ & [take]" --shell powershell51 --phase finish --take-one "PATH/session/take-one" --take-two "PATH/session/take-two"
+```
+
+Pass exported frame directories, not `samples/`. `finish` runs all five
+public tools, fresh local narration and a new-output cached-model repeat,
+then checks each final audio interval against its narration or known source
+tone. Tool stdout/stderr, arguments/statuses, source hashes, the contact
+sheet, and rendered-audio results go under `work/evidence`. Inspect actual
+pixels, hard captions, timing, and sound before declaring acceptance. The
+source tone is a labeled non-speech fixture, not TTS evidence.
+
+Use the native quoting/path recipes in the skill, remove cloud keys only
+from the test process, and exclude `llm` from its PATH. Retain final media
+outside disposable SDD scratch; do not commit large generated artifacts.

+ 5 - 0
tests/proving-it-works-with-a-movie/fixtures/browser.html

@@ -0,0 +1,5 @@
+<!doctype html><meta charset="utf-8"><title>Local counter proof</title>
+<style>body{margin:80px;background:#122438;color:#fff;font:36px system-ui}button{font:inherit;padding:20px 40px;background:#ffcf66;border:0;border-radius:12px}output{display:block;font-size:160px;margin:40px 0}small{font-size:26px}</style>
+<h1>Local counter — real clicks</h1><button onclick="count.value=Number(count.value)+1">Add one</button><output id="count">0</output><small>This page runs from a local file. Each click updates its own state.</small>
+<i id="cursor" style="position:fixed;display:none;width:24px;height:24px;border:4px solid #ff4081;border-radius:50%;pointer-events:none;transform:translate(-50%,-50%)"></i>
+<script>document.addEventListener('mousemove',e=>{cursor.style.display='block';cursor.style.left=e.clientX+'px';cursor.style.top=e.clientY+'px'});</script>

+ 221 - 0
tests/proving-it-works-with-a-movie/run-windows-acceptance.py

@@ -0,0 +1,221 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = ["pyyaml", "websocket-client==1.9.0"]
+# ///
+"""Prepare real browser scenes, then finish a movie with two exported terminal takes."""
+import argparse
+import array
+import base64
+import hashlib
+import importlib.util
+import json
+import math
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import time
+import wave
+
+from fixtures import duration, load_script, run_tool, _run_ffmpeg
+
+REPO = Path(__file__).resolve().parents[2]
+S = REPO / "skills/proving-it-works-with-a-movie"
+
+
+def write_json(path, value, *, bom=False):
+    path.write_bytes(("\ufeff" if bom else "").encode("utf-8") +
+                     (json.dumps(value, ensure_ascii=False, indent=2) + "\n").replace("\n", "\r\n").encode("utf-8"))
+
+
+def recorder_module():
+    sys.path.insert(0, str(S / "scripts"))
+    spec = importlib.util.spec_from_file_location("acceptance_recorder", S / "examples/film-terminal.py")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def capture_browser(work):
+    """Click the local application through the recorder's retained CDP client."""
+    rec = recorder_module()
+    browser = load_script("browser_tools").find_browser(None)
+    if not browser:
+        raise RuntimeError("Chrome or Edge is required")
+    frames = work / "browser"
+    frames.mkdir()
+    shutil.copyfile(Path(__file__).parent / "fixtures/browser.html", work / "browser.html")
+    port = rec.free_port()
+    job = rec.WindowsJob()
+    cdp = None
+    samples = []
+    try:
+        job.spawn([browser, "--headless=new", "--no-first-run", "--no-default-browser-check",
+                   f"--user-data-dir={work / 'browser-profile'}", "--remote-debugging-address=127.0.0.1",
+                   f"--remote-debugging-port={port}", "about:blank"], work, work / "browser.log")
+        deadline = time.monotonic() + 20
+        while time.monotonic() < deadline:
+            try:
+                page = next(p for p in rec.http_json(f"http://127.0.0.1:{port}/json/list") if p["type"] == "page")
+                cdp = rec.CDP(page["webSocketDebuggerUrl"], lambda event: None, work / "browser-cdp.jsonl")
+                break
+            except (OSError, StopIteration):
+                time.sleep(.1)
+        if cdp is None:
+            raise TimeoutError("fixture browser did not start")
+        cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1600, "height": 900, "deviceScaleFactor": 1, "mobile": False})
+        cdp.call("Page.navigate", {"url": (work / "browser.html").resolve().as_uri()})
+        deadline = time.monotonic() + 10
+        while time.monotonic() < deadline:
+            found = cdp.call("Runtime.evaluate", {"expression": "!!document.querySelector('button')", "returnByValue": True})
+            if found["result"].get("value"):
+                break
+            time.sleep(.1)
+        else:
+            raise TimeoutError("fixture page did not load")
+        position = cdp.call("Runtime.evaluate", {"expression": "(()=>{const r=document.querySelector('button').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()", "returnByValue": True})["result"]["value"]
+        for expected in range(3):
+            if expected:
+                cdp.call("Input.dispatchMouseEvent", {"type": "mouseMoved", **position})
+                for event in ("mousePressed", "mouseReleased"):
+                    cdp.call("Input.dispatchMouseEvent", {"type": event, **position, "button": "left", "clickCount": 1})
+            actual = cdp.call("Runtime.evaluate", {"expression": "document.querySelector('output').value", "returnByValue": True})
+            if actual["result"].get("value") != str(expected):
+                raise RuntimeError(f"real click did not update counter: {actual}")
+            for _ in range(7):
+                started = time.monotonic()
+                request = cdp.send("Page.captureScreenshot", {"format": "png"})
+                deadline = started + 2
+                while request not in cdp.responses and time.monotonic() < deadline:
+                    cdp.pump()
+                response = cdp.responses.pop(request, None)
+                ended = time.monotonic()
+                if ended > deadline or not response or "error" in response:
+                    raise TimeoutError("fixture screenshot exceeded two seconds")
+                target = frames / f"frame-{len(samples):08d}.png"
+                target.write_bytes(base64.b64decode(response["result"]["data"]))
+                samples.append({"state": expected, "started": started, "completed": ended, "frame": target.name})
+                time.sleep(max(0, .2 - (time.monotonic() - started)))
+        shutil.copyfile(frames / "frame-00000000.png", work / "still.png")
+        write_json(work / "browser-samples.json", samples)
+    finally:
+        if cdp:
+            cdp.ws.close()
+            cdp.trace.close()
+        job.close()
+    shutil.rmtree(work / "browser-profile")
+
+
+def prepare(work, shell):
+    work.mkdir(parents=True)
+    capture_browser(work)
+    card = work / "source.html"
+    card.write_text('<meta charset="utf-8"><body style="background:#293548;color:white;font:48px system-ui;padding:90px"><h1>Existing movie segment</h1><p>Original 440 Hz reference tone</p><p>This segment keeps its own audio. No speech.</p>', encoding="utf-8")
+    browser = load_script("browser_tools")
+    browser.render_card(card, work / "source.png", browser=browser.find_browser(None), width=1600, height=900)
+    _run_ffmpeg(["-loop", "1", "-i", str(work / "source.png"), "-f", "lavfi", "-i", "sine=frequency=440:duration=2",
+                 "-t", "2", "-r", "30", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ac", "2", str(work / "source.mp4")], cwd=work)
+    scenes = {"resolution": {"width": 1600, "height": 900}, "fps": 30, "scenes": [
+        {"id": "title", "kind": "card", "title": "Native Windows proof", "body": shell, "duration": 1,
+         "narration": "This movie shows a local browser and a real terminal running on Windows."},
+        {"id": "still", "kind": "image", "src": "still.png", "duration": 4, "narration": "The counter starts at zero."},
+        {"id": "source", "kind": "movie", "src": "source.mp4", "height": 900},
+        {"id": "browser", "kind": "frames", "src": "browser", "rate": 5, "narration": "Two real clicks change the counter from zero to two."},
+        {"id": "take-one", "kind": "frames", "src": "TAKE_ONE", "rate": 5,
+         "narration": "The terminal shows three colored states. The command stays alive between takes."},
+        {"id": "take-two", "kind": "frames", "src": "TAKE_TWO", "rate": 5,
+         "narration": "The same command receives the exit key and finishes successfully."}]}
+    write_json(work / "scenes.json", scenes, bom=True)
+    write_json(work / "prepare.json", {"shell_selection": shell, "browser": browser.find_browser(None), "source_audio": "440 Hz sine, two seconds, no speech"})
+
+
+def tone(path):
+    with wave.open(str(path)) as f:
+        rate = f.getframerate()
+        values = array.array("h", f.readframes(f.getnframes()))
+    # Use the center second, avoiding AAC edge padding.
+    values = values[rate // 2:rate * 3 // 2]
+    crossings = sum(a <= 0 < b for a, b in zip(values, values[1:]))
+    return {"frequency": crossings * rate / len(values), "rms": math.sqrt(sum(v*v for v in values) / len(values))}
+
+
+def finish(work, one, two):
+    scenes = work / "scenes.json"
+    doc = json.loads(scenes.read_text(encoding="utf-8-sig"))
+    for scene, take in zip(doc["scenes"][-2:], [one, two]):
+        if not take.is_dir() or not list(take.glob("*.png")):
+            raise ValueError(f"exported frame directory required: {take}")
+        scene["src"] = str(take.resolve())
+    write_json(scenes, doc, bom=True)
+    voice, segments = work / "narration", work / "assembly work"
+    cut, movie, subtitles = work / "cut.mp4", work / "movie.mp4", work / "movie.srt"
+    evidence = work / "evidence"
+    evidence.mkdir(exist_ok=True)
+    steps = [("narrate", [str(scenes), str(voice), "--engine", "piper", "--verify", "on"]),
+             ("narrate", [str(scenes), str(work / "cached-model-repeat"), "--engine", "piper", "--verify", "on"]),
+             ("assemble", [str(scenes), str(cut), "--narration", str(voice), "--work", str(segments)]),
+             ("make-subtitles", [str(voice / "manifest.json"), str(subtitles), "--offsets-json", str(segments / "offsets.json")]),
+             ("burn-subtitles", [str(cut), str(subtitles), str(movie)]),
+             ("check-movie", [str(movie), "--out", str(evidence / "checker"), "--json"])]
+    records = []
+    for index, (name, args) in enumerate(steps):
+        start = time.time()
+        result = run_tool(name, args, cwd=evidence)
+        (evidence / f"{index}-{name}.stdout").write_bytes(result.stdout)
+        (evidence / f"{index}-{name}.stderr").write_bytes(result.stderr)
+        records.append({"tool": name, "argv": [shutil.which("uv"), "run", "--script", str(S / "scripts" / name), *args], "exit": result.returncode, "elapsed": time.time() - start})
+        write_json(evidence / "commands.json", records)
+        print(f"{name}: exit {result.returncode}", flush=True)
+        if result.returncode:
+            raise RuntimeError(f"{name} failed; inspect {evidence}")
+    verify_audio(work, doc, segments, movie, evidence)
+    write_json(evidence / "source-hashes.json", {str(p.relative_to(REPO)): hashlib.sha256(p.read_bytes()).hexdigest() for p in [S / "examples/film-terminal.py", *[S / "scripts" / n for n in ["narrate", "assemble", "make-subtitles", "burn-subtitles", "check-movie"]], Path(__file__)]})
+
+
+def verify_audio(work, doc, segments, movie, evidence):
+    narrator = load_script("narrate")
+    checks, clock = [], 0.
+    for scene in doc["scenes"]:
+        length = duration(segments / (scene["id"] + ".mp4"))
+        audio = evidence / (scene["id"] + "-rendered.wav")
+        _run_ffmpeg(["-ss", str(clock), "-i", str(movie), "-t", str(length), "-vn", "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", str(audio)], cwd=work)
+        if scene["id"] == "source":
+            reference = evidence / "source-reference.wav"
+            _run_ffmpeg(["-i", str(work / "source.mp4"), "-vn", "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", str(reference)], cwd=work)
+            actual, expected = tone(audio), tone(reference)
+            passed = abs(actual["frequency"] - expected["frequency"]) < 2 and .9 < actual["rms"] / expected["rms"] < 1.1
+            check = {"source_reference": expected, "rendered": actual, "passed": passed}
+        else:
+            heard = narrator.transcribe_local(audio)
+            if heard is None:
+                raise RuntimeError(f"rendered ASR unavailable for {scene['id']}")
+            drift, worst = narrator.structural_drift(scene["narration"], heard)
+            check = {"script": scene["narration"], "heard": heard, "drift": drift, "worst": worst, "passed": drift <= .15 and worst <= 3}
+        checks.append({"scene": scene["id"], "start": clock, "duration": length, **check})
+        write_json(evidence / "rendered-audio.json", checks)
+        if not check["passed"]:
+            raise RuntimeError(f"rendered audio differs for {scene['id']}: {check}")
+        clock += length
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--work", type=Path, required=True)
+    parser.add_argument("--shell", choices=["powershell51", "powershell7", "gitbash"], required=True)
+    parser.add_argument("--phase", choices=["prepare", "finish"], required=True)
+    parser.add_argument("--take-one", type=Path)
+    parser.add_argument("--take-two", type=Path)
+    args = parser.parse_args()
+    if sys.platform != "win32":
+        parser.error("run this native Windows acceptance fixture on Windows")
+    if args.phase == "prepare":
+        prepare(args.work.resolve(), args.shell)
+    else:
+        if args.take_one is None or args.take_two is None:
+            parser.error("finish requires --take-one and --take-two exported frame directories")
+        finish(args.work.resolve(), args.take_one, args.take_two)
+
+
+if __name__ == "__main__":
+    main()

+ 35 - 0
tests/proving-it-works-with-a-movie/test_processes.py

@@ -6,6 +6,41 @@ from pathlib import Path
 import fixtures
 import fixtures
 
 
 
 
+class IdentityFailureTests(unittest.TestCase):
+    def job(self):
+        import ctypes
+        from unittest.mock import Mock
+        module = fixtures.load_script("windows_jobs")
+        job = module.WindowsJob.__new__(module.WindowsJob)
+        job.ctypes = ctypes
+        job.k = Mock()
+        job.handle = 10
+        return job
+
+    def test_open_identity_failure_closes_current_handle(self):
+        from unittest.mock import Mock
+        job = self.job()
+        job.k.OpenProcess.return_value = 21
+        job.process_time = Mock(side_effect=RuntimeError("identity unavailable"))
+        with self.assertRaisesRegex(RuntimeError, "identity unavailable"):
+            job.open_process(3, creation=123)
+        job.k.CloseHandle.assert_called_once_with(21)
+
+    def test_snapshot_identity_failure_closes_current_and_previous_handles(self):
+        from unittest.mock import Mock, call
+        job = self.job()
+        job.pids = Mock(return_value=[1, 2])
+        job.open_process = Mock(side_effect=[21, 22])
+        def owned(handle, parent, output):
+            output._obj.value = True
+            return True
+        job.k.IsProcessInJob.side_effect = owned
+        job.process_time = Mock(side_effect=[123, RuntimeError("identity unavailable")])
+        with self.assertRaisesRegex(RuntimeError, "identity unavailable"):
+            job.snapshot()
+        self.assertCountEqual(job.k.CloseHandle.call_args_list, [call(21), call(22)])
+
+
 @unittest.skipUnless(os.name == "nt", "Windows Job ownership is native Windows only")
 @unittest.skipUnless(os.name == "nt", "Windows Job ownership is native Windows only")
 class WindowsJobRegression(unittest.TestCase):
 class WindowsJobRegression(unittest.TestCase):
     def test_job_wait_and_close_own_child_process(self):
     def test_job_wait_and_close_own_child_process(self):

+ 43 - 0
tests/proving-it-works-with-a-movie/test_terminal.py

@@ -33,6 +33,36 @@ class TerminalPolicyTests(unittest.TestCase):
             with self.subTest(change=change):self.assertFalse(m.command_succeeded(good|change))
             with self.subTest(change=change):self.assertFalse(m.command_succeeded(good|change))
         self.assertTrue(m.command_succeeded(good|dict(native_producer=None,producer_exit_code=None)))
         self.assertTrue(m.command_succeeded(good|dict(native_producer=None,producer_exit_code=None)))
 
 
+class ReadinessDirectoryTests(unittest.TestCase):
+    def test_readiness_requires_requested_directory(self):
+        import tempfile
+        from types import SimpleNamespace
+        from unittest.mock import Mock
+        module = TerminalPolicyTests().recorder()
+        with tempfile.TemporaryDirectory() as tmp:
+            cwd = (Path(tmp) / "movie O'Brien λ & [take]").resolve()
+            cwd.mkdir()
+            for shell in ['powershell51', 'powershell7', 'gitbash']:
+                for actual in [cwd, Path(tmp)]:
+                    with self.subTest(shell=shell, actual=actual):
+                        terminal = module.Terminal.__new__(module.Terminal)
+                        terminal.args = SimpleNamespace(shell_kind=shell, cwd=cwd)
+                        terminal.session = 'session'
+                        terminal.directory = Path(tmp)
+                        terminal.closed = False
+                        terminal.parser = SimpleNamespace(records=[{'session':'session', 'cwd':str(actual)}])
+                        terminal.cdp = SimpleNamespace(pump=Mock())
+                        terminal.type = Mock()
+                        terminal.screenshot = Mock()
+                        if actual == cwd:
+                            self.assertEqual(terminal.readiness()['cwd'], str(cwd))
+                            terminal.screenshot.assert_called_once_with('ready.png')
+                        else:
+                            with self.assertRaisesRegex(RuntimeError, 'requested cwd'):
+                                terminal.readiness()
+                            terminal.screenshot.assert_not_called()
+
+
 import json
 import json
 import os
 import os
 import subprocess
 import subprocess
@@ -40,6 +70,19 @@ import sys
 import tempfile
 import tempfile
 import time
 import time
 
 
+@unittest.skipUnless(sys.platform == 'win32', 'native Windows required')
+class NativeCwdFailureTests(unittest.TestCase):
+    def test_missing_cwd_fails_before_launch(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            directory = Path(tmp) / 'session'
+            result = subprocess.run([sys.executable, str(SCRIPT), 'serve', '--shell',
+                'powershell51', '--directory', str(directory), '--cwd', str(Path(tmp) / 'missing')],
+                capture_output=True, timeout=10)
+            self.assertNotEqual(result.returncode, 0)
+            self.assertFalse((directory / 'launches.json').exists())
+            self.assertFalse((directory / 'control/ready.json').exists())
+
+
 @unittest.skipUnless(sys.platform=='win32', 'native Windows terminal required')
 @unittest.skipUnless(sys.platform=='win32', 'native Windows terminal required')
 class NativeTerminalTests(unittest.TestCase):
 class NativeTerminalTests(unittest.TestCase):
     def setUp(self):
     def setUp(self):