vocalize-cli 0.10.0__tar.gz → 0.10.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/CHANGELOG.md +36 -0
  2. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/PKG-INFO +3 -2
  3. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/README.md +2 -1
  4. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/dictation.md +4 -1
  5. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/report.md +1 -0
  6. vocalize_cli-0.10.1/docs/roadmap.md +24 -0
  7. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/claude_stop_hook.py +38 -3
  8. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_claude_stop_hook.py +61 -0
  9. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_dictate.py +71 -0
  10. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_provider.py +2 -0
  11. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_recorder_build.py +36 -0
  12. vocalize_cli-0.10.1/tests/test_uv_path.py +62 -0
  13. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/__init__.py +1 -1
  14. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/dictate.py +48 -5
  15. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/__init__.py +10 -2
  16. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/install.py +14 -4
  17. vocalize_cli-0.10.1/vocalize/recorder/Recorder.entitlements +8 -0
  18. vocalize_cli-0.10.0/docs/roadmap.md +0 -22
  19. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.env.example +0 -0
  20. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.github/workflows/ci.yml +0 -0
  21. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.gitignore +0 -0
  22. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/LICENSE +0 -0
  23. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/installation.md +0 -0
  24. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/next-features-analysis.md +0 -0
  25. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/choreography.md +0 -0
  26. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/decisions.md +0 -0
  27. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/design.md +0 -0
  28. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/plan.md +0 -0
  29. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/review-0.10.0.md +0 -0
  30. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/project-plan.md +0 -0
  31. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/report.md +0 -0
  32. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/validate-exit.sh +0 -0
  33. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-10-release-0-11-0/project-plan.md +0 -0
  34. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-10-release-0-11-0/validate-exit.sh +0 -0
  35. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/project-plan.md +0 -0
  36. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/report.md +0 -0
  37. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/validate-exit.sh +0 -0
  38. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/project-plan.md +0 -0
  39. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/report.md +0 -0
  40. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/validate-exit.sh +0 -0
  41. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/project-plan.md +0 -0
  42. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/report.md +0 -0
  43. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/validate-exit.sh +0 -0
  44. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/project-plan.md +0 -0
  45. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/report.md +0 -0
  46. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/validate-exit.sh +0 -0
  47. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/project-plan.md +0 -0
  48. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/validate-exit.sh +0 -0
  49. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-7-portal-read/project-plan.md +0 -0
  50. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-7-portal-read/validate-exit.sh +0 -0
  51. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-8-portal-write/project-plan.md +0 -0
  52. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-8-portal-write/validate-exit.sh +0 -0
  53. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-9-portal-page/project-plan.md +0 -0
  54. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-9-portal-page/validate-exit.sh +0 -0
  55. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/spike-2026-09-01.md +0 -0
  56. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/split-assessment.md +0 -0
  57. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/verification.md +0 -0
  58. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/provider-credentials.md +0 -0
  59. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-config-portal-design.md +0 -0
  60. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-dictation-design.md +0 -0
  61. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-voicebox-findings.md +0 -0
  62. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/install_hook.py +0 -0
  63. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/install_quick_action.py +0 -0
  64. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Dictate with Vocalize.workflow/Contents/Info.plist +0 -0
  65. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Dictate with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  66. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Info.plist +0 -0
  67. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Resources/document.wflow +0 -0
  68. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Info.plist +0 -0
  69. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  70. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Info.plist +0 -0
  71. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Resources/document.wflow +0 -0
  72. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/speak_options.py +0 -0
  73. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/speak_url_gate.py +0 -0
  74. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/pyproject.toml +0 -0
  75. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/conftest.py +0 -0
  76. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_audio.py +0 -0
  77. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_auth.py +0 -0
  78. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_cache.py +0 -0
  79. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_chain.py +0 -0
  80. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_cli.py +0 -0
  81. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_clipboard.py +0 -0
  82. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_config.py +0 -0
  83. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_elevenlabs_provider.py +0 -0
  84. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_exceptions.py +0 -0
  85. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_google_provider.py +0 -0
  86. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_http.py +0 -0
  87. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_install_hook.py +0 -0
  88. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_install_quick_action.py +0 -0
  89. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_manifest.py +0 -0
  90. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_worker.py +0 -0
  91. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_ledger.py +0 -0
  92. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_listen_check.py +0 -0
  93. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_local_install.py +0 -0
  94. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_openai_provider.py +0 -0
  95. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_polly_provider.py +0 -0
  96. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_preprocess.py +0 -0
  97. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_providers_registry.py +0 -0
  98. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_readiness.py +0 -0
  99. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_say_provider.py +0 -0
  100. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_speak_options.py +0 -0
  101. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_speak_url_gate.py +0 -0
  102. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_tts.py +0 -0
  103. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_whisper_manifest.py +0 -0
  104. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_whisper_worker.py +0 -0
  105. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_wizard.py +0 -0
  106. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/__main__.py +0 -0
  107. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/audio.py +0 -0
  108. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/auth.py +0 -0
  109. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/cache.py +0 -0
  110. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/chain.py +0 -0
  111. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/cli.py +0 -0
  112. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/clipboard.py +0 -0
  113. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/config.py +0 -0
  114. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/exceptions.py +0 -0
  115. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/interrupted.py +0 -0
  116. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/ledger.py +0 -0
  117. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/kokoro_manifest.py +0 -0
  118. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/kokoro_worker.py +0 -0
  119. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/whisper_manifest.py +0 -0
  120. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/whisper_worker.py +0 -0
  121. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/preprocess.py +0 -0
  122. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/__init__.py +0 -0
  123. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/_http.py +0 -0
  124. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/elevenlabs.py +0 -0
  125. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/google.py +0 -0
  126. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/kokoro.py +0 -0
  127. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/openai.py +0 -0
  128. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/polly.py +0 -0
  129. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/say.py +0 -0
  130. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/readiness.py +0 -0
  131. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/recorder/Info.plist.in +0 -0
  132. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/recorder/VocalizeRecorder.swift +0 -0
  133. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/tts.py +0 -0
  134. {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/wizard.py +0 -0
@@ -3,6 +3,42 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## 0.10.1 - 2026-09-02
7
+
8
+ Three fixes found in the first owner-present run of 0.10.0's dictation.
9
+ Together they meant no hotkey dictation could succeed on 0.10.0; upgrade.
10
+
11
+ ### Fixed
12
+
13
+ - **No dictation could ever start on a fresh install.** The recorder was
14
+ signed with the hardened runtime but without the
15
+ `com.apple.security.device.audio-input` entitlement, so macOS refused the
16
+ microphone on the spot — no permission dialog, status stuck at
17
+ `notDetermined` — and every first press ended in "The recorder did not
18
+ start". The bundle is now signed with
19
+ `vocalize/recorder/Recorder.entitlements`, and the entitlements are part
20
+ of the recorder's fingerprint, so `vocalize local install --stt` rebuilds
21
+ the bundle once (and, as with any rebuild, macOS asks for the microphone
22
+ again — it never actually asked before).
23
+ - **Every hotkey dictation ended in "Dictation failed" on a machine whose
24
+ `uv` came from Homebrew.** A Services environment has a bare PATH, and
25
+ `uv_path()` looked only there and in `~/.local/bin`; the same dictation
26
+ worked from a terminal. `/opt/homebrew/bin/uv` and `/usr/local/bin/uv`
27
+ are now tried too (this also covers Kokoro from a Quick Action).
28
+ - **Holding the dictation hotkey down turned into a cancel-and-restart
29
+ loop.** macOS re-fires a Service shortcut at the key-repeat rate, and
30
+ every repeat landed as a second press. Presses within half a second of
31
+ the previous one are now ignored as the same press; a deliberate cancel
32
+ is "press, a beat, press" inside the two-second window, as before.
33
+ - `hooks/claude_stop_hook.py --latest`, run from inside a Claude Code turn
34
+ (which is how `/speak` runs it), spoke the agent's own status line —
35
+ "Checking settings." — instead of the response the user asked to hear.
36
+ It now skips the turn in progress, back past the `/speak` message
37
+ itself, and speaks the response before it. From a plain terminal, where
38
+ no turn is in progress, `--latest` still speaks the newest response; the
39
+ hook tells the two apart by the `CLAUDECODE` variable Claude Code sets
40
+ in its shell. The Stop-hook path is unchanged.
41
+
6
42
  ## 0.10.0 - 2026-09-02
7
43
 
8
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
5
5
  Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
6
6
  Project-URL: Repository, https://github.com/matthager12-collab/vocalize
@@ -478,7 +478,8 @@ terminal, if you'd rather trigger it that way.
478
478
  transcript is on your clipboard; nothing is typed for you automatically.
479
479
  - **Nothing heard** (silence, or a microphone that isn't actually picking
480
480
  anything up) ends the dictation quietly — no clipboard write.
481
- - **Cancel** with a second press *within two seconds* of the first, or at
481
+ - **Cancel** with a second press *within two seconds* of the first (but
482
+ not within half a second — that's a held key, and it's ignored), or at
482
483
  any point with `vocalize listen --cancel` — the audio is discarded, never
483
484
  transcribed.
484
485
  - **A third press while transcribing is refused**: a Pop, and "Still
@@ -442,7 +442,8 @@ terminal, if you'd rather trigger it that way.
442
442
  transcript is on your clipboard; nothing is typed for you automatically.
443
443
  - **Nothing heard** (silence, or a microphone that isn't actually picking
444
444
  anything up) ends the dictation quietly — no clipboard write.
445
- - **Cancel** with a second press *within two seconds* of the first, or at
445
+ - **Cancel** with a second press *within two seconds* of the first (but
446
+ not within half a second — that's a held key, and it's ignored), or at
446
447
  any point with `vocalize listen --cancel` — the audio is discarded, never
447
448
  transcribed.
448
449
  - **A third press while transcribing is refused**: a Pop, and "Still
@@ -132,7 +132,10 @@ touching System Settings at all.
132
132
 
133
133
  **A second press within two seconds of the first is a cancel**, not a
134
134
  stop — treated as "I changed my mind" rather than the end of a very short
135
- sentence. So is `vocalize listen --cancel`, from a terminal, at any point
135
+ sentence. (Presses closer than half a second apart are the key being
136
+ *held* — macOS repeats a Service shortcut at the key-repeat rate — and are
137
+ ignored, so a cancel is "press, a beat, press".) So is
138
+ `vocalize listen --cancel`, from a terminal, at any point
136
139
  — including while a take is being transcribed. (The transcription that was
137
140
  already running keeps its own copy of the recording and finishes normally;
138
141
  `--cancel` only releases the hotkey so your next press starts a fresh
@@ -11,6 +11,7 @@ Branch `next-features`, shared checkout. Source: [project-plan.md](./project-pla
11
11
  - `status` (no mic): [run-1-status/report.md](../run-1-status/report.md) (the module and its exit-code contract) plus [run-4-dictation/report.md](../run-4-dictation/report.md) T-45 (the four STT readiness rows), further hardened this run (mic-verdict age, locked probe registry — see review-0.10.0.md).
12
12
  - Resume drill (no mic): [run-5-resume/report.md](../run-5-resume/report.md) T-46/T-47, with this run's review closing the gaps it left open (DEC-013's queued-read case, DEC-014f's voice/speed/chunking carry-over).
13
13
  - Note: rebuilding the recorder for the Swift permission-dialog fix (`94e5a46`) reset the microphone grant on the reference Mac to `notDetermined`, so Manual check 1 starts from a genuinely fresh state — not a regression, the state the fix exists for.
14
+ - **Owner-present run, 2026-09-02 evening (after 0.10.0 published):** Manual check 1 (mic grant) and 2 (hotkey path, real voice, transcript on the clipboard) done — but only after three defects were fixed and released as 0.10.1: the recorder was signed with the hardened runtime and no `audio-input` entitlement (no prompt ever appeared); `uv_path()` could not find Homebrew's uv from a Service's bare PATH (every stop ended in "Dictation failed"); and a held hotkey re-fired as serial cancel/restart presses (now debounced). Manual 3 is partly covered (held-key refusal); 4, 4b, 4c and 5 remain.
14
15
  - **T-53: pending** — merge and publish. Not started. This run does not merge to main or publish; per project-plan.md that happens on the owner's word, after T-52. The orchestrator handles this after reading this report.
15
16
 
16
17
  ## Security-gate summary (every run's verdict)
@@ -0,0 +1,24 @@
1
+ # Roadmap
2
+
3
+ Future work, with the research behind each item. Status legend: **planned** = analysed, decisions pending, nothing built; **spiked** = feasibility measured; **building**; **shipped**.
4
+
5
+ | Item | Status | Tracking | Research |
6
+ |---|---|---|---|
7
+ | Hotkey-triggered local dictation (voice → text via a local Whisper model, Quick Action toggle, clipboard output, optional cleanup) | **shipped** in 0.10.0, usable from 0.10.1 | [#1](https://github.com/matthager12-collab/vocalize/issues/1) · [plan](plans/2026-09-next-features/plan.md) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-dictation-design.md) · [voicebox findings](research/2026-09-01-voicebox-findings.md) |
8
+ | `vocalize status` one-screen readiness check | **shipped** in 0.10.0 | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) |
9
+ | Spoken cues for dictation (`[stt] cues = "words"`: "start" / "stopped" / "ready" instead of, or as well as, the sounds) | building | — | owner request, 2026-09-02 |
10
+ | Config portal (`vocalize portal`, stdlib local web page on top of the same readiness rows) | planned — runs 7–10 of the [plan](plans/2026-09-next-features/plan.md) | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-config-portal-design.md) |
11
+
12
+ ## Decisions (2026-09-01, closed 2026-09-02)
13
+
14
+ 1. The dictation spike ran ([results](plans/2026-09-next-features/spike-2026-09-01.md)); Whisper `small.en` is the default.
15
+ 2. `vocalize status` first (0.10.0); the portal follows as 0.11.0.
16
+ 3. Hotkey chord ⌃⌥⌘D, set by the user in System Settings.
17
+
18
+ ## Deferred on purpose
19
+
20
+ Hold-to-talk (needs an event tap + Input Monitoring, ~6 h), auto-paste (Accessibility, ~2 h, belongs in the recorder bundle), Apple STT mode (~1.5 h), menu-bar agent (~12 h), local-LLM cleanup, resident/pre-warmed STT worker (only if the spike shows >2.5 s overhead), a native SwiftUI/Tauri app.
21
+
22
+ ## Sequencing suggestion
23
+
24
+ Dictation first (the spike decides the engine, then ~20 h), portal or `status` second.
@@ -7,7 +7,10 @@
7
7
  2. On demand, with `--latest`. Nothing is read from stdin; the script finds
8
8
  the most recently written transcript under ~/.claude/projects and speaks
9
9
  that response. Run it when you want speech instead of installing the
10
- hook and getting it after every turn.
10
+ hook and getting it after every turn. Run from inside a Claude Code turn
11
+ (the /speak command does this; Claude Code sets CLAUDECODE=1 in its
12
+ shell) it skips that turn — the newest text there is the agent's own
13
+ status line — and speaks the response before it.
11
14
 
12
15
  Add `--print-length` to either mode to print the response's character
13
16
  count instead of speaking — for wrappers deciding whether to ask about
@@ -59,7 +62,27 @@ def _speech_timeout(text: str) -> int:
59
62
  return min(TIMEOUT_CEILING_SECONDS, TIMEOUT_BASE_SECONDS + len(text) // CHARS_PER_SECOND)
60
63
 
61
64
 
62
- def _extract_last_assistant_text(transcript_path: str) -> str:
65
+ def _is_human_message(entry: dict) -> bool:
66
+ """A user entry the person typed, not one Claude Code wrote.
67
+
68
+ Claude Code logs tool results and its own injected text — a skill's
69
+ body, say — as user entries too; the latter carry ``isMeta``.
70
+ """
71
+ if entry.get("type") != "user" or entry.get("isMeta"):
72
+ return False
73
+ content = (entry.get("message") or {}).get("content") or []
74
+ if isinstance(content, str):
75
+ return True
76
+ return any(isinstance(b, dict) and b.get("type") != "tool_result" for b in content)
77
+
78
+
79
+ def _extract_last_assistant_text(transcript_path: str, skip_current_turn: bool = False) -> str:
80
+ """Newest assistant text in the transcript.
81
+
82
+ With skip_current_turn, everything after the newest human message is
83
+ the turn still in progress (the one running this script); skip it and
84
+ return the response before that message instead.
85
+ """
63
86
  last_text_parts: list[str] = []
64
87
  try:
65
88
  with open(transcript_path, "r", encoding="utf-8") as f:
@@ -76,6 +99,11 @@ def _extract_last_assistant_text(transcript_path: str) -> str:
76
99
  except json.JSONDecodeError:
77
100
  continue
78
101
 
102
+ if skip_current_turn:
103
+ if _is_human_message(entry):
104
+ skip_current_turn = False # past the /speak message; older is fair game
105
+ continue
106
+
79
107
  if entry.get("type") != "assistant":
80
108
  continue
81
109
 
@@ -117,17 +145,24 @@ def main() -> int:
117
145
  if "--latest" in sys.argv[1:]:
118
146
  # On-demand mode: no hook payload on stdin, so don't read it at all.
119
147
  transcript_path = _find_latest_transcript()
148
+ # Run from inside a Claude Code turn (the /speak command does this,
149
+ # and Claude Code sets CLAUDECODE=1 in its shell), the newest
150
+ # assistant text is that turn's own status line — "Checking
151
+ # settings." — not the response the user asked to hear. From a plain
152
+ # terminal there is no turn in progress and the newest text is right.
153
+ skip_current_turn = bool(os.environ.get("CLAUDECODE"))
120
154
  else:
121
155
  try:
122
156
  payload = json.load(sys.stdin)
123
157
  except json.JSONDecodeError:
124
158
  payload = {}
125
159
  transcript_path = payload.get("transcript_path")
160
+ skip_current_turn = False # at Stop the turn is over: newest text is the target
126
161
 
127
162
  if not transcript_path:
128
163
  return 0 # nothing to do — don't block Claude Code on a hook error
129
164
 
130
- text = _extract_last_assistant_text(transcript_path)
165
+ text = _extract_last_assistant_text(transcript_path, skip_current_turn)
131
166
  if not text.strip():
132
167
  if "--print-length" in sys.argv[1:]:
133
168
  print(0)
@@ -14,6 +14,10 @@ def _assistant(content) -> str:
14
14
  return json.dumps({"type": "assistant", "message": {"content": content}})
15
15
 
16
16
 
17
+ def _user(content, **extra) -> str:
18
+ return json.dumps({"type": "user", "message": {"content": content}, **extra})
19
+
20
+
17
21
  def _text(text: str) -> dict:
18
22
  return {"type": "text", "text": text}
19
23
 
@@ -28,6 +32,9 @@ def _patch_main(monkeypatch, payload, which="/usr/local/bin/vocalize", run=None,
28
32
  """Wire up stdin, PATH lookup and subprocess so main() can't shell out."""
29
33
  monkeypatch.delenv("VOCALIZE_MAX_CHARS", raising=False)
30
34
  monkeypatch.delenv("VOCALIZE_BIN", raising=False)
35
+ # The suite itself often runs inside a Claude Code turn, where this is
36
+ # set; a --latest test must not change meaning depending on who ran it.
37
+ monkeypatch.delenv("CLAUDECODE", raising=False)
31
38
  monkeypatch.setattr(hook.sys, "argv", ["claude_stop_hook.py"])
32
39
  monkeypatch.setattr(
33
40
  hook.sys, "stdin", io.StringIO(json.dumps(payload)) if stdin is None else stdin
@@ -102,6 +109,31 @@ def test_falls_back_past_tool_use_only_entry(tmp_path):
102
109
  assert hook._extract_last_assistant_text(path) == "the spoken answer"
103
110
 
104
111
 
112
+ def test_skip_current_turn_returns_the_response_before_the_newest_human_message(tmp_path):
113
+ # /speak runs the hook mid-turn. By then the agent has written a status
114
+ # line and Claude Code has logged the Bash call that runs the hook (and
115
+ # its result). The response the user wants is the one BEFORE /speak.
116
+ # Only the typed /speak line counts as the person speaking: tool results
117
+ # and Claude Code's own injected text (isMeta) are user entries too.
118
+ path = _write_transcript(
119
+ tmp_path,
120
+ [
121
+ _assistant([_text("the long response the user asked to hear")]),
122
+ _user("<command-name>/speak</command-name>"),
123
+ _user([_text("Base directory for this skill: ...")], isMeta=True),
124
+ _assistant([_text("Checking settings.")]),
125
+ _assistant([{"type": "tool_use", "name": "Bash", "input": {}}]),
126
+ _user([{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]),
127
+ ],
128
+ )
129
+
130
+ assert hook._extract_last_assistant_text(path, skip_current_turn=True) == (
131
+ "the long response the user asked to hear"
132
+ )
133
+ # The Stop-hook path still wants the newest text: at Stop, the turn is over.
134
+ assert hook._extract_last_assistant_text(path) == "Checking settings."
135
+
136
+
105
137
  def test_ignores_malformed_json_lines(tmp_path):
106
138
  # The garbage line must sit AFTER the newest assistant entry. The scan
107
139
  # walks reversed(lines) and breaks on the first text it finds, so a
@@ -326,6 +358,35 @@ def test_latest_mode_speaks_newest_transcript_without_reading_stdin(monkeypatch,
326
358
  assert calls[0][-1] == "newest session"
327
359
 
328
360
 
361
+ def test_latest_mode_skips_the_current_turn_only_inside_claude_code(monkeypatch, tmp_path):
362
+ transcript = tmp_path / "session.jsonl"
363
+ transcript.write_text(
364
+ "\n".join(
365
+ [
366
+ _assistant([_text("the long response")]),
367
+ _user("/speak"),
368
+ _assistant([_text("Checking settings.")]),
369
+ ]
370
+ )
371
+ + "\n",
372
+ encoding="utf-8",
373
+ )
374
+ calls = _patch_main(monkeypatch, {})
375
+ monkeypatch.setattr(hook.sys, "argv", ["claude_stop_hook.py", "--latest"])
376
+ monkeypatch.setattr(hook, "_find_latest_transcript", lambda: str(transcript))
377
+
378
+ # From a plain terminal there is no turn in progress: newest text wins,
379
+ # exactly as the README's on-demand mode promises.
380
+ assert hook.main() == 0
381
+ assert calls[-1][-1] == "Checking settings."
382
+
383
+ # Inside a Claude Code turn, that newest text is the agent's own status
384
+ # line; the user wants the response before their /speak.
385
+ monkeypatch.setenv("CLAUDECODE", "1")
386
+ assert hook.main() == 0
387
+ assert calls[-1][-1] == "the long response"
388
+
389
+
329
390
  def test_subprocess_failure_is_logged_not_raised(monkeypatch, tmp_path, capsys):
330
391
  def boom(argv):
331
392
  raise RuntimeError("no audio device")
@@ -162,6 +162,9 @@ def harness(tmp_path, monkeypatch):
162
162
  # the grace for a record no fake read is going to write. The wait has
163
163
  # its own two tests below; these are about the toggle.
164
164
  monkeypatch.setattr(dictate, "_RESUME_GRACE", 0.05)
165
+ # Presses here land microseconds apart, which to the real debounce is
166
+ # a held key. Its own tests below put the window back.
167
+ monkeypatch.setattr(dictate, "_DEBOUNCE", 0.0)
165
168
 
166
169
  # A live PID is our recorder; a dead one is nothing. Replaced because
167
170
  # no shebang script can ever be *named* `recorder` to real `ps`.
@@ -486,6 +489,74 @@ def test_a_second_press_within_the_window_cancels(recorder, transcriber, harness
486
489
  assert any(dictate._NOTIFY_CANCELLED in line for line in harness.notifications())
487
490
 
488
491
 
492
+ def test_a_held_key_is_ignored_not_cancelled(recorder, transcriber, harness, monkeypatch):
493
+ """macOS re-fires a Service shortcut at the key-repeat rate while the
494
+ chord is held. Seen live 2026-09-02: every repeat landed as a cancel and
495
+ then a fresh start, dozens of times a minute. Presses inside the
496
+ debounce are the same press."""
497
+ recorder()
498
+ transcriber()
499
+ monkeypatch.setattr(dictate, "_DEBOUNCE", 0.5)
500
+ assert start() == 0
501
+
502
+ for _ in range(3): # the key is still down
503
+ assert dictate.toggle(stt()) == 0
504
+
505
+ assert dictate.session_path().is_file() # still recording
506
+ assert harness.played == ["Tink.aiff"] # no Pop, no cancel
507
+ assert harness.notifications() == []
508
+ assert lines(harness.uv_argv) == []
509
+
510
+
511
+ def test_a_second_tap_after_the_debounce_still_cancels(recorder, transcriber, harness, monkeypatch):
512
+ recorder()
513
+ transcriber()
514
+ monkeypatch.setattr(dictate, "_DEBOUNCE", 0.05)
515
+ start()
516
+ time.sleep(0.06)
517
+
518
+ assert dictate.toggle(stt()) == 0 # inside the two-second cancel window
519
+
520
+ assert not dictate.session_path().exists()
521
+ assert any(dictate._NOTIFY_CANCELLED in line for line in harness.notifications())
522
+
523
+
524
+ def test_a_repeat_queued_behind_a_slow_press_is_still_ignored(recorder, harness, monkeypatch):
525
+ """The Services runner runs presses one after another: a repeat fired
526
+ while a press was still launching the recorder begins the instant that
527
+ press returns. Measured from the press's *start*, it would look like a
528
+ deliberate second tap; the exit stamp is what catches it."""
529
+ recorder()
530
+ monkeypatch.setattr(dictate, "_DEBOUNCE", 0.1)
531
+ launch = dictate._launch_recorder
532
+
533
+ def slow_launch(workdir, stt):
534
+ time.sleep(0.15) # longer than the whole debounce window
535
+ return launch(workdir, stt)
536
+
537
+ monkeypatch.setattr(dictate, "_launch_recorder", slow_launch)
538
+ assert start() == 0
539
+
540
+ assert dictate.toggle(stt()) == 0 # the queued repeat
541
+
542
+ assert dictate.session_path().is_file()
543
+ assert harness.played == ["Tink.aiff"]
544
+
545
+
546
+ def test_the_press_stamp_is_refreshed_by_ignored_presses(recorder, harness, monkeypatch):
547
+ """Each repeat re-arms the window, so a key held longer than the window
548
+ stays ignored until it is released."""
549
+ recorder()
550
+ monkeypatch.setattr(dictate, "_DEBOUNCE", 0.1)
551
+ start()
552
+ for _ in range(4):
553
+ time.sleep(0.05) # each gap is inside the window; the total is not
554
+ assert dictate.toggle(stt()) == 0
555
+
556
+ assert dictate.session_path().is_file()
557
+ assert harness.played == ["Tink.aiff"]
558
+
559
+
489
560
  def test_cancel_from_the_command_line_discards_the_recording(
490
561
  recorder, transcriber, harness
491
562
  ):
@@ -177,6 +177,8 @@ def no_uv(monkeypatch, tmp_path):
177
177
  monkeypatch.setattr(local.shutil, "which", lambda name: None)
178
178
  # Path.home() reads $HOME, so this empties the ~/.local/bin fallback too.
179
179
  monkeypatch.setenv("HOME", str(tmp_path / "empty-home"))
180
+ # ...and the Homebrew spots are real paths on a developer's machine.
181
+ monkeypatch.setattr(local, "UV_FALLBACKS", ())
180
182
 
181
183
 
182
184
  @pytest.fixture
@@ -234,6 +234,42 @@ def test_the_signature_turns_the_hardened_runtime_on(bin_dir):
234
234
  assert fake.calls[-1][0][fake.calls[-1][0].index("--options") + 1] == "runtime"
235
235
 
236
236
 
237
+ def test_the_hardened_runtime_is_signed_with_the_microphone_entitlement(bin_dir):
238
+ """Under the hardened runtime, AVCaptureDevice.requestAccess is refused
239
+ on the spot — no dialog, status stays notDetermined — unless the binary
240
+ declares com.apple.security.device.audio-input. Seen live on 2026-09-02:
241
+ every first dictation ended in "The recorder did not start"."""
242
+ import plistlib
243
+
244
+ fake = FakeToolchain()
245
+
246
+ install_module.build_recorder(bin_dir=bin_dir, runner=fake)
247
+
248
+ argv = fake.calls[-1][0]
249
+ assert "--entitlements" in argv
250
+ entitlements = Path(argv[argv.index("--entitlements") + 1])
251
+ assert entitlements == install_module.RECORDER_ENTITLEMENTS
252
+ with entitlements.open("rb") as f:
253
+ declared = plistlib.load(f)
254
+ assert declared == {"com.apple.security.device.audio-input": True}
255
+
256
+
257
+ def test_a_changed_entitlements_file_forces_a_rebuild(bin_dir, tmp_path, monkeypatch):
258
+ """The entitlements are part of the signature, so they are part of the
259
+ recorder's identity: change them and the bundle must be rebuilt (and
260
+ the user told to re-grant), exactly like a source change."""
261
+ copy = tmp_path / "Recorder.entitlements"
262
+ shutil.copy(install_module.RECORDER_ENTITLEMENTS, copy)
263
+ monkeypatch.setattr(install_module, "RECORDER_ENTITLEMENTS", copy)
264
+
265
+ assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "built"
266
+ assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "current"
267
+
268
+ copy.write_text(copy.read_text(encoding="utf-8") + "\n", encoding="utf-8")
269
+
270
+ assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "rebuilt"
271
+
272
+
237
273
  def test_the_compile_argv_is_a_list_naming_only_our_own_paths(bin_dir):
238
274
  fake = FakeToolchain()
239
275
 
@@ -0,0 +1,62 @@
1
+ """`vocalize.local.uv_path()`: PATH, then ~/.local/bin, then Homebrew.
2
+
3
+ The Quick Actions run under a Services environment whose PATH is bare, so
4
+ a Homebrew uv (Apple silicon or Intel) has to be found by name — seen live
5
+ on 2026-09-02, when every hotkey dictation ended in "uv is not installed"
6
+ while the same command worked from a terminal.
7
+ """
8
+
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from vocalize import local
15
+
16
+
17
+ @pytest.fixture
18
+ def off_path(monkeypatch, tmp_path):
19
+ """Nothing on PATH and an empty home, so only the fallbacks can answer."""
20
+ monkeypatch.setattr(shutil, "which", lambda name: None)
21
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
22
+ return tmp_path
23
+
24
+
25
+ def test_path_wins(monkeypatch, tmp_path):
26
+ on_path = tmp_path / "uv"
27
+ on_path.write_text("")
28
+ monkeypatch.setattr(shutil, "which", lambda name: str(on_path) if name == "uv" else None)
29
+
30
+ assert local.uv_path() == str(on_path)
31
+
32
+
33
+ def test_the_installer_spot_in_home_is_tried_before_homebrew(off_path, monkeypatch):
34
+ own = off_path / "home" / ".local" / "bin" / "uv"
35
+ own.parent.mkdir(parents=True)
36
+ own.write_text("")
37
+ homebrew = off_path / "opt" / "homebrew" / "bin" / "uv"
38
+ homebrew.parent.mkdir(parents=True)
39
+ homebrew.write_text("")
40
+ monkeypatch.setattr(local, "UV_FALLBACKS", (homebrew,))
41
+
42
+ assert local.uv_path() == str(own)
43
+
44
+
45
+ def test_a_homebrew_uv_is_found_off_path(off_path, monkeypatch):
46
+ missing = off_path / "usr" / "local" / "bin" / "uv"
47
+ homebrew = off_path / "opt" / "homebrew" / "bin" / "uv"
48
+ homebrew.parent.mkdir(parents=True)
49
+ homebrew.write_text("")
50
+ monkeypatch.setattr(local, "UV_FALLBACKS", (homebrew, missing))
51
+
52
+ assert local.uv_path() == str(homebrew)
53
+
54
+
55
+ def test_no_uv_anywhere_is_none(off_path, monkeypatch):
56
+ monkeypatch.setattr(local, "UV_FALLBACKS", (off_path / "nope" / "uv",))
57
+
58
+ assert local.uv_path() is None
59
+
60
+
61
+ def test_the_shipped_fallbacks_are_homebrew_on_both_architectures():
62
+ assert local.UV_FALLBACKS == (Path("/opt/homebrew/bin/uv"), Path("/usr/local/bin/uv"))
@@ -7,4 +7,4 @@ formatting into something that actually sounds good spoken aloud
7
7
  which is close to useless).
8
8
  """
9
9
 
10
- __version__ = "0.10.0"
10
+ __version__ = "0.10.1"
@@ -75,6 +75,16 @@ _WORKDIR_PREFIX = "vocalize-dictate-"
75
75
  # A second press this soon after the first is the user changing their mind,
76
76
  # not the end of a sentence (design § Key flows).
77
77
  _CANCEL_WINDOW = 2.0
78
+ # ...but a press this soon after the *previous press* is the key still being
79
+ # held: macOS re-fires a Service shortcut at its key-repeat rate, and every
80
+ # repeat used to land as a cancel, then a fresh start, for as long as the
81
+ # chord was down. The runner executes presses serially, so the gap between
82
+ # a press ending and the queued repeat beginning is one process start-up
83
+ # (~0.3 s here); this has to be comfortably longer than that and shorter
84
+ # than a deliberate second tap. The stamp lives outside the session so it
85
+ # also covers repeats arriving while a stop is being finished.
86
+ _DEBOUNCE = 1.0
87
+ _PRESS_NAME = "dictate.press"
78
88
 
79
89
  # How long the first press waits for the recorder to say it started. Also
80
90
  # the window in which a second press treats a missing `rec.pid` as "still
@@ -967,13 +977,46 @@ def _finish_take(workdir: Path, stt: dict) -> tuple[str | None, bool]:
967
977
  # --- the toggle state machine -----------------------------------------
968
978
 
969
979
 
980
+ def _stamp_press() -> None:
981
+ """Record the moment as the latest press. Best effort: a stamp that
982
+ cannot be written simply never debounces."""
983
+ try:
984
+ audio.ensure_private_dir(CACHE_DIR)
985
+ (CACHE_DIR / _PRESS_NAME).write_text(f"{time.time()}\n", encoding="utf-8")
986
+ except OSError:
987
+ pass
988
+
989
+
990
+ def _is_key_repeat() -> bool:
991
+ """Whether this press arrived within `_DEBOUNCE` of the previous stamp.
992
+
993
+ The Services runner executes presses one after another, so a repeat
994
+ queued behind a running press starts the moment that press *ends* —
995
+ which is why `toggle` stamps on the way out as well as on the way in.
996
+ A held key keeps refreshing the stamp and stays ignored until it is
997
+ released.
998
+ """
999
+ now = time.time()
1000
+ try:
1001
+ last = float((CACHE_DIR / _PRESS_NAME).read_text(encoding="utf-8").strip())
1002
+ except (OSError, ValueError):
1003
+ last = 0.0
1004
+ _stamp_press()
1005
+ return now - last < _DEBOUNCE
1006
+
1007
+
970
1008
  def toggle(stt: dict) -> int:
971
1009
  """One press of the dictation hotkey. Returns the process exit code."""
972
- workdir = Path(tempfile.mkdtemp(prefix=_WORKDIR_PREFIX))
973
- if _claim_session(workdir):
974
- return _start(workdir, stt)
975
- shutil.rmtree(workdir, ignore_errors=True)
976
- return _second_press(stt)
1010
+ if _is_key_repeat():
1011
+ return 0
1012
+ try:
1013
+ workdir = Path(tempfile.mkdtemp(prefix=_WORKDIR_PREFIX))
1014
+ if _claim_session(workdir):
1015
+ return _start(workdir, stt)
1016
+ shutil.rmtree(workdir, ignore_errors=True)
1017
+ return _second_press(stt)
1018
+ finally:
1019
+ _stamp_press() # a repeat queued behind this press begins right now
977
1020
 
978
1021
 
979
1022
  def _start(workdir: Path, stt: dict) -> int:
@@ -11,6 +11,10 @@ from __future__ import annotations
11
11
  import shutil
12
12
  from pathlib import Path
13
13
 
14
+ # Where uv lives when it is on neither PATH nor in its own installer's
15
+ # spot (~/.local/bin): Homebrew on Apple silicon, then on Intel.
16
+ UV_FALLBACKS = (Path("/opt/homebrew/bin/uv"), Path("/usr/local/bin/uv"))
17
+
14
18
 
15
19
  def uv_path() -> str | None:
16
20
  """uv's executable, or None. PATH first, then its default install spot.
@@ -21,5 +25,9 @@ def uv_path() -> str | None:
21
25
  found = shutil.which("uv")
22
26
  if found:
23
27
  return found
24
- fallback = Path.home() / ".local" / "bin" / "uv"
25
- return str(fallback) if fallback.is_file() else None
28
+ # A Services (Quick Action) environment has a bare PATH, so the usual
29
+ # install spots are tried by name: uv's own installer, then Homebrew.
30
+ for fallback in (Path.home() / ".local" / "bin" / "uv", *UV_FALLBACKS):
31
+ if fallback.is_file():
32
+ return str(fallback)
33
+ return None
@@ -315,11 +315,13 @@ def selftest(
315
315
  _RECORDER_DIR = Path(__file__).resolve().parent.parent / "recorder"
316
316
  RECORDER_SOURCE = _RECORDER_DIR / "VocalizeRecorder.swift"
317
317
  RECORDER_PLIST_TEMPLATE = _RECORDER_DIR / "Info.plist.in"
318
+ RECORDER_ENTITLEMENTS = _RECORDER_DIR / "Recorder.entitlements"
318
319
 
319
320
  BIN_DIR = Path.home() / ".cache" / "vocalize" / "bin"
320
321
  BUNDLE_NAME = "Vocalize Recorder.app"
321
322
  RECORDER_STAMP_NAME = ".recorder"
322
- RECORDER_STAMP_VERSION = 2
323
+ # 3: the entitlements joined the fingerprint (0.10.0 signed without them).
324
+ RECORDER_STAMP_VERSION = 3
323
325
 
324
326
  _BUILD_TIMEOUT = 600
325
327
 
@@ -369,6 +371,7 @@ def _recorder_fingerprint() -> dict:
369
371
  "stamp_version": RECORDER_STAMP_VERSION,
370
372
  "source_sha256": _sha256_of(RECORDER_SOURCE),
371
373
  "plist_sha256": _sha256_of(RECORDER_PLIST_TEMPLATE),
374
+ "entitlements_sha256": _sha256_of(RECORDER_ENTITLEMENTS),
372
375
  }
373
376
 
374
377
 
@@ -525,7 +528,7 @@ def build_recorder(
525
528
  through a fake compiler: nothing here should ever need Xcode to be
526
529
  installed on the machine running the suite.
527
530
  """
528
- if not RECORDER_SOURCE.is_file() or not RECORDER_PLIST_TEMPLATE.is_file():
531
+ if not all(p.is_file() for p in (RECORDER_SOURCE, RECORDER_PLIST_TEMPLATE, RECORDER_ENTITLEMENTS)):
529
532
  raise InstallError(
530
533
  "This install of vocalize is missing the recorder source "
531
534
  f"({RECORDER_SOURCE.name}); reinstall vocalize to get it back."
@@ -570,14 +573,21 @@ def build_recorder(
570
573
  # --options runtime is the hardened runtime, which under the ad-hoc
571
574
  # signature refuses DYLD_INSERT_LIBRARIES — so nothing can inject a
572
575
  # dylib into the one process on this machine holding a microphone
573
- # grant. It does *not* stop that bundle being launched directly:
576
+ # grant. The hardened runtime also refuses the microphone itself,
577
+ # silently and with no dialog, unless the signature carries
578
+ # com.apple.security.device.audio-input — hence --entitlements
579
+ # (0.10.0 shipped without it and no first dictation could start).
580
+ # It does *not* stop that bundle being launched directly:
574
581
  # the grant belongs to the bundle, and anything running as this user
575
582
  # can `open` it with its own --out and record. That is inherent to
576
583
  # DEC-001 and is stated in docs/dictation.md § Privacy; there is no
577
584
  # code mitigation, only `local uninstall --stt` plus revoking the
578
585
  # grant in System Settings.
579
586
  _run_build_step(
580
- ["codesign", "-s", "-", "--force", "--options", "runtime", str(staging)],
587
+ [
588
+ "codesign", "-s", "-", "--force", "--options", "runtime",
589
+ "--entitlements", str(RECORDER_ENTITLEMENTS), str(staging),
590
+ ],
581
591
  runner, _CLT_HINT, "signed",
582
592
  )
583
593
  _swap_in(staging, bundle)
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.device.audio-input</key>
6
+ <true/>
7
+ </dict>
8
+ </plist>
@@ -1,22 +0,0 @@
1
- # Roadmap
2
-
3
- Future work, with the research behind each item. Status legend: **planned** = analysed, decisions pending, nothing built; **spiked** = feasibility measured; **building**; **shipped**.
4
-
5
- | Item | Status | Tracking | Research |
6
- |---|---|---|---|
7
- | Hotkey-triggered local dictation (voice → text via a local Whisper model, Quick Action toggle, clipboard output, optional cleanup) | planned | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-dictation-design.md) · [voicebox findings](research/2026-09-01-voicebox-findings.md) |
8
- | Config portal (`vocalize portal`, stdlib local web page with a readiness checklist) — or the one-day `vocalize status` screen first | planned | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-config-portal-design.md) |
9
-
10
- ## Open decisions (2026-09-01)
11
-
12
- 1. Run the 3-hour dictation spike? It measures Whisper (`base.en` / `small.en` / `large-v3-turbo-q5_0`) against Apple's on-device recognizer on the owner's own voice with developer jargon, plus latency and RAM on the 8 GB M3. If Apple's engine wins, the Whisper branch is deleted from the plan.
13
- 2. Portal (~50 h) or `vocalize status` (~1 day) first?
14
- 3. Hotkey chord — proposed ⌃⌥⌘D.
15
-
16
- ## Deferred on purpose
17
-
18
- Hold-to-talk (needs an event tap + Input Monitoring, ~6 h), auto-paste (Accessibility, ~2 h, belongs in the recorder bundle), Apple STT mode (~1.5 h), menu-bar agent (~12 h), local-LLM cleanup, resident/pre-warmed STT worker (only if the spike shows >2.5 s overhead), a native SwiftUI/Tauri app.
19
-
20
- ## Sequencing suggestion
21
-
22
- Dictation first (the spike decides the engine, then ~20 h), portal or `status` second.
File without changes
File without changes