@jetrabbits/agentic 0.3.0 → 0.3.2

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 (46) hide show
  1. package/AGENTS.md +17 -23
  2. package/CHANGELOG.md +19 -0
  3. package/MEMORY.md +41 -87
  4. package/Makefile +80 -22
  5. package/README.md +17 -7
  6. package/agentic +634 -124
  7. package/areas/devops/ci-cd/AGENTS.md +1 -15
  8. package/areas/devops/database-ops/AGENTS.md +1 -15
  9. package/areas/devops/devsecops/AGENTS.md +1 -15
  10. package/areas/devops/infrastructure/AGENTS.md +1 -15
  11. package/areas/devops/kubernetes/AGENTS.md +1 -15
  12. package/areas/devops/networking/AGENTS.md +1 -15
  13. package/areas/devops/observability/AGENTS.md +1 -15
  14. package/areas/devops/sre/AGENTS.md +1 -15
  15. package/areas/software/backend/AGENTS.md +1 -16
  16. package/areas/software/data-engineering/AGENTS.md +1 -16
  17. package/areas/software/frontend/AGENTS.md +1 -16
  18. package/areas/software/full-stack/AGENTS.md +1 -16
  19. package/areas/software/general/AGENTS.md +1 -7
  20. package/areas/software/mlops/AGENTS.md +1 -16
  21. package/areas/software/mobile/AGENTS.md +1 -16
  22. package/areas/software/platform/AGENTS.md +1 -16
  23. package/areas/software/qa/AGENTS.md +1 -16
  24. package/areas/software/security/AGENTS.md +1 -16
  25. package/areas/template/AGENTS.tmpl.md +1 -17
  26. package/docs/agentic-lifecycle.md +7 -3
  27. package/docs/agentic-stabilization/README.md +11 -7
  28. package/docs/agentic-token-minimization/README.md +7 -5
  29. package/docs/agentic-usage.md +12 -5
  30. package/docs/guidance-updates/2026-05-22-centralized-guidance-memory.md +19 -0
  31. package/docs/opencode_setup.md +7 -5
  32. package/docs/review-pipeline.md +82 -0
  33. package/extensions/claude/agents/instruction_reviewer.md +132 -0
  34. package/extensions/claude/agents/memory_curator.md +97 -0
  35. package/extensions/codex/AGENTS.override.md +17 -0
  36. package/extensions/codex/agents/instruction_reviewer.toml +139 -0
  37. package/extensions/codex/agents/memory_curator.toml +104 -0
  38. package/extensions/gemini/agents/instruction_reviewer.md +132 -0
  39. package/extensions/gemini/agents/memory_curator.md +97 -0
  40. package/extensions/opencode/agents/instruction_reviewer.md +133 -0
  41. package/extensions/opencode/agents/memory_curator.md +98 -0
  42. package/extensions/opencode/opencode.json +27 -3
  43. package/extensions/opencode/plugins/agent-model-mapper.ts +13 -2
  44. package/extensions/opencode/plugins/telegram-notification.ts +14 -14
  45. package/package.json +1 -1
  46. package/scripts/generate_how_to_use_agentic_gif.py +565 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetrabbits/agentic",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Agent Intelligence Configuration CLI",
