acuvo-code 0.2.0

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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,561 @@
1
+ /**
2
+ * ── ⭐⭐ TALK TO YOUR TERMINAL — the two voice directions ─────────────────────
3
+ *
4
+ * AUDIO IN: record a memo on your phone, drop the file in the repo,
5
+ * `acuvo --task-audio note.m4a` — it transcribes it, SHOWS you what
6
+ * it heard, and only then codes what you said.
7
+ * AUDIO OUT: `acuvo --say "<task>"` — walk away from a long run and come back
8
+ * to a one-sentence verdict you can hear instead of read.
9
+ *
10
+ * Both halves were proven end to end against the live Modal endpoints on
11
+ * 2026-08-11: `speak` produced a real 211,244-byte WAV, and `transcribe`
12
+ * returned the exact sentence back. This module is assembly, not research; the
13
+ * work is entirely in the two places assembly goes wrong.
14
+ *
15
+ * ── ⚠️⚠️ FILE IN, FILE OUT. THIS MODULE DOES NOT RECORD AND DOES NOT PLAY. ──
16
+ *
17
+ * Say it plainly because the feature invites the assumption. There is NO
18
+ * zero-dependency way to capture a microphone in Node — no built-in audio API
19
+ * exists, and every option (`node-record-lpcm16`, `naudiodon`, a bundled
20
+ * `sox`/`ffmpeg`) is either an npm package or a shipped binary. This package's
21
+ * headline enterprise property is zero dependencies forever, and that property
22
+ * is worth more than live capture. Playback is the same argument in reverse:
23
+ * we write a WAV into the workspace and hand back the ONE command that plays it
24
+ * on your OS (`playbackHint`), rather than spawning an audio player ourselves.
25
+ *
26
+ * ⭐ IF ANYONE REVISITS LIVE CAPTURE, this is what it costs, so the trade is
27
+ * argued rather than rediscovered: (a) an OS binary reached through
28
+ * `command.mjs`'s allowlist — `ffmpeg -f dshow` on Windows, `-f avfoundation`
29
+ * on macOS, `arecord` on Linux — which is three platform paths, a permissions
30
+ * prompt on macOS, and a dependency on software we do not ship; or (b) an
31
+ * optional peer dependency, which ends the zero-dependency claim the moment it
32
+ * is documented. The honest middle is what is built here: any recorder the user
33
+ * already has produces a file, and a file is a first-class input.
34
+ *
35
+ * ── ⚠️⚠️ A TRANSCRIPT IS A MODEL INSTRUCTION THAT NOBODY PROOFREAD ──────────
36
+ *
37
+ * This is the whole reason `taskFromAudio` does not simply return a string and
38
+ * let the caller run it. Whisper mishears — "the server" becomes "the sensor",
39
+ * "don't delete" becomes "do delete" — and on silence it does something worse:
40
+ * it INVENTS. "Thanks for watching!" is its most famous hallucination and an
41
+ * empty room reliably produces it. Handing that to a file-writing agent is the
42
+ * same class of mistake as executing a command you never read.
43
+ *
44
+ * ⭐ SO THE SEAM IS: transcribe → SHOW → decide → run. `taskFromAudio` always
45
+ * returns `needsConfirmation: true` and never acts. `decideTranscript` is a
46
+ * pure function of (what was heard, what the user answered, is this a terminal,
47
+ * is this --json) and returns whether to run and WITH WHAT TEXT — so the
48
+ * confirmation policy is one reviewable table instead of branching inside a
49
+ * CLI. Under `--json` or on a non-TTY there is nobody to ask, so the answer is
50
+ * a REFUSAL naming `--yes`, never a silent yes.
51
+ *
52
+ * ⭐⭐ AND A CORRECTION IS NOT A REFUSAL. "no, the server not the sensor" is the
53
+ * commonest real answer to a mis-heard task, and treating it as "n" would throw
54
+ * the user's own fix away and make them retype the whole thing. Answering with
55
+ * anything substantive amends or replaces the task in one line.
56
+ *
57
+ * ── ⚠️ AUDIO THAT PLAYS UNASKED IS HOSTILE, AND A DIFF READ ALOUD IS A
58
+ * PUNISHMENT ─────────────────────────────────────────────────────────────
59
+ * `speakSummary` is silent by default and returns `{ ok: true, spoken: false }`
60
+ * for it — silence is a correct outcome, not a failure. When it is asked for,
61
+ * it speaks the VERDICT: what was asked, how much changed, did it verify. Never
62
+ * the file list. Sixty seconds of paths read by a synthesiser is worse than no
63
+ * feature, so `summariseOutcome` is hard-capped at `MAX_SPOKEN_CHARS` and names
64
+ * a file only when exactly one changed.
65
+ *
66
+ * ── ⚠️ AND IT DEGRADES BY BEING ABSENT, NOT BY BEING BROKEN ────────────────
67
+ * No `MODAL_TRANSCRIBE_URL` → `--task-audio` reports that it is unavailable and
68
+ * reaches no network. No `MODAL_TTS_URL` → the same for `--say`. Nothing here
69
+ * is ever offered-and-broken; `mediaToolSchemas` in media.mjs settled that rule
70
+ * for the model-facing tools and this is the human-facing half of it.
71
+ */
72
+
73
+ import { speak, transcribe, mediaConfig } from './media.mjs';
74
+
75
+ /**
76
+ * The ceiling on a SPOKEN task. An hour-long podcast transcribes to tens of
77
+ * thousands of characters, and handing that to the model as one instruction is
78
+ * a real bill for a request nobody made — a voice memo that turns into a
79
+ * 68,000-character prompt is a mis-drop, not a task.
80
+ *
81
+ * ⚠️ REFUSED, NOT TRUNCATED. Cutting it at 4,000 would run the first minute of
82
+ * what was said and silently discard the rest, which is the worst of the three
83
+ * options: the user believes their whole instruction was heard.
84
+ */
85
+ export const MAX_TASK_CHARS = 4000;
86
+
87
+ /** One or two sentences. Anything longer stops being a verdict. */
88
+ export const MAX_SPOKEN_CHARS = 320;
89
+
90
+ /**
91
+ * What this install can hear and say.
92
+ *
93
+ * ⚠️ READ AT CALL TIME from `env`, never captured at import — same rule as
94
+ * `mediaConfig`, for the same reason: a variable that changed mid-session must
95
+ * be seen, and a module-level snapshot is how a capability stays dark after it
96
+ * was fixed. Delegated to `mediaConfig` rather than re-reading the variables,
97
+ * so the two can never disagree about whether a service is configured.
98
+ */
99
+ export function voiceConfig(env = process.env) {
100
+ const cfg = mediaConfig(env);
101
+ return {
102
+ canListen: Boolean(cfg.transcribe),
103
+ canSpeak: Boolean(cfg.speak),
104
+ listenUrl: cfg.transcribe ?? null,
105
+ speakUrl: cfg.speak ?? null,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Tidy a transcript into one line of instruction.
111
+ *
112
+ * ⚠️ WHITESPACE ONLY. It normalises CRLF, collapses runs of space and strips a
113
+ * BOM — and it does NOT touch a single word. Non-ASCII survives byte for byte,
114
+ * because most of the world does not dictate in English and a "cleaner" that
115
+ * mangles `健康チェック` or `santé` has corrupted the instruction it was meant
116
+ * to prepare. Emoji included: `ship it 🚀` is a real thing people say.
117
+ */
118
+ export function cleanTranscript(raw) {
119
+ if (typeof raw !== 'string') return '';
120
+ return raw
121
+ .replace(/^/, '')
122
+ .replace(/\r\n?/g, '\n')
123
+ .replace(/\s+/g, ' ')
124
+ .trim();
125
+ }
126
+
127
+ /**
128
+ * ── ⚠️⚠️ WHISPER INVENTS WORDS WHEN THERE IS NOTHING TO HEAR ────────────────
129
+ *
130
+ * These strings are its documented hallucinations on silence or noise — they
131
+ * come from the YouTube captions it was trained on, so a quiet room produces
132
+ * "Thanks for watching!" with full confidence. That phrase reaching a coding
133
+ * agent as an instruction is not a hypothetical.
134
+ *
135
+ * ⭐ FLAGGED, NEVER STRIPPED. Editing the transcript under the user is a second
136
+ * mis-hearing on top of the first; the honest move is to show the words AND the
137
+ * doubt, and let a human spend one keystroke.
138
+ */
139
+ const HALLUCINATIONS = [
140
+ /^thanks? for watching/i,
141
+ /^thank you\.?$/i,
142
+ /^thanks\.?$/i,
143
+ /^subtitles?\b.*\b(by|provided)/i,
144
+ /amara\.org/i,
145
+ /^\s*[♪♫]/,
146
+ /^bye[.!]?$/i,
147
+ /^you\.?$/i,
148
+ ];
149
+
150
+ /**
151
+ * Reasons to look twice before running this. Pure — takes the text and the
152
+ * segments the service returned, returns sentences.
153
+ *
154
+ * ⚠️ IT MUST NOT FIRE ON A GOOD TRANSCRIPT. A check that flags correct work
155
+ * trains people to press through the warning, which is strictly worse than no
156
+ * warning at all — this repo has been bitten by that four times in one day.
157
+ */
158
+ export function transcriptWarnings(text, segments = []) {
159
+ const out = [];
160
+ const t = cleanTranscript(text);
161
+ if (!t) return ['nothing was heard — the transcript is empty'];
162
+
163
+ if (HALLUCINATIONS.some((re) => re.test(t))) {
164
+ out.push('⚠️ that looks like what the transcriber says when it hears SILENCE — it hallucinates stock captions on quiet audio. Check the recording before running this.');
165
+ }
166
+
167
+ const words = t.split(' ').filter(Boolean);
168
+ if (words.length < 3 || t.length < 12) {
169
+ out.push(`⚠️ only ${words.length} word${words.length === 1 ? '' : 's'} was heard — one mis-heard word would be the entire task`);
170
+ }
171
+
172
+ /**
173
+ * `avg_logprob` is the model's own confidence; below about -1.0 it is
174
+ * guessing. `no_speech_prob` above 0.5 means it half-believes the clip is
175
+ * silent — while still returning words for it.
176
+ */
177
+ const segs = Array.isArray(segments) ? segments.filter((s) => s && typeof s === 'object') : [];
178
+ const unsure = segs.filter((s) => (typeof s.avg_logprob === 'number' && s.avg_logprob < -1.0)
179
+ || (typeof s.no_speech_prob === 'number' && s.no_speech_prob > 0.5));
180
+ if (unsure.length > 0) {
181
+ const worst = unsure.map((s) => (typeof s.text === 'string' ? s.text.trim() : '')).filter(Boolean)[0];
182
+ out.push(`⚠️ low confidence on ${unsure.length} segment${unsure.length === 1 ? '' : 's'}${worst ? ` (e.g. "${worst.slice(0, 60)}")` : ''} — read it before you run it`);
183
+ }
184
+
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * ── ⭐ AUDIO IN — a voice memo becomes a task ────────────────────────────────
190
+ *
191
+ * ⚠️⚠️ IT NEVER ACTS. It returns `needsConfirmation: true` and the words it
192
+ * heard. Running is the caller's decision, made through `decideTranscript`.
193
+ *
194
+ * ⚠️ `transcribeImpl` DEFAULTS TO THE REAL ONE and `fetchImpl` is threaded
195
+ * through it, on purpose: the tests exercise the genuine payload contract with
196
+ * an injected fetch rather than stubbing the media module. That contract is not
197
+ * theoretical — this endpoint reads `audio_b64`, and one underscore cost this
198
+ * package the entire voice loop once already.
199
+ */
200
+ export async function taskFromAudio(root, audioPath, { env = process.env, fetchImpl = fetch, transcribeImpl = transcribe } = {}) {
201
+ const cfg = voiceConfig(env);
202
+ if (!cfg.canListen) {
203
+ return {
204
+ ok: false,
205
+ error: 'no transcription service is configured (MODAL_TRANSCRIBE_URL), so --task-audio is unavailable on this machine. Set it in your .env and the flag starts working; nothing else changes.',
206
+ };
207
+ }
208
+
209
+ const heard = await transcribeImpl(root, audioPath, { env, fetchImpl });
210
+ if (!heard.ok) return { ok: false, error: heard.error };
211
+
212
+ const task = cleanTranscript(heard.text);
213
+ const where = heard.path ?? audioPath;
214
+ if (!task) {
215
+ return {
216
+ ok: false,
217
+ error: `no speech was found in ${where} — the transcript came back empty, so there is nothing to run. Check the file actually contains audio.`,
218
+ };
219
+ }
220
+ if (task.length > MAX_TASK_CHARS) {
221
+ return {
222
+ ok: false,
223
+ error: `${where} transcribes to ${task.length} characters, over the ${MAX_TASK_CHARS}-character limit for a spoken task. That is a recording, not an instruction — refused rather than truncated, because running the first minute and silently dropping the rest is worse than refusing.`,
224
+ };
225
+ }
226
+
227
+ return {
228
+ ok: true,
229
+ path: where,
230
+ task,
231
+ transcript: task,
232
+ segments: Array.isArray(heard.segments) ? heard.segments : [],
233
+ warnings: transcriptWarnings(task, heard.segments),
234
+ /**
235
+ * ⚠️ ALWAYS TRUE, never conditional on the warnings being empty. A
236
+ * confidently-transcribed wrong word is the dangerous case, not the
237
+ * flagged one — "delete the sensor cache" arrives at full confidence.
238
+ */
239
+ needsConfirmation: true,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * The lines to SHOW before acting. Pure, and total: a partial result must still
245
+ * render, because the one moment this is called is the moment something is
246
+ * about to be run.
247
+ */
248
+ export function confirmationLines(result) {
249
+ const r = result ?? {};
250
+ const lines = [''];
251
+ lines.push(` heard in ${r.path ?? 'the audio file'}:`);
252
+ lines.push('');
253
+ lines.push(` "${typeof r.task === 'string' && r.task ? r.task : '(nothing)'}"`);
254
+ lines.push('');
255
+ for (const w of Array.isArray(r.warnings) ? r.warnings : []) lines.push(` ${w}`);
256
+ if (Array.isArray(r.warnings) && r.warnings.length > 0) lines.push('');
257
+ lines.push(' run it? [y = yes · Enter = no · or type a correction]');
258
+ return lines;
259
+ }
260
+
261
+ /** Single-word answers that mean "run it as heard". */
262
+ const AFFIRM = new Set(['y', 'yes', 'yeah', 'yep', 'ok', 'okay', 'sure', 'go', 'run', 'do']);
263
+ /** Single-word answers that mean "do not". */
264
+ const DENY = new Set(['n', 'no', 'nope', 'nah', 'q', 'quit', 'cancel', 'abort', 'stop']);
265
+ /**
266
+ * ⚠️ A SEPARATE, SMALLER SET, and the difference matters. A multi-word answer
267
+ * beginning with a pure yes/no PARTICLE is a correction to the transcript
268
+ * ("no, the server not the sensor"). A multi-word answer beginning with a VERB
269
+ * that merely happens to be in AFFIRM — "run the test suite and report the
270
+ * failures" — is a whole new instruction, and amending the mis-heard one onto
271
+ * it would smuggle the mistake back in.
272
+ */
273
+ const AMEND_PREFIX = new Set(['y', 'yes', 'yeah', 'yep', 'n', 'no', 'nope', 'nah', 'ok', 'okay']);
274
+
275
+ /**
276
+ * ── ⭐⭐ THE CONFIRMATION POLICY, AS ONE PURE TABLE ─────────────────────────
277
+ *
278
+ * Inputs: what was heard, what the human typed, whether there IS a human
279
+ * (`tty`), whether the caller promised a machine one JSON object (`json`), and
280
+ * whether they pre-authorised with `--yes`. Output: run or not, and with what.
281
+ *
282
+ * ⚠️⚠️ NO TTY AND NO `--yes` MEANS REFUSE. Under `--json`, in a pipe, in cron
283
+ * or in CI there is nobody to show the transcript to — and "nobody was there to
284
+ * object" is not consent. The refusal names `--yes`, so a script that genuinely
285
+ * wants unattended voice tasking says so once, out loud, in its own source.
286
+ */
287
+ export function decideTranscript(input) {
288
+ const { task = '', answer = null, tty = false, json = false, assumeYes = false } = input ?? {};
289
+ const base = typeof task === 'string' ? task.trim() : '';
290
+
291
+ if (assumeYes) {
292
+ return { run: true, task: base, edited: false, how: 'accepted', why: '--yes was passed, so the transcript was pre-authorised' };
293
+ }
294
+ if (json) {
295
+ return { run: false, task: base, edited: false, how: 'refused', why: '--json emits one object and cannot stop to ask whether the transcript is right. Pass --yes if you have decided in advance, or drop --json and confirm in the terminal.' };
296
+ }
297
+ if (!tty) {
298
+ return { run: false, task: base, edited: false, how: 'refused', why: 'this is not an interactive terminal, so there is nobody to show the transcript to — and nobody objecting is not the same as somebody agreeing. Pass --yes to run a transcript unattended.' };
299
+ }
300
+
301
+ const raw = typeof answer === 'string' ? answer.trim() : '';
302
+ if (raw === '') {
303
+ return { run: false, task: base, edited: false, how: 'cancelled', why: 'no answer given — the default is not to act' };
304
+ }
305
+
306
+ const tokens = raw.split(/\s+/);
307
+ const first = tokens[0].toLowerCase().replace(/[^a-z]/g, '');
308
+
309
+ if (tokens.length === 1 && AFFIRM.has(first)) {
310
+ return { run: true, task: base, edited: false, how: 'accepted', why: 'confirmed as heard' };
311
+ }
312
+ if (tokens.length === 1 && DENY.has(first)) {
313
+ return { run: false, task: base, edited: false, how: 'cancelled', why: 'declined' };
314
+ }
315
+
316
+ if (AMEND_PREFIX.has(first)) {
317
+ const rest = tokens.slice(1).join(' ').replace(/^[,;:.\-—]+\s*/, '').trim();
318
+ if (rest) {
319
+ return {
320
+ run: true,
321
+ /**
322
+ * ⭐ THE ORIGINAL WORDS ARE KEPT AND THE CORRECTION IS MARKED AS
323
+ * WINNING. Replacing the transcript outright would lose everything the
324
+ * user said that was heard CORRECTLY, which is nearly all of it.
325
+ */
326
+ task: `${base}\n\nCorrection from the user — this wins wherever it disagrees with the line above: ${rest}`,
327
+ edited: true,
328
+ how: 'amended',
329
+ why: 'the transcript was corrected rather than rejected',
330
+ };
331
+ }
332
+ return AFFIRM.has(first)
333
+ ? { run: true, task: base, edited: false, how: 'accepted', why: 'confirmed as heard' }
334
+ : { run: false, task: base, edited: false, how: 'cancelled', why: 'declined' };
335
+ }
336
+
337
+ return { run: true, task: raw, edited: true, how: 'replaced', why: 'the user typed a different task, so the transcript was discarded' };
338
+ }
339
+
340
+ /**
341
+ * ── ⚠️ MAKE A STRING SPEAKABLE ──────────────────────────────────────────────
342
+ * A synthesiser reads `**fix** the \`parser\`` as punctuation. Markdown, ANSI
343
+ * and brackets go; every letter — including non-ASCII — stays.
344
+ */
345
+ function speakable(s) {
346
+ return String(s ?? '')
347
+ // eslint-disable-next-line no-control-regex
348
+ .replace(/\[[0-9;]*[A-Za-z]/g, '')
349
+ .replace(/[*`_#[\]<>|~]/g, ' ')
350
+ .replace(/\s+/g, ' ')
351
+ .trim();
352
+ }
353
+
354
+ /** The last path segment. A deep path read aloud is noise; the filename is the fact. */
355
+ function basename(p) {
356
+ const s = String(p ?? '').replace(/[\\/]+$/, '');
357
+ const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
358
+ return i === -1 ? s : s.slice(i + 1);
359
+ }
360
+
361
+ /**
362
+ * ── ⭐⭐ THE VERDICT — pure, and the only thing that gets spoken ─────────────
363
+ *
364
+ * Three facts and no more: what was asked, how much changed, whether it
365
+ * verified. Everything else belongs in the terminal, where it can be scrolled
366
+ * back and read at your own speed.
367
+ *
368
+ * ⚠️⚠️ THE FAILING CASE MUST NEVER FLATTER. `verification.ran` and
369
+ * `verification.passed` stay separate here exactly as they do everywhere else
370
+ * in this codebase — collapsing them is how a run whose suite exits 1 gets
371
+ * narrated as a success, and speech is the worst possible medium for that lie
372
+ * because nobody re-reads it.
373
+ *
374
+ * ⚠️ AND "NOTHING WAS RUN" IS SAID OUT LOUD. Omitting the verification clause
375
+ * when nothing was checked leaves a sentence that SOUNDS like success.
376
+ */
377
+ export function summariseOutcome(outcome, { task = null, maxChars = MAX_SPOKEN_CHARS } = {}) {
378
+ if (outcome == null) return 'There is no result to report.';
379
+
380
+ if (outcome.ok === false) {
381
+ const why = speakable(outcome.error ?? outcome.stage ?? 'no reason was given').slice(0, 160);
382
+ return speakable(`The run did not finish: ${why}.`).slice(0, maxChars);
383
+ }
384
+
385
+ const executed = Array.isArray(outcome.executed) ? outcome.executed.filter(Boolean) : [];
386
+ const writes = executed.filter((e) => e && (e.mutated === true || e.name === 'write_file') && e.result?.ok === true);
387
+
388
+ let changePart;
389
+ if (writes.length === 0) {
390
+ changePart = 'No files changed.';
391
+ } else if (writes.length === 1) {
392
+ // ⭐ ONE name is worth hearing. Twelve is a punishment, so above one it is a
393
+ // count — the terminal already printed the list.
394
+ changePart = `Changed ${speakable(basename(writes[0].result?.path ?? writes[0].path ?? 'one file'))}.`;
395
+ } else {
396
+ changePart = `Changed ${writes.length} files.`;
397
+ }
398
+
399
+ const v = outcome.verification ?? {};
400
+ const cmd = speakable(v.command ?? 'the check').slice(0, 40);
401
+ let verifyPart;
402
+ if (v.ran !== true) {
403
+ verifyPart = 'Nothing was run to check it.';
404
+ } else if (v.passed === true) {
405
+ verifyPart = `${cmd} passed.`;
406
+ } else {
407
+ verifyPart = `${cmd} still fails${typeof v.exitCode === 'number' ? ` with exit code ${v.exitCode}` : ''}.`;
408
+ }
409
+
410
+ const tail = `${changePart} ${verifyPart}`;
411
+ const asked = speakable(task);
412
+ if (!asked) return speakable(tail).slice(0, maxChars);
413
+
414
+ // The verdict is the point, so it gets the budget first and the request is
415
+ // trimmed to whatever is left rather than the other way round.
416
+ const budget = maxChars - tail.length - 'Asked to . '.length;
417
+ const shortAsked = asked.length > budget && budget > 12 ? `${asked.slice(0, budget - 1).trim()}…` : asked;
418
+ if (budget <= 12) return speakable(tail).slice(0, maxChars);
419
+ return speakable(`Asked to ${shortAsked}. ${tail}`).slice(0, maxChars);
420
+ }
421
+
422
+ /**
423
+ * ── ⭐ AUDIO OUT — narrate the verdict ──────────────────────────────────────
424
+ *
425
+ * ⚠️⚠️ SILENT BY DEFAULT, and that silence is `ok: true`. A run that was not
426
+ * asked to speak has not failed to speak. Only an explicit `enabled` (the
427
+ * `--say` flag) makes a request, which is also what keeps this from quietly
428
+ * spending money on every run.
429
+ *
430
+ * ⚠️ ASKED-BUT-UNCONFIGURED IS `ok: false`, WITH THE VARIABLE NAMED. Staying
431
+ * quiet there would be the "offered and broken" failure inverted: the user
432
+ * pressed the button and heard nothing, with no way to learn why.
433
+ *
434
+ * ⚠️ IT CAN NEVER FAIL THE RUN. The caller prints `reason` to stderr and moves
435
+ * on — the code is already written and the exit code is a verification verdict,
436
+ * not a bookkeeping one. `text` is returned even on failure so the verdict it
437
+ * could not say can still be printed.
438
+ */
439
+ export async function speakSummary(root, outcome, {
440
+ env = process.env,
441
+ fetchImpl = fetch,
442
+ speakImpl = speak,
443
+ task = null,
444
+ enabled = false,
445
+ dryRun = false,
446
+ outPath = null,
447
+ now = Date.now,
448
+ } = {}) {
449
+ const text = summariseOutcome(outcome, { task });
450
+
451
+ if (!enabled) return { ok: true, spoken: false, reason: 'not asked for — pass --say to hear the verdict', text };
452
+ // `--help` promises a dry run touches nothing, and a WAV is something.
453
+ if (dryRun) return { ok: true, spoken: false, reason: 'dry run — nothing was written and nothing was spoken', text };
454
+
455
+ const cfg = voiceConfig(env);
456
+ if (!cfg.canSpeak) {
457
+ return {
458
+ ok: false,
459
+ spoken: false,
460
+ reason: 'no speech service is configured (MODAL_TTS_URL), so the verdict was not spoken. Set it in your .env and --say starts working.',
461
+ text,
462
+ };
463
+ }
464
+
465
+ const target = outPath || `.acuvo/verdict-${now()}.wav`;
466
+ const res = await speakImpl(root, text, target, { env, fetchImpl });
467
+ if (!res.ok) return { ok: false, spoken: false, reason: res.error, text };
468
+
469
+ return { ok: true, spoken: true, path: res.path, bytes: res.bytes, text, hint: playbackHint(res.path) };
470
+ }
471
+
472
+ /**
473
+ * ⚠️ WE WROTE A FILE; WE DID NOT PLAY IT — see the header. This returns the one
474
+ * command that plays it on this OS, which is honest, zero-dependency, and
475
+ * copy-pasteable. It never claims the sound came out.
476
+ */
477
+ export function playbackHint(relPath, platform = process.platform) {
478
+ const p = String(relPath ?? '');
479
+ const q = `"${p.replace(/"/g, '\\"')}"`;
480
+ /**
481
+ * ── ⚠️⚠️ THE QUOTING HAS TO NEST, AND THE FIRST VERSION DID NOT ────────────
482
+ *
483
+ * It used `JSON.stringify(p)` — a DOUBLE-quoted string — inside an argument
484
+ * that is itself double-quoted, producing:
485
+ *
486
+ * powershell -c "(New-Object Media.SoundPlayer ".acuvo/v.wav").PlaySync()"
487
+ *
488
+ * where the inner `"` closes the outer one. cmd then sees three arguments and
489
+ * PowerShell never receives a string at all. Found by RUNNING the live round
490
+ * trip and reading what it printed; the test that "covered" this asserted
491
+ * only that the word `powershell` appeared, and passed against a command that
492
+ * could never work.
493
+ *
494
+ * ⭐ SINGLE quotes inside, and they are also the right choice on their own
495
+ * merits: a PowerShell single-quoted string is a LITERAL, so a Windows path
496
+ * full of backslashes needs no escaping. The one character that can break out
497
+ * is a single quote, and PowerShell escapes it by doubling.
498
+ */
499
+ if (platform === 'win32') return `powershell -c "(New-Object Media.SoundPlayer '${p.replace(/'/g, "''")}').PlaySync()"`;
500
+ if (platform === 'darwin') return `afplay ${q}`;
501
+ if (platform === 'linux') return `aplay ${q} (or paplay / ffplay -nodisp -autoexit)`;
502
+ return `play ${q} with any audio player`;
503
+ }
504
+
505
+ /**
506
+ * ── ⭐ THE FLAGS, LIFTED OUT OF ARGV BEFORE `parseArgv` SEES THEM ────────────
507
+ *
508
+ * Exactly the shape `bin/acuvo.mjs` already uses for its lifecycle flags, and
509
+ * for the same reason: `parseArgv` refuses any `--flag` it does not know, which
510
+ * is the right default and is why these come out first.
511
+ *
512
+ * ⚠️ TOTAL, NOT PERMISSIVE. Anything unrecognised is passed through untouched,
513
+ * so `parseArgv` still produces its own sentence for a typo — two parsers both
514
+ * guessing is how `--sayy` gets silently ignored.
515
+ */
516
+ export function extractVoiceFlags(argv) {
517
+ const flags = { taskAudio: null, say: false, yes: false };
518
+ const rest = [];
519
+ const need = '--task-audio needs the path to an audio file in the workspace, e.g. --task-audio note.m4a';
520
+ for (let i = 0; i < (argv?.length ?? 0); i += 1) {
521
+ const arg = argv[i];
522
+ if (arg === '--say') { flags.say = true; continue; }
523
+ if (arg === '--yes' || arg === '-y') { flags.yes = true; continue; }
524
+ if (arg === '--task-audio') {
525
+ const value = argv[i + 1];
526
+ // ⚠️ A missing value must not eat the next flag: `--task-audio --json`
527
+ // silently transcribing a file called "--json" is a confusing failure two
528
+ // steps later, and refusing here is one step.
529
+ if (value === undefined || value.startsWith('--')) return { ok: false, error: need };
530
+ flags.taskAudio = value;
531
+ i += 1;
532
+ continue;
533
+ }
534
+ if (arg.startsWith('--task-audio=')) {
535
+ const value = arg.slice('--task-audio='.length);
536
+ if (value === '') return { ok: false, error: need };
537
+ flags.taskAudio = value;
538
+ continue;
539
+ }
540
+ rest.push(arg);
541
+ }
542
+ return { ok: true, flags, argv: rest };
543
+ }
544
+
545
+ /** Documented where people look. A capability only the changelog knows about is
546
+ * the "built but unreachable" failure this whole package exists to end. */
547
+ export const VOICE_USAGE = [
548
+ '',
549
+ 'Voice (file in, file out — acuvo does not record and does not play):',
550
+ ' --task-audio <file> Transcribe an audio file in the workspace and run what it says.',
551
+ ' It SHOWS you the transcript first and waits: press Enter to',
552
+ ' cancel, y to run it, or type a correction ("no, the server not',
553
+ ' the sensor") to fix a mis-heard word without retyping the task.',
554
+ ' Needs MODAL_TRANSCRIBE_URL.',
555
+ ' --say Speak the verdict when the run ends — what was asked, how much',
556
+ ' changed, whether it verified. Writes a .wav into .acuvo/ and',
557
+ ' prints the command to play it. Silent unless you pass this.',
558
+ ' Needs MODAL_TTS_URL.',
559
+ ' --yes, -y Run a transcribed task without confirming. Required with --json',
560
+ ' or in a pipe/cron/CI, where there is nobody to ask.',
561
+ ].join('\n');