arkaos 4.31.0 → 4.32.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 (43) hide show
  1. package/README.md +1 -1
  2. package/THE-ARKAOS-GUIDE.md +1 -1
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/config/claude-agents/brand-director.md +1 -1
  6. package/config/claude-agents/content-strategist.md +1 -1
  7. package/config/claude-agents/frontend-dev.md +1 -1
  8. package/config/claude-agents/marketing-director.md +1 -1
  9. package/config/claude-agents/scriptwriter.md +1 -1
  10. package/config/claude-agents/trends-analyst.md +1 -1
  11. package/config/claude-agents/video-producer.md +1 -1
  12. package/config/skills-curated.yaml +2 -1
  13. package/config/skills-provenance.yaml +6 -0
  14. package/core/keys.py +17 -5
  15. package/departments/brand/agents/brand-director.yaml +1 -0
  16. package/departments/content/agents/content-strategist.yaml +1 -0
  17. package/departments/content/agents/production/trends-analyst.yaml +1 -0
  18. package/departments/content/agents/production/video-producer.yaml +1 -0
  19. package/departments/content/agents/scriptwriter.yaml +1 -0
  20. package/departments/dev/agents/frontend-dev.yaml +1 -0
  21. package/departments/dev/skills/animated-website/SKILL.md +14 -1
  22. package/departments/dev/skills/watch/SKILL.md +162 -0
  23. package/departments/dev/skills/watch/references/claude-video.LICENSE +21 -0
  24. package/departments/dev/skills/watch/scripts/config.py +145 -0
  25. package/departments/dev/skills/watch/scripts/download.py +179 -0
  26. package/departments/dev/skills/watch/scripts/frames.py +762 -0
  27. package/departments/dev/skills/watch/scripts/setup.py +373 -0
  28. package/departments/dev/skills/watch/scripts/transcribe.py +95 -0
  29. package/departments/dev/skills/watch/scripts/watch.py +531 -0
  30. package/departments/dev/skills/watch/scripts/whisper.py +466 -0
  31. package/departments/marketing/agents/marketing-director.yaml +1 -0
  32. package/departments/marketing/skills/ad-creative/SKILL.md +5 -0
  33. package/harness/codex/AGENTS.md +1 -1
  34. package/harness/copilot/copilot-instructions.md +1 -1
  35. package/harness/cursor/rules/arkaos.mdc +2 -2
  36. package/harness/gemini/GEMINI.md +1 -1
  37. package/harness/opencode/AGENTS.md +1 -1
  38. package/harness/zed/.rules +1 -1
  39. package/installer/doctor.js +38 -4
  40. package/knowledge/agents-registry-v2.json +8 -1
  41. package/knowledge/skills-manifest.json +14 -1
  42. package/package.json +1 -1
  43. package/pyproject.toml +1 -1
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env python3
2
+ """Transcribe a video via Groq or OpenAI Whisper API.
3
+
4
+ Strategy: extract audio (mono 16kHz mp3, tiny payload), upload to whichever
5
+ API has a key. Returns segments in the same shape as transcribe.parse_vtt so
6
+ the rest of the pipeline (filter_range, format_transcript) doesn't care where
7
+ the transcript came from.
8
+
9
+ Pure stdlib — no `pip install groq` or `pip install openai` needed.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import json
15
+ import math
16
+ import mimetypes
17
+ import shutil
18
+ import ssl
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import uuid
24
+ from pathlib import Path
25
+ from urllib.request import Request, urlopen
26
+
27
+ SCRIPT_DIR = Path(__file__).resolve().parent
28
+ if str(SCRIPT_DIR) not in sys.path:
29
+ sys.path.insert(0, str(SCRIPT_DIR))
30
+ from config import resolve_api_key # noqa: E402
31
+
32
+ GROQ_ENDPOINT = "https://api.groq.com/openai/v1/audio/transcriptions"
33
+ GROQ_MODEL = "whisper-large-v3"
34
+
35
+ OPENAI_ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
36
+ OPENAI_MODEL = "whisper-1"
37
+
38
+ # Both Groq's free tier and OpenAI whisper-1 cap uploads at 25 MB. We target a
39
+ # margin under that so multipart framing overhead never pushes a chunk over.
40
+ MAX_UPLOAD_BYTES = 24 * 1024 * 1024
41
+
42
+
43
+ def plan_chunks(
44
+ total_seconds: float,
45
+ total_bytes: int,
46
+ max_bytes: int = MAX_UPLOAD_BYTES,
47
+ ) -> list[tuple[float, float]]:
48
+ """Split a duration into contiguous (offset, duration) chunks under max_bytes.
49
+
50
+ Size scales linearly with duration (constant-bitrate mono mp3), so an even
51
+ time split yields evenly-sized chunks. Returns a single full-length chunk
52
+ when the audio already fits.
53
+ """
54
+ if total_bytes <= max_bytes or total_seconds <= 0:
55
+ return [(0.0, total_seconds)]
56
+
57
+ n = math.ceil(total_bytes / max_bytes)
58
+ chunk = total_seconds / n
59
+ plan: list[tuple[float, float]] = []
60
+ for i in range(n):
61
+ offset = i * chunk
62
+ # The last chunk absorbs any rounding remainder so durations sum exactly.
63
+ duration = (total_seconds - offset) if i == n - 1 else chunk
64
+ plan.append((round(offset, 3), round(duration, 3)))
65
+ return plan
66
+
67
+
68
+ def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None, None]:
69
+ """Return (backend, api_key). Prefers Groq, falls back to OpenAI.
70
+
71
+ If `preferred` is "groq" or "openai", only that backend's key is
72
+ considered. Resolution order lives in config.resolve_api_key:
73
+ env -> ~/.arkaos/keys.json (`/arka keys`) -> ~/.arkaos/watch.env -> ./.env.
74
+ """
75
+ candidates = (("GROQ_API_KEY", "groq"), ("OPENAI_API_KEY", "openai"))
76
+ if preferred is not None:
77
+ candidates = tuple(c for c in candidates if c[1] == preferred)
78
+
79
+ for key_name, backend in candidates:
80
+ value = resolve_api_key(key_name)
81
+ if value:
82
+ return backend, value
83
+
84
+ return None, None
85
+
86
+
87
+ def extract_audio(video_path: str, out_path: Path) -> Path:
88
+ """Extract mono 16kHz 64kbps mp3 — ~480 kB/min, fits any Whisper limit."""
89
+ if shutil.which("ffmpeg") is None:
90
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
91
+
92
+ out_path.parent.mkdir(parents=True, exist_ok=True)
93
+ cmd = [
94
+ "ffmpeg",
95
+ "-hide_banner",
96
+ "-loglevel", "error",
97
+ "-y",
98
+ "-i", str(Path(video_path).resolve()),
99
+ "-vn",
100
+ "-acodec", "libmp3lame",
101
+ "-ar", "16000",
102
+ "-ac", "1",
103
+ "-b:a", "64k",
104
+ str(out_path.resolve()),
105
+ ]
106
+ result = subprocess.run(cmd, capture_output=True, text=True)
107
+ if result.returncode != 0:
108
+ raise SystemExit(f"ffmpeg audio extraction failed: {result.stderr.strip()}")
109
+ if not out_path.exists() or out_path.stat().st_size == 0:
110
+ raise SystemExit("ffmpeg produced no audio — video may have no audio track")
111
+ return out_path
112
+
113
+
114
+ def audio_duration(audio_path: Path) -> float:
115
+ """Return the duration of an audio file in seconds via ffprobe."""
116
+ if shutil.which("ffprobe") is None:
117
+ raise SystemExit("ffprobe is not installed. Install with: brew install ffmpeg")
118
+
119
+ result = subprocess.run(
120
+ [
121
+ "ffprobe",
122
+ "-v", "quiet",
123
+ "-print_format", "json",
124
+ "-show_format",
125
+ str(audio_path.resolve()),
126
+ ],
127
+ capture_output=True,
128
+ text=True,
129
+ )
130
+ if result.returncode != 0:
131
+ raise SystemExit(f"ffprobe failed: {result.stderr.strip()}")
132
+ fmt = json.loads(result.stdout or "{}").get("format", {})
133
+ return float(fmt.get("duration") or 0.0)
134
+
135
+
136
+ def split_audio(
137
+ full_audio: Path,
138
+ work_dir: Path,
139
+ plan: list[tuple[float, float]],
140
+ ) -> list[tuple[Path, float]]:
141
+ """Slice full_audio into per-plan chunk files, returning (path, offset) pairs.
142
+
143
+ Uses stream copy (`-c copy`) so there is no re-encode and no quality loss;
144
+ mp3 frame boundaries are close enough for transcription's purposes.
145
+ """
146
+ if shutil.which("ffmpeg") is None:
147
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
148
+
149
+ work_dir.mkdir(parents=True, exist_ok=True)
150
+ chunks: list[tuple[Path, float]] = []
151
+ for index, (offset, duration) in enumerate(plan):
152
+ out_path = work_dir / f"chunk_{index:03d}.mp3"
153
+ cmd = [
154
+ "ffmpeg",
155
+ "-hide_banner",
156
+ "-loglevel", "error",
157
+ "-y",
158
+ "-ss", f"{offset:.3f}",
159
+ "-i", str(full_audio.resolve()),
160
+ "-t", f"{duration:.3f}",
161
+ "-c", "copy",
162
+ str(out_path.resolve()),
163
+ ]
164
+ result = subprocess.run(cmd, capture_output=True, text=True)
165
+ if result.returncode != 0 or not out_path.exists() or out_path.stat().st_size == 0:
166
+ raise SystemExit(
167
+ f"ffmpeg failed to split audio chunk {index + 1}: {result.stderr.strip()}"
168
+ )
169
+ chunks.append((out_path, offset))
170
+ return chunks
171
+
172
+
173
+ def _build_multipart(fields: dict[str, str], file_path: Path) -> tuple[bytes, str]:
174
+ """Assemble a multipart/form-data body the Whisper APIs accept.
175
+
176
+ Whisper's multipart upload is small and predictable — doing it by hand
177
+ keeps us on pure stdlib instead of pulling requests/groq/openai SDKs.
178
+ """
179
+ boundary = f"----WatchBoundary{uuid.uuid4().hex}"
180
+ eol = b"\r\n"
181
+ buf = io.BytesIO()
182
+
183
+ for name, value in fields.items():
184
+ buf.write(f"--{boundary}".encode())
185
+ buf.write(eol)
186
+ buf.write(f'Content-Disposition: form-data; name="{name}"'.encode())
187
+ buf.write(eol)
188
+ buf.write(eol)
189
+ buf.write(str(value).encode())
190
+ buf.write(eol)
191
+
192
+ mimetype = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
193
+ buf.write(f"--{boundary}".encode())
194
+ buf.write(eol)
195
+ buf.write(
196
+ f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'.encode()
197
+ )
198
+ buf.write(eol)
199
+ buf.write(f"Content-Type: {mimetype}".encode())
200
+ buf.write(eol)
201
+ buf.write(eol)
202
+ buf.write(file_path.read_bytes())
203
+ buf.write(eol)
204
+ buf.write(f"--{boundary}--".encode())
205
+ buf.write(eol)
206
+
207
+ return buf.getvalue(), boundary
208
+
209
+
210
+ MAX_ATTEMPTS = 4 # initial + 3 retries
211
+ MAX_429_RETRIES = 2
212
+ RETRY_BASE_DELAY = 2.0
213
+
214
+
215
+ def _post_whisper(endpoint: str, api_key: str, model: str, audio_path: Path) -> dict:
216
+ fields = {
217
+ "model": model,
218
+ "response_format": "verbose_json",
219
+ "temperature": "0",
220
+ }
221
+ body, boundary = _build_multipart(fields, audio_path)
222
+ headers = {
223
+ "Authorization": f"Bearer {api_key}",
224
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
225
+ # Groq sits behind Cloudflare — the default `Python-urllib/3.x` UA
226
+ # trips WAF rule 1010 (403) before auth even runs. Any non-default
227
+ # UA clears it; we identify honestly.
228
+ "User-Agent": "watch-skill/1.0 (+claude-code; python-urllib)",
229
+ }
230
+
231
+ context = ssl.create_default_context()
232
+ rate_limit_hits = 0
233
+ last_exc: Exception | None = None
234
+ last_detail = ""
235
+
236
+ for attempt in range(MAX_ATTEMPTS):
237
+ request = Request(endpoint, data=body, headers=headers, method="POST")
238
+ try:
239
+ with urlopen(request, timeout=300, context=context) as response:
240
+ payload = response.read().decode("utf-8", errors="replace")
241
+ except urllib.error.HTTPError as exc:
242
+ detail = _read_error_body(exc)
243
+ last_exc, last_detail = exc, detail
244
+
245
+ # 4xx other than 429 are client errors — no retry will fix them.
246
+ if 400 <= exc.code < 500 and exc.code != 429:
247
+ raise SystemExit(f"Whisper request failed: {exc}{detail}") from exc
248
+
249
+ if exc.code == 429:
250
+ rate_limit_hits += 1
251
+ if rate_limit_hits >= MAX_429_RETRIES:
252
+ raise SystemExit(f"Whisper request failed: {exc}{detail}") from exc
253
+ delay = _retry_after(exc) or RETRY_BASE_DELAY * (2 ** attempt) + 1
254
+ else:
255
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
256
+
257
+ if attempt < MAX_ATTEMPTS - 1:
258
+ print(
259
+ f"[watch] whisper HTTP {exc.code} — retrying in {delay:.1f}s "
260
+ f"(attempt {attempt + 2}/{MAX_ATTEMPTS})",
261
+ file=sys.stderr,
262
+ )
263
+ time.sleep(delay)
264
+ continue
265
+ except (urllib.error.URLError, TimeoutError, ConnectionResetError, OSError) as exc:
266
+ last_exc, last_detail = exc, ""
267
+ if attempt < MAX_ATTEMPTS - 1:
268
+ delay = RETRY_BASE_DELAY * (attempt + 1)
269
+ print(
270
+ f"[watch] whisper network error ({type(exc).__name__}: {exc}) — "
271
+ f"retrying in {delay:.1f}s (attempt {attempt + 2}/{MAX_ATTEMPTS})",
272
+ file=sys.stderr,
273
+ )
274
+ time.sleep(delay)
275
+ continue
276
+
277
+ try:
278
+ return json.loads(payload)
279
+ except json.JSONDecodeError as exc:
280
+ raise SystemExit(
281
+ f"Whisper returned non-JSON response: {exc}: {payload[:200]}"
282
+ ) from exc
283
+
284
+ raise SystemExit(
285
+ f"Whisper request failed after {MAX_ATTEMPTS} attempts: {last_exc}{last_detail}"
286
+ )
287
+
288
+
289
+ def _read_error_body(exc: urllib.error.HTTPError) -> str:
290
+ try:
291
+ body = exc.read()
292
+ except Exception:
293
+ return ""
294
+ if not body:
295
+ return ""
296
+ try:
297
+ return f" — {body.decode('utf-8', errors='replace')[:400]}"
298
+ except Exception:
299
+ return ""
300
+
301
+
302
+ def _retry_after(exc: urllib.error.HTTPError) -> float | None:
303
+ header = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
304
+ if not header:
305
+ return None
306
+ try:
307
+ return float(header)
308
+ except ValueError:
309
+ return None
310
+
311
+
312
+ def shift_segments(segments: list[dict], offset_seconds: float) -> list[dict]:
313
+ """Return a copy of segments with start/end shifted by offset_seconds.
314
+
315
+ Each chunk is transcribed in isolation, so Whisper returns 0-based timestamps
316
+ per chunk; shifting by the chunk's offset stitches them into source time.
317
+ """
318
+ if offset_seconds == 0:
319
+ return segments
320
+ return [
321
+ {
322
+ "start": round(seg["start"] + offset_seconds, 2),
323
+ "end": round(seg["end"] + offset_seconds, 2),
324
+ "text": seg["text"],
325
+ }
326
+ for seg in segments
327
+ ]
328
+
329
+
330
+ def _segments_from_response(data: dict) -> list[dict]:
331
+ """Convert Whisper verbose_json into our {start, end, text} segment format."""
332
+ out: list[dict] = []
333
+ for seg in data.get("segments") or []:
334
+ text = (seg.get("text") or "").strip()
335
+ if not text:
336
+ continue
337
+ out.append({
338
+ "start": round(float(seg.get("start") or 0.0), 2),
339
+ "end": round(float(seg.get("end") or 0.0), 2),
340
+ "text": text,
341
+ })
342
+
343
+ if not out:
344
+ full = (data.get("text") or "").strip()
345
+ if full:
346
+ out.append({"start": 0.0, "end": 0.0, "text": full})
347
+
348
+ return out
349
+
350
+
351
+ def transcribe_chunks(
352
+ chunks: list[tuple[Path, float]],
353
+ transcribe_one,
354
+ ) -> list[dict]:
355
+ """Transcribe each chunk, shift its segments by the chunk offset, concatenate.
356
+
357
+ A chunk that fails after its own retries is logged and skipped so one bad
358
+ slice doesn't discard the whole transcript. Raises only if every chunk fails.
359
+ """
360
+ segments: list[dict] = []
361
+ failures = 0
362
+ for index, (path, offset) in enumerate(chunks):
363
+ try:
364
+ chunk_segments = transcribe_one(path)
365
+ except SystemExit as exc:
366
+ failures += 1
367
+ print(
368
+ f"[watch] chunk {index + 1}/{len(chunks)} failed — skipping ({exc})",
369
+ file=sys.stderr,
370
+ )
371
+ continue
372
+ segments.extend(shift_segments(chunk_segments, offset))
373
+ print(
374
+ f"[watch] chunk {index + 1}/{len(chunks)} → {len(chunk_segments)} segments",
375
+ file=sys.stderr,
376
+ )
377
+
378
+ if failures == len(chunks):
379
+ raise SystemExit("Whisper failed on every audio chunk")
380
+ return segments
381
+
382
+
383
+ def _transcribe_file(backend: str, api_key: str, audio_path: Path) -> list[dict]:
384
+ """Upload one audio file and return its 0-based segments."""
385
+ if backend == "groq":
386
+ response = _post_whisper(GROQ_ENDPOINT, api_key, GROQ_MODEL, audio_path)
387
+ elif backend == "openai":
388
+ response = _post_whisper(OPENAI_ENDPOINT, api_key, OPENAI_MODEL, audio_path)
389
+ else:
390
+ raise SystemExit(f"Unknown whisper backend: {backend}")
391
+ return _segments_from_response(response)
392
+
393
+
394
+ def transcribe_video(
395
+ video_path: str,
396
+ audio_out: Path,
397
+ backend: str | None = None,
398
+ api_key: str | None = None,
399
+ ) -> tuple[list[dict], str]:
400
+ """Run the full flow: extract audio → upload → parse segments.
401
+
402
+ Returns (segments, backend_used). Raises SystemExit on any failure.
403
+ """
404
+ if backend is None or api_key is None:
405
+ detected_backend, detected_key = load_api_key()
406
+ backend = backend or detected_backend
407
+ api_key = api_key or detected_key
408
+
409
+ if not backend or not api_key:
410
+ raise SystemExit(
411
+ "No Whisper API key available. Add one with `/arka keys` "
412
+ "(OPENAI_API_KEY or GROQ_API_KEY), or set it in the environment "
413
+ "or in ~/.arkaos/watch.env."
414
+ )
415
+
416
+ print(f"[watch] extracting audio for Whisper ({backend})…", file=sys.stderr)
417
+ audio_path = extract_audio(video_path, audio_out)
418
+ audio_bytes = audio_path.stat().st_size
419
+
420
+ def transcribe_one(path: Path) -> list[dict]:
421
+ return _transcribe_file(backend, api_key, path)
422
+
423
+ if audio_bytes <= MAX_UPLOAD_BYTES:
424
+ print(
425
+ f"[watch] audio: {audio_bytes / 1024:.0f} kB — uploading to {backend} Whisper…",
426
+ file=sys.stderr,
427
+ )
428
+ segments = transcribe_one(audio_path)
429
+ else:
430
+ duration = audio_duration(audio_path)
431
+ plan = plan_chunks(duration, audio_bytes, MAX_UPLOAD_BYTES)
432
+ print(
433
+ f"[watch] audio: {audio_bytes / (1024 * 1024):.0f} MB exceeds "
434
+ f"{MAX_UPLOAD_BYTES // (1024 * 1024)} MB — splitting into {len(plan)} chunks…",
435
+ file=sys.stderr,
436
+ )
437
+ chunks = split_audio(audio_path, audio_out.parent / "chunks", plan)
438
+ segments = transcribe_chunks(chunks, transcribe_one)
439
+
440
+ if not segments:
441
+ raise SystemExit("Whisper returned no transcript segments")
442
+
443
+ print(f"[watch] transcribed {len(segments)} segments via {backend}", file=sys.stderr)
444
+ return segments, backend
445
+
446
+
447
+ if __name__ == "__main__":
448
+ if len(sys.argv) < 2:
449
+ print(
450
+ "usage: whisper.py <video-path> [<audio-out.mp3>] [--backend groq|openai]",
451
+ file=sys.stderr,
452
+ )
453
+ raise SystemExit(2)
454
+
455
+ video = sys.argv[1]
456
+ audio_out = (
457
+ Path(sys.argv[2])
458
+ if len(sys.argv) > 2 and not sys.argv[2].startswith("--")
459
+ else Path("audio.mp3")
460
+ )
461
+ backend_override = None
462
+ if "--backend" in sys.argv:
463
+ backend_override = sys.argv[sys.argv.index("--backend") + 1]
464
+
465
+ segments, backend = transcribe_video(video, audio_out, backend=backend_override)
466
+ print(json.dumps({"backend": backend, "segments": segments}, indent=2))
@@ -50,6 +50,7 @@ authority:
50
50
  expertise:
51
51
  domains:
52
52
  - growth strategy
53
+ - video-ad teardown via dev/watch (hook, pacing and spoken-copy evidence from frames + transcript)
53
54
  - content marketing
54
55
  - SEO
55
56
  - paid acquisition
@@ -85,6 +85,11 @@ When starting fresh, you generate a full set of ad creative based on product con
85
85
  ### Mode 2: Iterate from Performance Data
86
86
  When the user provides performance data (CSV, paste, or API output), you analyze what's working, identify patterns in top performers, and generate new variations that build on winning themes while exploring new angles.
87
87
 
88
+ **Reference or competitor VIDEO ads:** never judge a video ad from its
89
+ thumbnail or description — watch it with the native `dev/watch` skill
90
+ (frames + timestamped transcript) and decompose hook, pacing, on-screen
91
+ text and spoken copy with timestamp citations before cloning the mechanics.
92
+
88
93
  The core loop:
89
94
 
90
95
  ```
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: ArkaOS v4.31.0 agent-team contract
2
+ description: ArkaOS v4.32.0 agent-team contract
3
3
  alwaysApply: true
4
4
  ---
5
5
 
6
6
  # ArkaOS — The Operating System for AI Agent Teams
7
7
 
8
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
8
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
9
9
 
10
10
  You are operating within ArkaOS. Every request routes through the
11
11
  appropriate department squad — never respond as a generic assistant.
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -1,6 +1,6 @@
1
1
  # ArkaOS — The Operating System for AI Agent Teams
2
2
 
3
- > v4.31.0 — 89 agents, 17 departments, 329 skills. Generated by `scripts/harness_gen.py`; do not edit.
3
+ > v4.32.0 — 89 agents, 17 departments, 330 skills. Generated by `scripts/harness_gen.py`; do not edit.
4
4
 
5
5
  You are operating within ArkaOS. Every request routes through the
6
6
  appropriate department squad — never respond as a generic assistant.
@@ -149,6 +149,31 @@ export function companionPluginsInstalled() {
149
149
  }
150
150
  }
151
151
 
152
+ // /watch media tooling (dev/watch skill). A binary on PATH can still be
153
+ // a corpse — a dangling homebrew dylib makes ffmpeg abort at load (dyld,
154
+ // exit 134) while `which` stays green — so the native binaries are probed
155
+ // with `-version`. yt-dlp is presence-only: probing it costs a Python
156
+ // interpreter start for a breakage mode it doesn't have.
157
+ export function watchMediaTooling() {
158
+ const missing = [];
159
+ for (const bin of ["ffmpeg", "ffprobe"]) {
160
+ if (!commandExists(bin)) {
161
+ missing.push(bin);
162
+ continue;
163
+ }
164
+ try {
165
+ execSync(`${bin} -version`, {
166
+ stdio: ["pipe", "pipe", "ignore"],
167
+ timeout: 10000,
168
+ });
169
+ } catch {
170
+ missing.push(`${bin} (broken)`);
171
+ }
172
+ }
173
+ if (!commandExists("yt-dlp")) missing.push("yt-dlp");
174
+ return missing;
175
+ }
176
+
152
177
  export const checks = [
153
178
  {
154
179
  name: "install-dir",
@@ -560,11 +585,20 @@ export const checks = [
560
585
  fix: () => "Run: npx arkaos install --force",
561
586
  },
562
587
  {
563
- name: "yt-dlp",
564
- description: "yt-dlp present (video reference analysis + content ingestion)",
588
+ // Supersedes the presence-only "yt-dlp" check: dev/watch needs
589
+ // ffmpeg/ffprobe too, and needs them RUNNABLE, not just on PATH.
590
+ name: "watch-media-tooling",
591
+ description: "/watch video tooling (ffmpeg, ffprobe, yt-dlp) present and runnable",
565
592
  severity: "warn",
566
- check: () => commandExists("yt-dlp"),
567
- fix: () => "Install yt-dlp: brew install yt-dlp (macOS) / pipx install yt-dlp — only needed for video/content workflows",
593
+ check: () => watchMediaTooling().length === 0,
594
+ fix: () => {
595
+ const missing = watchMediaTooling();
596
+ return (
597
+ `Missing/broken: ${missing.join(", ")}. macOS: brew install ffmpeg yt-dlp ` +
598
+ "(a (broken) entry usually means a dangling dylib — brew reinstall it). " +
599
+ "Or run the dev/watch installer: ~/.claude/skills/arka-watch/scripts/setup.py"
600
+ );
601
+ },
568
602
  },
569
603
  {
570
604
  name: "gotchas",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "version": "2.0.0",
4
- "generated": "2026-07-21T23:13:55.387821",
4
+ "generated": "2026-07-22T09:33:03.145247",
5
5
  "total_agents": 89,
6
6
  "generator": "core/agents/registry_gen.py",
7
7
  "tiers": {
@@ -73,6 +73,7 @@
73
73
  },
74
74
  "expertise_domains": [
75
75
  "brand identity creation",
76
+ "reference-video visual analysis via dev/watch (complete frames + transcript — motion and art direction judged on evidence, never on screenshots)",
76
77
  "visual design direction",
77
78
  "UX/UI strategy",
78
79
  "design systems",
@@ -784,6 +785,7 @@
784
785
  },
785
786
  "expertise_domains": [
786
787
  "viral content design",
788
+ "reference-video analysis via dev/watch (frames + timestamped transcript before judging any video)",
787
789
  "hook writing & packaging",
788
790
  "script structure",
789
791
  "content operating systems",
@@ -934,6 +936,7 @@
934
936
  "authority": {},
935
937
  "expertise_domains": [
936
938
  "trend detection across platforms (Agent-Reach: X, Reddit, YouTube, RSS, Exa)",
939
+ "viral-video deconstruction via dev/watch (scene-aware frames + transcript, timestamp-cited)",
937
940
  "niche viability scoring (audience size, competition density, monetization path, brand fit)",
938
941
  "platform algorithm shift monitoring",
939
942
  "whitespace analysis for new channels and formats",
@@ -986,6 +989,7 @@
986
989
  },
987
990
  "expertise_domains": [
988
991
  "video production pipelines (script → storyboard → assets → edit → render)",
992
+ "cut review via dev/watch (frame + transcript evidence on own renders before the Quality Gate)",
989
993
  "Hyperframes video-as-code editing (HTML/CSS/JS + GSAP → MP4)",
990
994
  "Higgsfield generation orchestration (image, video, audio, motion control, upscale, reframe)",
991
995
  "shot lists and EDLs (scene/shot/VO/on-screen-text columns)",
@@ -1088,6 +1092,7 @@
1088
1092
  "authority": {},
1089
1093
  "expertise_domains": [
1090
1094
  "video scriptwriting (YouTube, Reels, TikTok)",
1095
+ "reference-video teardown via dev/watch (hook, pacing and beat analysis from frames + transcript)",
1091
1096
  "cinematic narrative for short and long-form",
1092
1097
  "storyboard-ready scene writing (scene/shot/VO/on-screen-text columns)",
1093
1098
  "narrative structure (hook-bridge-body-CTA; beat sheets; story circles)",
@@ -1744,6 +1749,7 @@
1744
1749
  },
1745
1750
  "expertise_domains": [
1746
1751
  "Vue 3 (Composition API)",
1752
+ "motion-reference analysis via dev/watch (scroll/animation benchmarks from frames + transcript before implementing)",
1747
1753
  "Nuxt 3",
1748
1754
  "React 19 / Next.js 15",
1749
1755
  "TypeScript",
@@ -3171,6 +3177,7 @@
3171
3177
  },
3172
3178
  "expertise_domains": [
3173
3179
  "growth strategy",
3180
+ "video-ad teardown via dev/watch (hook, pacing and spoken-copy evidence from frames + transcript)",
3174
3181
  "content marketing",
3175
3182
  "SEO",
3176
3183
  "paid acquisition",