5
5
  "bin": {
6
6
  "agentic": "bin/agentic.js"
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import html
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import textwrap
15
+ from pathlib import Path
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+ DEFAULT_PROMPT = "Define acceptance criteria for a tiny CLI that greets a user by name. Keep it short."
19
+ ROLE_ORDER = ["product-owner", "pm", "team-lead", "developer", "qa", "designer", "devops-engineer"]
20
+ GIF_WIDTH = 820
21
+ GIF_HEIGHT = 522
22
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
23
+
24
+
25
+ def parse_args() -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(
27
+ description=(
28
+ "Generate images/how_to_use_agentic.gif from a reproducible agentic TUI/fzf "
29
+ "walkthrough plus a real OpenCode product-owner run."
30
+ )
31
+ )
32
+ parser.add_argument("--output", default=str(ROOT / "images/how_to_use_agentic.gif"), help="GIF output path")
33
+ parser.add_argument("--tmp-root", help="Keep/use this temporary working directory")
34
+ parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Prompt sent to OpenCode product-owner")
35
+ parser.add_argument("--fps", type=int, default=4, help="GIF frame rate")
36
+ parser.add_argument("--target-seconds", type=float, default=55.0, help="Approximate final GIF duration")
37
+ parser.add_argument("--agentic", default=str(ROOT / "agentic"), help="agentic executable to run")
38
+ parser.add_argument("--opencode", default="opencode", help="opencode executable to run")
39
+ parser.add_argument(
40
+ "--project-name",
41
+ default="agentic-opencode-demo",
42
+ help="Display name used in the GIF for the temporary target project",
43
+ )
44
+ parser.add_argument(
45
+ "--skip-opencode",
46
+ action="store_true",
47
+ help="Render the TUI install GIF without running OpenCode; useful for testing rendering only",
48
+ )
49
+ return parser.parse_args()
50
+
51
+
52
+ def require_tool(name: str) -> str:
53
+ found = shutil.which(name)
54
+ if not found:
55
+ raise SystemExit(f"Missing required tool on PATH: {name}")
56
+ return found
57
+
58
+
59
+ def run(command: list[str], *, env: dict[str, str] | None = None, cwd: Path = ROOT, output: Path | None = None) -> None:
60
+ if output:
61
+ with output.open("w", encoding="utf-8") as handle:
62
+ subprocess.run(command, cwd=cwd, env=env, stdout=handle, stderr=subprocess.STDOUT, check=True)
63
+ else:
64
+ subprocess.run(command, cwd=cwd, env=env, check=True)
65
+
66
+
67
+ def copy_opencode_state(real_home: Path, demo_home: Path) -> None:
68
+ (demo_home / ".config").mkdir(parents=True, exist_ok=True)
69
+ (demo_home / ".local/share/opencode").mkdir(parents=True, exist_ok=True)
70
+ (demo_home / ".cache").mkdir(parents=True, exist_ok=True)
71
+
72
+ config_dir = real_home / ".config/opencode"
73
+ if config_dir.exists():
74
+ shutil.copytree(config_dir, demo_home / ".config/opencode", dirs_exist_ok=True)
75
+
76
+ auth_file = real_home / ".local/share/opencode/auth.json"
77
+ if auth_file.exists():
78
+ shutil.copy2(auth_file, demo_home / ".local/share/opencode/auth.json")
79
+
80
+ cache_dir = real_home / ".cache/opencode"
81
+ if cache_dir.exists():
82
+ shutil.copytree(cache_dir, demo_home / ".cache/opencode", dirs_exist_ok=True)
83
+
84
+
85
+ def write_fzf_driver(path: Path) -> None:
86
+ path.write_text(
87
+ r'''#!/usr/bin/env bash
88
+ set -euo pipefail
89
+
90
+ prompt=""
91
+ header=""
92
+ print_query=false
93
+ while [ "$#" -gt 0 ]; do
94
+ case "$1" in
95
+ --prompt) shift; prompt="${1:-}" ;;
96
+ --header) shift; header="${1:-}" ;;
97
+ --print-query) print_query=true ;;
98
+ esac
99
+ shift || true
100
+ done
101
+
102
+ input="$(cat)"
103
+ log="${AGENTIC_FAKE_FZF_LOG:-/tmp/agentic-fake-fzf.log}"
104
+ {
105
+ printf '%s\n' "--- prompt: $prompt"
106
+ printf '%s\n' "--- header: $header"
107
+ printf '%s\n' "$input"
108
+ } >> "$log"
109
+
110
+ choose_copilot() {
111
+ count_file="${AGENTIC_FAKE_FZF_COUNT:-/tmp/agentic-fake-fzf-count}"
112
+ n=0
113
+ [ -f "$count_file" ] && n="$(cat "$count_file" 2>/dev/null || printf 0)"
114
+ n=$((n + 1))
115
+ printf '%s\n' "$n" > "$count_file"
116
+ copilot="$(printf '%s\n' "$input" | awk -v n="$n" 'BEGIN{c=0} /github-copilot\// {c++; lines[c]=$0} END{if(c>0){idx=((n-1)%c)+1; print lines[idx]}}')"
117
+ if [ -n "$copilot" ]; then
118
+ printf '%s\n' "$copilot"
119
+ else
120
+ printf '%s\n' "$(printf '%s\n' "$input" | sed -n '1p')"
121
+ fi
122
+ }
123
+
124
+ case "$prompt" in
125
+ "Select interface theme:"*) printf 'dark\n' ;;
126
+ "Target project directory"*)
127
+ if [ "$print_query" = true ]; then
128
+ printf '%s\n<press Enter to confirm path>\n' "${AGENTIC_DEMO_PROJECT:-/tmp/agentic-opencode-demo}"
129
+ else
130
+ printf '%s\n' "${AGENTIC_DEMO_PROJECT:-/tmp/agentic-opencode-demo}"
131
+ fi
132
+ ;;
133
+ "Select Agent OS target"*) printf 'opencode\n' ;;
134
+ "Select optional MCP integration"*) printf 'context7\nmempalace\n' ;;
135
+ "Context7 API key mode:"*) printf 'Use without API key\n' ;;
136
+ "Select area"*) printf 'software\n' ;;
137
+ "Select specialization"*) printf 'backend\ngeneral\n' ;;
138
+ "Select optional OpenCode plugin"*) printf 'telegram-notification\nagent-model-mapper\n' ;;
139
+ "Telegram botToken"*)
140
+ if [ "$print_query" = true ]; then printf 'demo-token\n<press Enter to confirm>\n'; else printf 'demo-token\n'; fi
141
+ ;;
142
+ "Telegram chatId"*)
143
+ if [ "$print_query" = true ]; then printf 'demo-chat\n<press Enter to confirm>\n'; else printf 'demo-chat\n'; fi
144
+ ;;
145
+ "Save OpenCode model mapping?"*) printf 'Confirm\n' ;;
146
+ *" main> "*|*" fallback> "*) choose_copilot ;;
147
+ *) printf '%s\n' "$(printf '%s\n' "$input" | sed -n '1p')" ;;
148
+ esac
149
+ ''',
150
+ encoding="utf-8",
151
+ )
152
+ path.chmod(0o755)
153
+
154
+
155
+ def run_agentic_tui(args: argparse.Namespace, tmp_root: Path, project_dir: Path, demo_home: Path) -> Path:
156
+ fake_bin = tmp_root / "bin"
157
+ fake_bin.mkdir(parents=True, exist_ok=True)
158
+ write_fzf_driver(fake_bin / "fzf")
159
+
160
+ env = os.environ.copy()
161
+ env.update(
162
+ {
163
+ "HOME": str(demo_home),
164
+ "XDG_CONFIG_HOME": str(tmp_root / "xdg-config"),
165
+ "XDG_DATA_HOME": str(tmp_root / "xdg-data"),
166
+ "PATH": f"{fake_bin}:{env.get('PATH', '')}",
167
+ "AGENTIC_FORCE_INTERACTIVE": "1",
168
+ "AGENTIC_DEMO_PROJECT": str(project_dir),
169
+ "AGENTIC_FAKE_FZF_LOG": str(tmp_root / "fzf.log"),
170
+ "AGENTIC_FAKE_FZF_COUNT": str(tmp_root / "fzf-count"),
171
+ "AGENTIC_MEMPALACE_SETUP": "skip",
172
+ "AGENTIC_DOCTOR": "0",
173
+ }
174
+ )
175
+ output = tmp_root / "agentic-tui.out"
176
+ run([args.agentic, "tui"], env=env, output=output)
177
+ return output
178
+
179
+
180
+ def run_opencode(args: argparse.Namespace, tmp_root: Path, project_dir: Path, demo_home: Path) -> Path:
181
+ output = tmp_root / "opencode-product-owner.out"
182
+ if args.skip_opencode:
183
+ output.write_text(
184
+ "> product-owner · github-copilot/gpt-5.4\n"
185
+ "Acceptance criteria\n"
186
+ "1. The CLI accepts a user name as input via a required argument, e.g. `greet Alice`.\n"
187
+ "2. When a valid name is provided, the CLI prints exactly: `Hello, Alice!`\n"
188
+ "3. If no name is provided, the CLI shows a clear usage/error message and exits non-zero.\n"
189
+ "4. The CLI exits with status code `0` on successful greeting output.\n"
190
+ "5. The greeting is written to standard output only.\n",
191
+ encoding="utf-8",
192
+ )
193
+ return output
194
+
195
+ env = os.environ.copy()
196
+ env.update({"HOME": str(demo_home), "OPENCODE_DISABLE_AUTOUPDATE": "1"})
197
+ run(
198
+ [
199
+ args.opencode,
200
+ "run",
201
+ "--dir",
202
+ str(project_dir),
203
+ "--agent",
204
+ "product-owner",
205
+ "--dangerously-skip-permissions",
206
+ args.prompt,
207
+ ],
208
+ env=env,
209
+ output=output,
210
+ )
211
+ return output
212
+
213
+
214
+ def read_mappings(project_dir: Path) -> list[tuple[str, str, str]]:
215
+ config = json.loads((project_dir / ".opencode/opencode.json").read_text(encoding="utf-8"))
216
+ mappings: list[tuple[str, str, str]] = []
217
+ for role in ROLE_ORDER:
218
+ agent = config["agent"][role]
219
+ model = agent.get("model", "")
220
+ fallback = (agent.get("fallback") or [""])[0]
221
+ if not model.startswith("github-copilot/"):
222
+ raise SystemExit(f"Generated non-Copilot model for {role}: {model}")
223
+ if fallback and not fallback.startswith("github-copilot/"):
224
+ raise SystemExit(f"Generated non-Copilot fallback for {role}: {fallback}")
225
+ mappings.append((role, model, fallback))
226
+ return mappings
227
+
228
+
229
+ def clean_opencode_output(path: Path) -> list[str]:
230
+ raw = ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace"))
231
+ lines: list[str] = []
232
+ for line in raw.splitlines():
233
+ line = line.strip("\r")
234
+ if not line.strip():
235
+ continue
236
+ lowered = line.lower()
237
+ if "sqlite-migration" in lowered or "database migration" in lowered:
238
+ continue
239
+ line = line.replace("—", "-").replace("Here’s", "Here's").replace("· gpt-5.4", "· github-copilot/gpt-5.4")
240
+ lines.append(line)
241
+ return lines
242
+
243
+
244
+ def build_slides(
245
+ args: argparse.Namespace,
246
+ mappings: list[tuple[str, str, str]],
247
+ opencode_lines: list[str],
248
+ ) -> list[dict]:
249
+ slides: list[dict] = []
250
+
251
+ def terminal(duration: float, lines: list[tuple[str, str]]) -> None:
252
+ slides.append({"kind": "terminal", "duration": duration, "lines": lines})
253
+
254
+ def fzf(
255
+ duration: float,
256
+ prompt: str,
257
+ header: str,
258
+ options: list[str],
259
+ selected: list[str] | None = None,
260
+ cursor: int = 0,
261
+ query: str | None = None,
262
+ ) -> None:
263
+ slides.append(
264
+ {
265
+ "kind": "fzf",
266
+ "duration": duration,
267
+ "prompt": prompt,
268
+ "header": header,
269
+ "options": options,
270
+ "selected": set(selected or []),
271
+ "cursor": cursor,
272
+ "query": query,
273
+ }
274
+ )
275
+
276
+ terminal(
277
+ 2.4,
278
+ [
279
+ ("cmd", "$ ./agentic tui"),
280
+ ("normal", ""),
281
+ ("title", " _ ____ _____ _ _ _____ ___ ____"),
282
+ ("title", " / \\ / ___| ____| \\ | |_ _|_ _/ ___|"),
283
+ ("title", " / _ \\| | _| _| | \\| | | | | | |"),
284
+ ("title", " / ___ \\ |_| | |___| |\\ | | | | | |___"),
285
+ ("title", "/_/ \\_\\____|_____|_| \\_| |_| |___\\____|"),
286
+ ("section", "Agentic installer (TUI mode) v0.3.1"),
287
+ ("dim", "Theme: dark (resolved: dark)"),
288
+ ],
289
+ )
290
+ fzf(2.2, "Target project directory [/tmp/agentic-project]:", "Type path and press Enter to confirm", ["<press Enter to confirm path>"], query=f"/tmp/{args.project_name}")
291
+ fzf(3.0, "Select Agent OS target(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["default", "opencode", "codex", "claude", "antigravity", "cursor", "kilocode", "gemini"], ["opencode"], 1)
292
+ fzf(3.0, "Select optional MCP integration(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["<none>", "context7", "mempalace"], ["context7", "mempalace"], 2)
293
+ fzf(2.4, "Context7 API key mode:", "Use Up/Down to navigate - Enter to select", ["Use without API key", "Enter CONTEXT7_API_KEY"], ["Use without API key"], 0)
294
+ fzf(2.6, "Select area(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["devops", "software"], ["software"], 1)
295
+ fzf(3.4, "Select specialization(s) for 'software':", "Use Up/Down to navigate - Space to select - Enter to confirm", ["backend", "data-engineering", "frontend", "full-stack", "general", "mlops", "mobile", "platform", "qa", "security"], ["backend", "general"], 4)
296
+ fzf(3.2, "Select optional OpenCode plugin(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["<none>", "telegram-notification", "agent-model-mapper"], ["telegram-notification", "agent-model-mapper"], 2)
297
+ terminal(
298
+ 2.0,
299
+ [
300
+ ("section", "OpenCode plugins selected"),
301
+ ("ok", "telegram-notification enabled"),
302
+ ("ok", "agent-model-mapper enabled"),
303
+ ("dim", "Telegram credentials entered for this temporary demo project"),
304
+ ],
305
+ )
306
+
307
+ model_options = [
308
+ "github-copilot/gpt-4o",
309
+ "github-copilot/claude-sonnet-4.6",
310
+ "github-copilot/gpt-5.2",
311
+ "github-copilot/claude-sonnet-4.5",
312
+ "github-copilot/gemini-2.5-pro",
313
+ "github-copilot/grok-code-fast-1",
314
+ "github-copilot/claude-opus-4.6",
315
+ "github-copilot/gpt-4.1",
316
+ "github-copilot/gpt-5.4",
317
+ "github-copilot/gpt-5.4-mini",
318
+ "github-copilot/claude-haiku-4.5",
319
+ "github-copilot/gemini-3.1-pro-preview",
320
+ "github-copilot/gpt-5.5",
321
+ ]
322
+ for role, model, _fallback in mappings[:3]:
323
+ cursor = model_options.index(model) if model in model_options else 0
324
+ fzf(2.0, f"{role} main>", f"Select main model for {role}", model_options[:10], [model], min(cursor, 9))
325
+
326
+ terminal(
327
+ 4.2,
328
+ [("section", "agent-model-mapper: GitHub Copilot models")]
329
+ + [("ok", f"{role}: main={model} fallback={fallback}") for role, model, fallback in mappings],
330
+ )
331
+ fzf(2.0, "Save OpenCode model mapping?", "All Agentic roles mapped to github-copilot/* models", ["Confirm", "Cancel"], ["Confirm"], 0)
332
+ terminal(
333
+ 5.8,
334
+ [
335
+ ("cmd", "2026-05-22 17:02:51 [agentic] Run log initialized"),
336
+ ("ok", "telegram-notification enabled"),
337
+ ("section", "agent-model-mapper: choose OpenCode models for Agentic roles"),
338
+ ("ok", "agent-model-mapper: updated .opencode/opencode.json"),
339
+ ("ok", "Context7 MCP configured without an API key."),
340
+ ("ok", "MemPalace MCP binary found: mempalace-mcp"),
341
+ ("section", "=== Installation report ==="),
342
+ ("ok", "Agent OS targets: opencode"),
343
+ ("ok", "Specializations: software.backend software.general"),
344
+ ("ok", "Created directories: 24"),
345
+ ("ok", "Copied/generated paths: 89"),
346
+ ("ok", "Warnings: (none)"),
347
+ ],
348
+ )
349
+ terminal(
350
+ 2.6,
351
+ [
352
+ ("cmd", f"$ cd /tmp/{args.project_name}"),
353
+ ("cmd", "$ opencode run --agent product-owner \\"),
354
+ ("cmd", f' "{args.prompt}"'),
355
+ ],
356
+ )
357
+
358
+ output_rows: list[tuple[str, str]] = []
359
+ for line in opencode_lines:
360
+ style = "normal"
361
+ if line.startswith("agent-model-mapper"):
362
+ style = "ok"
363
+ elif line.startswith("> product-owner"):
364
+ style = "agent"
365
+ elif line.startswith("**"):
366
+ style = "section"
367
+ line = line.strip("*")
368
+ elif re.match(r"^[0-9]+\.", line):
369
+ style = "ok"
370
+ for part in textwrap.wrap(line, width=92, replace_whitespace=False, drop_whitespace=False) or [""]:
371
+ output_rows.append((style, part))
372
+ terminal(6.8, output_rows[:18])
373
+ terminal(3.0, [("ok", "Done: Agentic TUI installed OpenCode guidance; Product Owner answered through github-copilot/gpt-5.4.")])
374
+
375
+ total = sum(slide["duration"] for slide in slides)
376
+ if args.target_seconds > 0:
377
+ scale = args.target_seconds / total
378
+ for slide in slides:
379
+ slide["duration"] *= scale
380
+ return slides
381
+
382
+
383
+ def render_gif(slides: list[dict], output: Path, tmp_root: Path, fps: int) -> None:
384
+ require_tool("rsvg-convert")
385
+ require_tool("ffmpeg")
386
+
387
+ frames_dir = tmp_root / "tui_frames"
388
+ svg_dir = tmp_root / "tui_svg"
389
+ for directory in (frames_dir, svg_dir):
390
+ if directory.exists():
391
+ shutil.rmtree(directory)
392
+ directory.mkdir(parents=True)
393
+
394
+ colors = {
395
+ "bg": "#101418",
396
+ "bar": "#1c232b",
397
+ "border": "#2c3640",
398
+ "normal": "#d6deeb",
399
+ "dim": "#8b98a7",
400
+ "cmd": "#8bd5ff",
401
+ "ok": "#a6e3a1",
402
+ "warn": "#f9e2af",
403
+ "section": "#f5c2e7",
404
+ "title": "#ffffff",
405
+ "agent": "#94e2d5",
406
+ "selected": "#0f2f3a",
407
+ "accent": "#89dceb",
408
+ }
409
+
410
+ def text_node(x: int, y: int, text: str, style: str = "normal", *, weight: str | None = None, size: int | None = None) -> str:
411
+ color = colors.get(style, colors["normal"])
412
+ weight = weight or ("700" if style in {"title", "section", "agent"} else "400")
413
+ size = size or (14 if style == "title" else 13)
414
+ return (
415
+ f'<text x="{x}" y="{y}" fill="{color}" font-family="Menlo, Monaco, monospace" '
416
+ f'font-size="{size}" font-weight="{weight}" xml:space="preserve">{html.escape(text)}</text>'
417
+ )
418
+
419
+ def base(nodes: list[str]) -> str:
420
+ return (
421
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{GIF_WIDTH}" height="{GIF_HEIGHT}" '
422
+ f'viewBox="0 0 {GIF_WIDTH} {GIF_HEIGHT}">'
423
+ f'<rect width="{GIF_WIDTH}" height="{GIF_HEIGHT}" fill="{colors["bg"]}"/>'
424
+ f'<rect x="0" y="0" width="{GIF_WIDTH}" height="34" fill="{colors["bar"]}"/>'
425
+ '<circle cx="20" cy="17" r="5" fill="#ff5f57"/>'
426
+ '<circle cx="38" cy="17" r="5" fill="#ffbd2e"/>'
427
+ '<circle cx="56" cy="17" r="5" fill="#28c840"/>'
428
+ f'{text_node(76, 22, "agentic tui - OpenCode setup", "dim", size=12)}'
429
+ f'<rect x="0.5" y="0.5" width="{GIF_WIDTH - 1}" height="{GIF_HEIGHT - 1}" fill="none" stroke="{colors["border"]}"/>'
430
+ f'{"".join(nodes)}</svg>'
431
+ )
432
+
433
+ def render_terminal(slide: dict) -> str:
434
+ nodes: list[str] = []
435
+ y = 58
436
+ for style, line in slide["lines"][-25:]:
437
+ nodes.append(text_node(18, y, line, style))
438
+ y += 17
439
+ return base(nodes)
440
+
441
+ def render_fzf(slide: dict) -> str:
442
+ nodes = [text_node(18, 54, "$ ./agentic tui", "cmd")]
443
+ px, py, pw, ph = 28, 92, 764, 390
444
+ nodes.append(f'<rect x="{px}" y="{py}" width="{pw}" height="{ph}" rx="4" fill="#0b1117" stroke="{colors["border"]}"/>')
445
+ nodes.append(text_node(px + 16, py + 28, slide["header"], "dim", size=12))
446
+ if slide.get("query") is not None:
447
+ nodes.append(text_node(px + 16, py + 58, f'{slide["prompt"]} {slide["query"]}', "cmd"))
448
+ opt_y = py + 92
449
+ else:
450
+ nodes.append(text_node(px + 16, py + 58, slide["prompt"], "cmd"))
451
+ opt_y = py + 90
452
+
453
+ selected = slide["selected"]
454
+ cursor = slide["cursor"]
455
+ options = slide["options"]
456
+ start = 0
457
+ max_opts = 16
458
+ if len(options) > max_opts:
459
+ start = max(0, min(cursor - 7, len(options) - max_opts))
460
+ visible = options[start : start + max_opts]
461
+ for index, option in enumerate(visible, start):
462
+ yy = opt_y + (index - start) * 22
463
+ is_cursor = index == cursor
464
+ is_selected = option in selected
465
+ if is_cursor:
466
+ nodes.append(f'<rect x="{px + 10}" y="{yy - 15}" width="{pw - 20}" height="21" fill="{colors["selected"]}"/>')
467
+ marker = "[x]" if is_selected else "[ ]"
468
+ prefix = "> " if is_cursor else " "
469
+ style = "ok" if is_selected else ("accent" if is_cursor else "normal")
470
+ nodes.append(text_node(px + 18, yy, f"{prefix}{marker} {option}", style))
471
+ nodes.append(text_node(px + 16, py + ph - 18, "Space: toggle Enter: confirm fzf", "dim", size=12))
472
+ return base(nodes)
473
+
474
+ cumulative: list[tuple[float, dict]] = []
475
+ elapsed = 0.0
476
+ for slide in slides:
477
+ elapsed += slide["duration"]
478
+ cumulative.append((elapsed, slide))
479
+
480
+ def slide_at(timestamp: float) -> dict:
481
+ for end, slide in cumulative:
482
+ if timestamp <= end:
483
+ return slide
484
+ return slides[-1]
485
+
486
+ frame_count = math.ceil(elapsed * fps)
487
+ for frame in range(frame_count):
488
+ slide = slide_at(frame / fps)
489
+ svg = render_fzf(slide) if slide["kind"] == "fzf" else render_terminal(slide)
490
+ svg_path = svg_dir / f"frame_{frame:04d}.svg"
491
+ png_path = frames_dir / f"frame_{frame:04d}.png"
492
+ svg_path.write_text(svg, encoding="utf-8")
493
+ subprocess.run(["rsvg-convert", "-w", str(GIF_WIDTH), "-h", str(GIF_HEIGHT), str(svg_path), "-o", str(png_path)], check=True)
494
+
495
+ output.parent.mkdir(parents=True, exist_ok=True)
496
+ run(
497
+ [
498
+ "ffmpeg",
499
+ "-hide_banner",
500
+ "-y",
501
+ "-framerate",
502
+ str(fps),
503
+ "-i",
504
+ str(frames_dir / "frame_%04d.png"),
505
+ "-vf",
506
+ "fps="
507
+ + str(fps)
508
+ + ",split[s0][s1];[s0]palettegen=max_colors=96[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5",
509
+ "-loop",
510
+ "0",
511
+ str(output),
512
+ ]
513
+ )
514
+
515
+
516
+ def scan_gif_for_private_strings(output: Path) -> None:
517
+ data = output.read_bytes()
518
+ needles = [b"/Users", b"token", b"secret", b"auth", b"google/", b"openai/", b"ollama-cloud/", b"deepseek/", b"minimax-"]
519
+ found = [needle.decode("utf-8", errors="ignore") for needle in needles if needle in data]
520
+ if found:
521
+ raise SystemExit(f"Generated GIF contains suspicious strings: {', '.join(found)}")
522
+
523
+
524
+ def main() -> int:
525
+ args = parse_args()
526
+ require_tool("rsvg-convert")
527
+ require_tool("ffmpeg")
528
+ require_tool(args.opencode)
529
+
530
+ tmp_root = Path(args.tmp_root) if args.tmp_root else Path(tempfile.mkdtemp(prefix="agentic-tui-gif."))
531
+ tmp_root.mkdir(parents=True, exist_ok=True)
532
+ project_dir = tmp_root / "project"
533
+ demo_home = tmp_root / "home"
534
+ demo_home.mkdir(parents=True, exist_ok=True)
535
+
536
+ copy_opencode_state(Path.home(), demo_home)
537
+ print(f"[gif] temp root: {tmp_root}")
538
+ print("[gif] running agentic TUI with deterministic fzf selections")
539
+ run_agentic_tui(args, tmp_root, project_dir, demo_home)
540
+
541
+ mappings = read_mappings(project_dir)
542
+ print("[gif] mapped roles:")
543
+ for role, model, fallback in mappings:
544
+ print(f"[gif] {role}: main={model} fallback={fallback}")
545
+
546
+ if args.skip_opencode:
547
+ print("[gif] using built-in OpenCode output sample (--skip-opencode)")
548
+ else:
549
+ print("[gif] running OpenCode product-owner")
550
+ opencode_output = run_opencode(args, tmp_root, project_dir, demo_home)
551
+ opencode_lines = clean_opencode_output(opencode_output)
552
+ if not args.skip_opencode and not any(line.startswith("> product-owner") for line in opencode_lines):
553
+ raise SystemExit(f"OpenCode output did not include product-owner header; see {opencode_output}")
554
+
555
+ output = Path(args.output)
556
+ print(f"[gif] rendering {output}")
557
+ slides = build_slides(args, mappings, opencode_lines)
558
+ render_gif(slides, output, tmp_root, args.fps)
559
+ scan_gif_for_private_strings(output)
560
+ print(f"[gif] wrote {output}")
561
+ return 0
562
+
563
+
564
+ if __name__ == "__main__":
565
+ raise SystemExit(main())