@app-vitals/agent-runtime 0.0.0-stage → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/voice.ts ADDED
@@ -0,0 +1,315 @@
1
+ import { type SpawnOptions, spawn } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { resolvePiperVoicePaths, validatePiperVoice } from "./piper-voice.ts";
7
+
8
+ const VOICE_DIR = join(tmpdir(), "shipwright-agent-voice");
9
+ const WHISPER_MAX_BYTES = 25 * 1024 * 1024; // 25MB
10
+
11
+ export interface VoiceConfig {
12
+ groqApiKey?: string;
13
+ elevenLabsApiKey?: string;
14
+ voiceId?: string;
15
+ piperVoice?: string;
16
+ whisperServiceUrl?: string;
17
+ }
18
+
19
+ // DI seam for the whisper-svc HTTP client — replaces spawnFn from the old local-Whisper path
20
+ export type WhisperClientFn = (
21
+ audioBuffer: Buffer,
22
+ audioPath: string,
23
+ ) => Promise<string | null>;
24
+
25
+ type SpawnFn = (
26
+ command: string,
27
+ args: string[],
28
+ options?: SpawnOptions,
29
+ ) => ReturnType<typeof spawn>;
30
+
31
+ const defaultSpawn: SpawnFn = (cmd, args, opts) => spawn(cmd, args, opts ?? {});
32
+
33
+ /**
34
+ * Creates an HTTP client that transcribes audio via the self-hosted Whisper pod
35
+ * the chart ships: onerahmet/openai-whisper-asr-webservice.
36
+ *
37
+ * That image exposes a single ASR endpoint — `POST /asr` — with the audio
38
+ * attached as the multipart field `audio_file`. The behaviour is selected via
39
+ * query params; with `output=txt` the service returns the transcription as a
40
+ * plain-text body (NOT JSON). We pin:
41
+ * - task=transcribe (transcribe, not translate-to-English)
42
+ * - output=txt (plain text body, simplest to consume)
43
+ * - encode=true (let ffmpeg inside the image decode arbitrary input)
44
+ *
45
+ * Accepts an optional fetchFn for dependency injection in tests (avoids
46
+ * global.fetch mocking).
47
+ */
48
+ export function makeWhisperSvcClient(
49
+ serviceUrl: string,
50
+ fetchFn: typeof fetch = globalThis.fetch,
51
+ ): WhisperClientFn {
52
+ return async (
53
+ audioBuffer: Buffer,
54
+ audioPath: string,
55
+ ): Promise<string | null> => {
56
+ const formData = new FormData();
57
+ const blob = new Blob([new Uint8Array(audioBuffer)]);
58
+ // onerahmet's ASR endpoint reads the upload from the `audio_file` field.
59
+ formData.append(
60
+ "audio_file",
61
+ blob,
62
+ audioPath.split("/").pop() ?? "audio.wav",
63
+ );
64
+
65
+ const base = serviceUrl.replace(/\/+$/, "");
66
+ const url = `${base}/asr?encode=true&task=transcribe&output=txt`;
67
+
68
+ let resp: Response;
69
+ try {
70
+ resp = await fetchFn(url, {
71
+ method: "POST",
72
+ body: formData,
73
+ });
74
+ } catch (err) {
75
+ console.warn("[voice] whisper-svc request failed:", err);
76
+ return null;
77
+ }
78
+
79
+ if (!resp.ok) {
80
+ console.warn(
81
+ "[voice] whisper-svc transcription failed:",
82
+ resp.status,
83
+ await resp.text(),
84
+ );
85
+ return null;
86
+ }
87
+
88
+ // output=txt → the body IS the transcription (no JSON envelope).
89
+ const text = (await resp.text()).trim();
90
+ return text.length > 0 ? text : null;
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Transcribe an audio file via Groq Whisper API.
96
+ * Returns the transcription text, or null on any error.
97
+ */
98
+ async function transcribeGroq(
99
+ audioPath: string,
100
+ fileBuffer: Buffer,
101
+ apiKey: string,
102
+ fetchFn: typeof fetch = globalThis.fetch,
103
+ ): Promise<string | null> {
104
+ const formData = new FormData();
105
+ const blob = new Blob([new Uint8Array(fileBuffer)]);
106
+ formData.append("file", blob, audioPath.split("/").pop() ?? "audio.webm");
107
+ formData.append("model", "whisper-large-v3");
108
+
109
+ let resp: Response;
110
+ try {
111
+ resp = await fetchFn(
112
+ "https://api.groq.com/openai/v1/audio/transcriptions",
113
+ {
114
+ method: "POST",
115
+ headers: { Authorization: `Bearer ${apiKey}` },
116
+ body: formData,
117
+ },
118
+ );
119
+ } catch (err) {
120
+ console.error("[voice] Groq STT request failed:", err);
121
+ return null;
122
+ }
123
+
124
+ if (!resp.ok) {
125
+ console.error("[voice] Groq STT failed:", resp.status, await resp.text());
126
+ return null;
127
+ }
128
+
129
+ const data = (await resp.json()) as { text: string };
130
+ return data.text ?? null;
131
+ }
132
+
133
+ /**
134
+ * Transcribe an audio file.
135
+ * Fallback chain: Groq (if key set) → whisper-svc HTTP (if URL set or client injected) → null
136
+ *
137
+ * Accepts an optional fetchFn for DI in tests (avoids global.fetch mocking).
138
+ * Accepts an optional whisperClientFn for tests that inject a custom WhisperClientFn directly.
139
+ */
140
+ export async function transcribeAudio(
141
+ audioPath: string,
142
+ voiceConfig: VoiceConfig = {},
143
+ fetchFn?: typeof fetch,
144
+ whisperClientFn?: WhisperClientFn,
145
+ ): Promise<string | null> {
146
+ let fileBuffer: Buffer;
147
+ try {
148
+ fileBuffer = await readFile(audioPath);
149
+ } catch (err) {
150
+ console.warn("[voice] could not read audio file:", err);
151
+ return null;
152
+ }
153
+
154
+ if (fileBuffer.byteLength > WHISPER_MAX_BYTES) {
155
+ console.warn(
156
+ `[voice] audio file exceeds 25MB limit (${fileBuffer.byteLength} bytes) — skipping transcription`,
157
+ );
158
+ return null;
159
+ }
160
+
161
+ const resolvedFetch = fetchFn ?? globalThis.fetch;
162
+
163
+ const apiKey = voiceConfig.groqApiKey;
164
+ if (apiKey) {
165
+ const groqResult = await transcribeGroq(
166
+ audioPath,
167
+ fileBuffer,
168
+ apiKey,
169
+ resolvedFetch,
170
+ );
171
+ if (groqResult !== null) {
172
+ return groqResult;
173
+ }
174
+ console.warn(
175
+ "[voice] Groq transcription failed — falling back to whisper-svc",
176
+ );
177
+ }
178
+
179
+ const clientFn =
180
+ whisperClientFn ??
181
+ (voiceConfig.whisperServiceUrl
182
+ ? makeWhisperSvcClient(voiceConfig.whisperServiceUrl, resolvedFetch)
183
+ : null);
184
+
185
+ if (!clientFn) return null;
186
+ return clientFn(fileBuffer, audioPath);
187
+ }
188
+
189
+ /**
190
+ * Synthesize speech from text using ElevenLabs (if key available) or the
191
+ * self-hosted Piper TTS binary baked into the image.
192
+ * Returns the path to the generated audio file, or null on failure.
193
+ *
194
+ * Accepts an optional fetchFn for DI in tests (avoids global.fetch mocking).
195
+ * Accepts an optional spawnFn for Piper subprocess injection in tests.
196
+ */
197
+ export async function synthesizeSpeech(
198
+ text: string,
199
+ voiceConfig: VoiceConfig = {},
200
+ fetchFn?: typeof fetch,
201
+ spawnFn: SpawnFn = defaultSpawn,
202
+ ): Promise<string | null> {
203
+ mkdirSync(VOICE_DIR, { recursive: true });
204
+
205
+ const elevenKey = voiceConfig.elevenLabsApiKey;
206
+ if (elevenKey) {
207
+ return synthesizeElevenLabs(
208
+ text,
209
+ elevenKey,
210
+ voiceConfig.voiceId,
211
+ fetchFn ?? globalThis.fetch,
212
+ );
213
+ }
214
+
215
+ return synthesizePiper(text, voiceConfig, spawnFn);
216
+ }
217
+
218
+ async function synthesizeElevenLabs(
219
+ text: string,
220
+ apiKey: string,
221
+ voiceId?: string,
222
+ fetchFn: typeof fetch = globalThis.fetch,
223
+ ): Promise<string | null> {
224
+ const vid = voiceId ?? "CwhRBWXzGAHq8TQ4Fs17"; // Roger
225
+
226
+ let resp: Response;
227
+ try {
228
+ resp = await fetchFn(`https://api.elevenlabs.io/v1/text-to-speech/${vid}`, {
229
+ method: "POST",
230
+ headers: {
231
+ "xi-api-key": apiKey,
232
+ "Content-Type": "application/json",
233
+ },
234
+ body: JSON.stringify({ text, model_id: "eleven_flash_v2_5" }),
235
+ });
236
+ } catch (err) {
237
+ console.error("[voice] ElevenLabs TTS request failed:", err);
238
+ return null;
239
+ }
240
+
241
+ if (!resp.ok) {
242
+ console.error(
243
+ "[voice] ElevenLabs TTS failed:",
244
+ resp.status,
245
+ await resp.text(),
246
+ );
247
+ return null;
248
+ }
249
+
250
+ const outPath = join(VOICE_DIR, `${Date.now()}-response.mp3`);
251
+ await writeFile(outPath, Buffer.from(await resp.arrayBuffer()));
252
+ return outPath;
253
+ }
254
+
255
+ /**
256
+ * Synthesize speech via the self-hosted Piper TTS binary baked into the
257
+ * image. Unlike a network-based TTS provider, Piper is a local
258
+ * binary that reads text from stdin and writes audio directly to
259
+ * `--output_file`. It never requires the network and produces WAV — not
260
+ * MP3 — output, since ffmpeg stays absent from the image (no transcode
261
+ * step; Piper's native WAV output is used directly).
262
+ *
263
+ * The voice is resolved via validatePiperVoice() (falls back to
264
+ * DEFAULT_PIPER_VOICE when voiceConfig.piperVoice is unset or not
265
+ * discovered) and resolvePiperVoicePaths() (only the .onnx model path is
266
+ * needed for --model — Piper auto-discovers the sibling .onnx.json).
267
+ *
268
+ * Never throws — resolves null on every failure path (spawn error, non-zero
269
+ * exit), mirroring synthesizeElevenLabs's never-throw contract.
270
+ */
271
+ function synthesizePiper(
272
+ text: string,
273
+ voiceConfig: VoiceConfig = {},
274
+ spawnFn: SpawnFn = defaultSpawn,
275
+ ): Promise<string | null> {
276
+ const outPath = join(VOICE_DIR, `${Date.now()}-response.wav`);
277
+
278
+ const voiceName = validatePiperVoice(voiceConfig.piperVoice);
279
+ const { onnxPath } = resolvePiperVoicePaths(voiceName);
280
+
281
+ return new Promise((resolve) => {
282
+ let proc: ReturnType<typeof spawn>;
283
+ try {
284
+ proc = spawnFn("piper", ["--model", onnxPath, "--output_file", outPath]);
285
+ } catch (err) {
286
+ console.error("[voice] piper spawn error:", err);
287
+ resolve(null);
288
+ return;
289
+ }
290
+
291
+ const stderrChunks: Buffer[] = [];
292
+ proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
293
+
294
+ let errorFired = false;
295
+ proc.on("error", (err) => {
296
+ errorFired = true;
297
+ console.error("[voice] piper spawn error:", err);
298
+ resolve(null);
299
+ });
300
+
301
+ proc.on("close", (code) => {
302
+ if (errorFired) return;
303
+ if (code !== 0) {
304
+ const stderr = Buffer.concat(stderrChunks).toString();
305
+ console.error("[voice] piper failed with exit code", code, stderr);
306
+ resolve(null);
307
+ return;
308
+ }
309
+ resolve(outPath);
310
+ });
311
+
312
+ proc.stdin?.write(text);
313
+ proc.stdin?.end();
314
+ });
315
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * agent/src/runtime/voice.unit.test.ts
3
+ *
4
+ * Documents and locks the contract between makeWhisperSvcClient and the
5
+ * self-hosted Whisper pod the chart ships (onerahmet/openai-whisper-asr-webservice).
6
+ *
7
+ * That image exposes a single ASR endpoint:
8
+ * POST /asr?encode=true&task=transcribe&output=txt
9
+ * with the audio attached as the multipart field `audio_file`, and (with
10
+ * output=txt) returns the transcription as a plain-text body — NOT JSON.
11
+ *
12
+ * These tests use an injected fetchFn (no global.fetch mutation) per the
13
+ * test-isolation contract.
14
+ *
15
+ * synthesizeSpeech dispatch coverage (ElevenLabs vs Piper, including the
16
+ * fs-touching Piper success/failure paths) lives in voice.integration.test.ts
17
+ * — this file is pure logic, no I/O of any kind, per docs/test-readiness/test-system.md.
18
+ */
19
+
20
+ import { describe, expect, mock, test } from "bun:test";
21
+ import { makeWhisperSvcClient } from "./voice.ts";
22
+
23
+ describe("makeWhisperSvcClient — onerahmet /asr contract", () => {
24
+ test("POSTs to the /asr endpoint of the service URL", async () => {
25
+ const fetchFn = mock(
26
+ async (_url: string, _opts: RequestInit) =>
27
+ new Response("hello world", { status: 200 }),
28
+ );
29
+ const client = makeWhisperSvcClient(
30
+ "http://whisper:9000",
31
+ fetchFn as unknown as typeof fetch,
32
+ );
33
+
34
+ await client(Buffer.from("audio"), "clip.webm");
35
+
36
+ const [url] = fetchFn.mock.calls[0] as [string, RequestInit];
37
+ expect(url.startsWith("http://whisper:9000/asr")).toBe(true);
38
+ });
39
+
40
+ test("includes the onerahmet query params (task=transcribe, output=txt, encode=true)", async () => {
41
+ const fetchFn = mock(
42
+ async (_url: string, _opts: RequestInit) =>
43
+ new Response("ok", { status: 200 }),
44
+ );
45
+ const client = makeWhisperSvcClient(
46
+ "http://whisper:9000",
47
+ fetchFn as unknown as typeof fetch,
48
+ );
49
+
50
+ await client(Buffer.from("audio"), "clip.webm");
51
+
52
+ const [url] = fetchFn.mock.calls[0] as [string, RequestInit];
53
+ const qs = new URL(url).searchParams;
54
+ expect(qs.get("task")).toBe("transcribe");
55
+ expect(qs.get("output")).toBe("txt");
56
+ expect(qs.get("encode")).toBe("true");
57
+ });
58
+
59
+ test("attaches the audio under the `audio_file` multipart field", async () => {
60
+ const bodies: FormData[] = [];
61
+ const fetchFn = mock(async (_url: string, opts: RequestInit) => {
62
+ bodies.push(opts.body as FormData);
63
+ return new Response("ok", { status: 200 });
64
+ });
65
+ const client = makeWhisperSvcClient(
66
+ "http://whisper:9000",
67
+ fetchFn as unknown as typeof fetch,
68
+ );
69
+
70
+ await client(Buffer.from("audio"), "/tmp/recording.webm");
71
+
72
+ const form = bodies[0];
73
+ expect(form).toBeInstanceOf(FormData);
74
+ const file = form.get("audio_file") as File | null;
75
+ expect(file).not.toBeNull();
76
+ expect(file?.name).toBe("recording.webm");
77
+ });
78
+
79
+ test("parses the plain-text (output=txt) body as the transcription", async () => {
80
+ const fetchFn = mock(
81
+ async (_url: string, _opts: RequestInit) =>
82
+ new Response(" transcribed words ", { status: 200 }),
83
+ );
84
+ const client = makeWhisperSvcClient(
85
+ "http://whisper:9000",
86
+ fetchFn as unknown as typeof fetch,
87
+ );
88
+
89
+ const result = await client(Buffer.from("audio"), "clip.webm");
90
+ expect(result).toBe("transcribed words");
91
+ });
92
+
93
+ test("returns null on a non-2xx ASR response", async () => {
94
+ const fetchFn = mock(async () => new Response("boom", { status: 500 }));
95
+ const client = makeWhisperSvcClient(
96
+ "http://whisper:9000",
97
+ fetchFn as unknown as typeof fetch,
98
+ );
99
+ const warn = console.warn;
100
+ console.warn = () => {};
101
+ const result = await client(Buffer.from("audio"), "clip.webm");
102
+ console.warn = warn;
103
+ expect(result).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,43 @@
1
+ # Bootstrap
2
+
3
+ _You're seeing this because this is a fresh workspace. Run the ritual, then delete this file._
4
+
5
+ ## First-Run Ritual
6
+
7
+ 1. **Introduce yourself** — open with: "Hey. I just came online. Who am I? Who are you?"
8
+ 2. **Discover your identity** — learn your name, role, vibe, and emoji collaboratively
9
+ 3. **Determine deployment type** — is this a solo deployment (one human) or a team?
10
+ 4. **Update IDENTITY.md** — fill in the fields and add the correct import:
11
+ - Solo: add `@USER.md` at the bottom
12
+ - Team: add `@TEAM.md` at the bottom
13
+ 5. **Create USER.md or TEAM.md** — fill it in with context about your human(s)
14
+ 6. **Clone the repo** — `git clone <repo-url> repos/<repo>` so it's available for research
15
+ 7. **Walk through the dev loop** — explain that `shipwright-dev-task`, `shipwright-review`, and
16
+ `shipwright-patch` are enabled by default, each running every minute via `/shipwright:dev-task`,
17
+ `/shipwright:review`, and `/shipwright:patch` respectively. But none of them self-discovers
18
+ work — they're explicit-target-only executors. Candidate selection happens once per tick in the
19
+ `shipwright-loop` cron: it merges candidates from all enabled phases and dispatches exactly one
20
+ winning item via strict age-based FIFO, with the target task id or `org/repo#number` embedded
21
+ directly in the prompt. A standalone phase cron with `shipwright-loop` disabled is silently inert
22
+ — its prompt carries no target, so every tick just responds `[silent]`.
23
+ Note: `shipwright-deploy` is another phase cron on the same every-minute schedule, but it's
24
+ opt-in — enable it via the accounts API when the agent is ready for autonomous deployment.
25
+ 8. **Delete this file** — once bootstrap is complete, remove BOOTSTRAP.md
26
+
27
+ ## What You're Building
28
+
29
+ After bootstrap, the load chain looks like:
30
+
31
+ ```
32
+ CLAUDE.md
33
+ @SOUL.md ← personality/tone — yours to evolve
34
+ @IDENTITY.md ← who you are + who you work with
35
+ @USER.md ← solo: your human's context
36
+ (or @TEAM.md) ← team: the whole crew
37
+ ```
38
+
39
+ SOUL.md is yours — it won't be overwritten on upgrades. Make it accurate.
40
+
41
+ ---
42
+
43
+ _Delete this file when the ritual is complete._
@@ -0,0 +1,48 @@
1
+ /**
2
+ * BOOTSTRAP.md.template content regression guard — ABF-1.1
3
+ *
4
+ * Verifies the first-run bootstrap template's cron-walkthrough step describes
5
+ * the shipwright-loop-driven, explicit-target-only pipeline model (mirrors
6
+ * the former CLAUDE template content test / DPF-1.1), not the stale "every 30 min,
7
+ * self-discovering pickup of approved todos" model it replaced.
8
+ *
9
+ * Content-assertion only: readFileSync, no I/O beyond local file reads.
10
+ */
11
+ import { describe, expect, it } from "bun:test";
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ // The template sits beside this test, so this works in any repo layout.
16
+ const TEMPLATE_PATH = join(import.meta.dir, "BOOTSTRAP.md.template");
17
+
18
+ function readTemplate(): string {
19
+ return readFileSync(TEMPLATE_PATH, "utf8");
20
+ }
21
+
22
+ describe("BOOTSTRAP.md.template — shipwright-loop model", () => {
23
+ it("mentions the shipwright-loop cron as the driver", () => {
24
+ expect(readTemplate()).toContain("shipwright-loop");
25
+ });
26
+
27
+ it("describes dev-task/review/patch as explicit-target-only executors", () => {
28
+ expect(readTemplate().toLowerCase()).toContain("explicit-target-only");
29
+ });
30
+
31
+ it("mentions strict age-based FIFO work selection", () => {
32
+ expect(readTemplate().toLowerCase()).toContain("fifo");
33
+ });
34
+
35
+ it("notes a standalone phase cron with shipwright-loop disabled is silently inert", () => {
36
+ expect(readTemplate().toLowerCase()).toContain("silently inert");
37
+ });
38
+
39
+ it("does not claim the phase crons run every 30 minutes", () => {
40
+ expect(readTemplate()).not.toContain("30 min");
41
+ });
42
+
43
+ it("does not claim approved todos are picked up automatically without a driving loop", () => {
44
+ expect(readTemplate()).not.toContain(
45
+ "Once a task is approved, it gets picked up automatically.",
46
+ );
47
+ });
48
+ });
@@ -0,0 +1,8 @@
1
+ # Identity
2
+
3
+ - **Name:** _(fill in during bootstrap)_
4
+ - **Role:** _(AI assistant? team member?)_
5
+ - **Vibe:** _(direct? warm? grounded?)_
6
+ - **Emoji:** _(pick one)_
7
+
8
+ <!-- Bootstrap: add @USER.md or @TEAM.md here once you've created the right file -->