lastlight 0.4.0 → 0.6.1

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 (100) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +56 -6
  3. package/agent-context/rules.md +0 -38
  4. package/agent-context/soul.md +0 -11
  5. package/deploy/entrypoint.sh +21 -6
  6. package/deploy/sandbox-entrypoint.sh +1 -1
  7. package/dist/cli.js +24 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +13 -2
  10. package/dist/config.js +29 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/connectors/messaging/base.js +22 -9
  13. package/dist/connectors/messaging/base.js.map +1 -1
  14. package/dist/connectors/slack/connector.d.ts +62 -9
  15. package/dist/connectors/slack/connector.js +216 -63
  16. package/dist/connectors/slack/connector.js.map +1 -1
  17. package/dist/connectors/slack/connector.test.d.ts +1 -0
  18. package/dist/connectors/slack/connector.test.js +135 -0
  19. package/dist/connectors/slack/connector.test.js.map +1 -0
  20. package/dist/connectors/types.d.ts +7 -0
  21. package/dist/engine/agent-executor.d.ts +29 -144
  22. package/dist/engine/agent-executor.js +280 -962
  23. package/dist/engine/agent-executor.js.map +1 -1
  24. package/dist/engine/agent-executor.seam.test.d.ts +1 -0
  25. package/dist/engine/agent-executor.seam.test.js +57 -0
  26. package/dist/engine/agent-executor.seam.test.js.map +1 -0
  27. package/dist/engine/dispatcher.d.ts +13 -0
  28. package/dist/engine/dispatcher.js +26 -7
  29. package/dist/engine/dispatcher.js.map +1 -1
  30. package/dist/engine/dispatcher.test.js.map +1 -1
  31. package/dist/engine/executors/backends.d.ts +41 -0
  32. package/dist/engine/executors/backends.js +541 -0
  33. package/dist/engine/executors/backends.js.map +1 -0
  34. package/dist/engine/executors/shared.d.ts +189 -0
  35. package/dist/engine/executors/shared.js +612 -0
  36. package/dist/engine/executors/shared.js.map +1 -0
  37. package/dist/engine/message-batcher.d.ts +32 -0
  38. package/dist/engine/message-batcher.js +109 -0
  39. package/dist/engine/message-batcher.js.map +1 -0
  40. package/dist/engine/message-batcher.test.d.ts +1 -0
  41. package/dist/engine/message-batcher.test.js +109 -0
  42. package/dist/engine/message-batcher.test.js.map +1 -0
  43. package/dist/engine/profiles.d.ts +9 -0
  44. package/dist/engine/profiles.js.map +1 -1
  45. package/dist/evals-api.d.ts +24 -0
  46. package/dist/evals-api.js +22 -0
  47. package/dist/evals-api.js.map +1 -0
  48. package/dist/index.js +41 -4
  49. package/dist/index.js.map +1 -1
  50. package/dist/sandbox/command-exec.integration.test.d.ts +1 -0
  51. package/dist/sandbox/command-exec.integration.test.js +183 -0
  52. package/dist/sandbox/command-exec.integration.test.js.map +1 -0
  53. package/dist/sandbox/docker.d.ts +32 -0
  54. package/dist/sandbox/docker.js +109 -4
  55. package/dist/sandbox/docker.js.map +1 -1
  56. package/dist/sandbox/index.d.ts +1 -1
  57. package/dist/sandbox/index.js +1 -1
  58. package/dist/sandbox/index.js.map +1 -1
  59. package/dist/sandbox/smol.d.ts +162 -0
  60. package/dist/sandbox/smol.integration.test.d.ts +1 -0
  61. package/dist/sandbox/smol.integration.test.js +130 -0
  62. package/dist/sandbox/smol.integration.test.js.map +1 -0
  63. package/dist/sandbox/smol.js +485 -0
  64. package/dist/sandbox/smol.js.map +1 -0
  65. package/dist/skills-install.d.ts +12 -0
  66. package/dist/skills-install.js +179 -0
  67. package/dist/skills-install.js.map +1 -0
  68. package/dist/workflows/loader.test.js +83 -0
  69. package/dist/workflows/loader.test.js.map +1 -1
  70. package/dist/workflows/phase-executor.d.ts +28 -3
  71. package/dist/workflows/phase-executor.js +170 -45
  72. package/dist/workflows/phase-executor.js.map +1 -1
  73. package/dist/workflows/phase-executor.test.js +64 -1
  74. package/dist/workflows/phase-executor.test.js.map +1 -1
  75. package/dist/workflows/runner.test.js +32 -27
  76. package/dist/workflows/runner.test.js.map +1 -1
  77. package/dist/workflows/schema.d.ts +20 -0
  78. package/dist/workflows/schema.js +72 -2
  79. package/dist/workflows/schema.js.map +1 -1
  80. package/package.json +15 -3
  81. package/plugins/lastlight/.claude-plugin/plugin.json +12 -0
  82. package/plugins/lastlight/README.md +44 -0
  83. package/plugins/lastlight/skills/lastlight-client/SKILL.md +82 -0
  84. package/plugins/lastlight/skills/lastlight-evals/SKILL.md +97 -0
  85. package/plugins/lastlight/skills/lastlight-evals/references/instance-schema.md +84 -0
  86. package/plugins/lastlight/skills/lastlight-evals/references/models-json.md +42 -0
  87. package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +72 -0
  88. package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +55 -0
  89. package/plugins/lastlight/skills/lastlight-overlay/references/overlay-layout.md +52 -0
  90. package/plugins/lastlight/skills/lastlight-server/SKILL.md +124 -0
  91. package/plugins/lastlight/skills/lastlight-server/references/env-schema.md +103 -0
  92. package/plugins/lastlight/skills/lastlight-server/references/operations.md +60 -0
  93. package/sandbox.Dockerfile +7 -0
  94. package/skills/browser-qa/scripts/agent-browser.mjs +15 -0
  95. package/skills/demo/SKILL.md +45 -19
  96. package/skills/demo/scripts/compose-demo.sh +77 -39
  97. package/workflows/examples/bash-smoke.yaml +41 -0
  98. package/workflows/prompts/demo.md +27 -5
  99. package/workflows/qa-test.yaml +1 -1
  100. package/workflows/verify.yaml +1 -1
@@ -0,0 +1,103 @@
1
+ # Overlay config reference — `instance/secrets/.env` + `instance/config.yaml`
2
+
3
+ The server reads config in layers: packaged `config/default.yaml` → overlay
4
+ `instance/config.yaml` → `instance/secrets/.env` env vars → `LASTLIGHT_*` env.
5
+ Secrets stay env-only and out of git. The container entrypoint copies
6
+ `instance/secrets/.env` → `/app/.env` and `instance/secrets/*.pem` → `/app/*.pem`
7
+ (mode 600) at boot, and the overlay is mounted read-only at `/app/instance`.
8
+
9
+ ## `instance/secrets/.env` (mode 0600)
10
+
11
+ Write exactly these keys. `WEBHOOK_SECRET` and `ADMIN_SECRET` are random 32-byte
12
+ hex (`openssl rand -hex 32`). Include only the ONE provider key that matches the
13
+ model.
14
+
15
+ ```dotenv
16
+ # ── Last Light — Environment Variables ─────────────────────
17
+
18
+ # Overlay (this deployment's private config + assets)
19
+ LASTLIGHT_OVERLAY_DIR=/app/instance
20
+
21
+ # ── GitHub App (required) ────────────────────────────────
22
+ GITHUB_APP_ID=123456
23
+ # PEM lives at instance/secrets/app.pem; the entrypoint symlinks it to /app/app.pem.
24
+ GITHUB_APP_PRIVATE_KEY_PATH=./app.pem
25
+ GITHUB_APP_INSTALLATION_ID=789012
26
+
27
+ # ── Webhook (required) — must match the GitHub App's webhook secret ──
28
+ WEBHOOK_SECRET=<openssl rand -hex 32>
29
+
30
+ # ── Domain (used by Caddy for TLS) ───────────────────────
31
+ DOMAIN=lastlight.example.com
32
+
33
+ # ── Model + provider API key ─────────────────────────────
34
+ LASTLIGHT_MODEL=anthropic/claude-sonnet-4-6
35
+ # Set whichever ONE matches LASTLIGHT_MODEL:
36
+ ANTHROPIC_API_KEY=sk-ant-...
37
+ # OPENAI_API_KEY=sk-...
38
+ # OPENROUTER_API_KEY=sk-or-...
39
+
40
+ # ── Admin dashboard ──────────────────────────────────────
41
+ ADMIN_SECRET=<openssl rand -hex 32>
42
+ # Optional — protects /admin with a password (>=8 chars):
43
+ # ADMIN_PASSWORD=...
44
+
45
+ # ── Slack (optional) ─────────────────────────────────────
46
+ # SLACK_BOT_TOKEN=xoxb-...
47
+ # SLACK_APP_TOKEN=xapp-...
48
+ # SLACK_DELIVERY_CHANNEL=C0123456
49
+ # SLACK_ALLOWED_USERS=U0123,U0456
50
+ ```
51
+
52
+ Notes:
53
+ - `GITHUB_APP_PRIVATE_KEY_PATH=./app.pem` is correct as-is — it's resolved
54
+ inside the container, not on the host. Just place the file at
55
+ `instance/secrets/app.pem`.
56
+ - Provider-key detection: `sk-ant-…` is Anthropic; `sk-or-…` is OpenRouter; any
57
+ other `sk-…` is OpenAI.
58
+ - **Removing** an env var later requires a container *recreate*
59
+ (`lastlight server start agent`), not just a restart — compose injects
60
+ `env_file` vars at creation time. Adding/changing one only needs
61
+ `lastlight server restart agent`.
62
+
63
+ ## `instance/config.yaml`
64
+
65
+ Non-secret overlay config, merged over `config/default.yaml`. Arrays replace;
66
+ maps deep-merge. Minimum useful content is the managed-repos list:
67
+
68
+ ```yaml
69
+ # Last Light — private deployment overlay config
70
+ # Merged over config/default.yaml at startup. Restart to apply:
71
+ # lastlight server restart agent
72
+ managedRepos:
73
+ - owner/repo
74
+ - owner/another-repo
75
+ ```
76
+
77
+ If there are no repos yet, write `managedRepos: []` and tell the user to add
78
+ entries before the bot will act. You can also override `models`, `variants`,
79
+ `routes`, and `disabled.*` here — see the repo's `config/default.yaml` for the
80
+ full shape.
81
+
82
+ ## `instance/.gitignore`
83
+
84
+ So the overlay can become a private git repo without leaking secrets:
85
+
86
+ ```gitignore
87
+ secrets/
88
+ *.pem
89
+ ```
90
+
91
+ ## `instance/docker-compose.override.yml` (only if Caddy is disabled)
92
+
93
+ Write this ONLY when the user opts out of Caddy TLS (they terminate TLS
94
+ elsewhere). Then symlink it into the working dir as `./docker-compose.override.yml`
95
+ (or run `lastlight server setup` / `update`, which ensures the symlink).
96
+
97
+ ```yaml
98
+ # Deployment compose override — this deployment opted out of Caddy TLS.
99
+ services:
100
+ caddy:
101
+ profiles:
102
+ - disabled
103
+ ```
@@ -0,0 +1,60 @@
1
+ # Last Light server — day-2 operations
2
+
3
+ All commands are **host-local** (run on the server, not over HTTP) and operate
4
+ on the working directory. That directory resolves from `--home` →
5
+ `LASTLIGHT_HOME` → the saved `serverHome` (from `lastlight server setup`) →
6
+ `~/lastlight`.
7
+
8
+ ## Lifecycle
9
+
10
+ ```bash
11
+ lastlight server status # docker compose ps + core/overlay version drift
12
+ lastlight server start [service] # docker compose up -d (whole stack, or one service)
13
+ lastlight server stop [service] # stop one service, or `down` the whole stack
14
+ lastlight server restart [service] # restart (default service: agent)
15
+ ```
16
+
17
+ ## Apply config changes
18
+
19
+ - **Overlay config.yaml or an added/changed `.env` value:**
20
+ `lastlight server restart agent` — no image rebuild.
21
+ - **Removing an `.env` value:** `lastlight server start agent` (recreate) — a
22
+ restart can't unset env_file vars injected at container creation.
23
+ - **Code/asset changes** (anything under `src/`, `workflows/`, `skills/`,
24
+ `agent-context/`, `config/default.yaml`): a full rebuild — `lastlight server
25
+ update`.
26
+
27
+ ## Redeploy after a code change (the canonical deploy)
28
+
29
+ ```bash
30
+ lastlight server update # pull core + overlay, build images, recreate, restart sidecars, health-check
31
+ # flags: --no-core --no-overlay --no-build --yes
32
+ ```
33
+
34
+ This: git-pulls the core repo and the `instance/` overlay, builds the `agent` +
35
+ `sandbox` (+ best-effort `sandbox-qa`) images, `docker compose up -d
36
+ --remove-orphans`, force-restarts the egress sidecars (coredns + nginx + otel),
37
+ and health-checks `http://127.0.0.1:8644/health`.
38
+
39
+ ## Logs & health
40
+
41
+ ```bash
42
+ curl -fsS http://127.0.0.1:8644/health
43
+ lastlight server logs agent --follow # live harness logs
44
+ lastlight server logs [service] --tail 200 --since 10m
45
+ lastlight server list # the lastlight-* containers
46
+ ```
47
+
48
+ ## Debug a running instance (over the admin API)
49
+
50
+ These talk to the instance over HTTP (need `lastlight login` first — see the
51
+ lastlight-client skill):
52
+
53
+ ```bash
54
+ lastlight workflow list [--status s] [--workflow name]
55
+ lastlight workflow log <id> [--follow]
56
+ lastlight session list ; lastlight session log <id> --follow
57
+ lastlight logs search "<text>" [--scope errors|messages|all]
58
+ lastlight approvals list|approve <id>|reject <id>
59
+ lastlight stats [--daily n | --hourly n]
60
+ ```
@@ -62,6 +62,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
62
62
  && apt-get purge -y python3-pip \
63
63
  && rm -rf /var/lib/apt/lists/*
64
64
 
65
+ # uv — fast, isolated Python runner for `type: script` (runtime: python) phases.
66
+ # Single static binary; `uv run script.py` honours PEP 723 inline dependency
67
+ # blocks and resolves them into a cached venv (UV_CACHE_DIR is pointed at the
68
+ # shared /cache volume at runtime, mirroring the npm/pnpm/yarn cache wiring).
69
+ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh \
70
+ && uv --version
71
+
65
72
  # Create non-root agent user, UID/GID pinned to 10001 to MATCH the harness
66
73
  # `lastlight` user (see Dockerfile: `useradd -u 10001 lastlight`). The
67
74
  # entrypoint chowns the bind-mounted workspace to `agent`; per-PR workspaces
@@ -69,6 +69,13 @@ import { createRequire } from 'node:module';
69
69
  const require = createRequire(import.meta.url);
70
70
 
71
71
  const WAIT_TIMEOUT = 10_000; // sane default for waitFor / assertText probes
72
+ // When recording, hold the page open briefly after the last step before closing
73
+ // the context (which flushes the .webm). A bare goto→waitFor→text flow finishes
74
+ // in ~250 ms — too short for Playwright's video to capture a *painted* frame, so
75
+ // the clip can come out blank (the /demo before/after's "before" panel did).
76
+ // Settling lets late paints + async data ("Loading…") resolve and guarantees a
77
+ // watchable tail. Override with --record-settle-ms / LASTLIGHT_RECORD_SETTLE_MS.
78
+ const RECORD_SETTLE_MS = Number(process.env.LASTLIGHT_RECORD_SETTLE_MS) || 1500;
72
79
 
73
80
  function emit(obj) {
74
81
  process.stdout.write(JSON.stringify(obj) + '\n');
@@ -300,6 +307,14 @@ async function run(flowPath, baseUrlArg, outDirArg, recordDirArg) {
300
307
  // the context to close, which flushes the .webm to disk. A save failure is a
301
308
  // reported finding, not a fatal run error.
302
309
  if (doRecord) {
310
+ // Settle before closing: let late paints + async data resolve and ensure
311
+ // the .webm has a watchable, non-blank tail (see RECORD_SETTLE_MS).
312
+ if (RECORD_SETTLE_MS > 0) {
313
+ await page
314
+ .waitForLoadState('networkidle', { timeout: RECORD_SETTLE_MS })
315
+ .catch(() => {});
316
+ await page.waitForTimeout(RECORD_SETTLE_MS).catch(() => {});
317
+ }
303
318
  const video = page.video();
304
319
  await context.close();
305
320
  if (video) {
@@ -95,14 +95,34 @@ node <browser-qa-dir>/scripts/agent-browser.mjs run flow.json \
95
95
  # → writes /tmp/demo-cap/session.webm, reports {"video":"…/session.webm", …}
96
96
  ```
97
97
 
98
- Pick the viewport to match the layout so the clip isn't letterboxed:
99
- `1280x720` for `single`; `~960x1000` per panel for `side-by-side` (set it in the
100
- flow's `viewport`).
98
+ Pick the viewport to match the layout so the clip isn't letterboxed (the
99
+ compositor renders at **1920×1080** by default): `1920x1080` for `single`;
100
+ `~960x1000` per panel for `side-by-side` (set it in the flow's `viewport`).
101
+ Recording at the output resolution keeps UI text crisp instead of upscaled.
102
+
103
+ **Before/after comparison:** record the **same** scripted interaction against
104
+ each branch — `session-after.webm` (PR head) and `session-before.webm` (base
105
+ branch). Only the behaviour should differ.
106
+
107
+ When the harness triggered this on a PR, your workspace is **already checked out
108
+ at the PR head** — record the *after* from that current checkout first (no
109
+ re-checkout). Then get the *before* from the base branch. The pre-clone is
110
+ shallow + single-branch, so fetch the base **by ref** (the prompt passes its
111
+ name) rather than trusting a local branch name, which may be a synthesized
112
+ `lastlight/N-slug` that actually points at the default branch:
101
113
 
102
- **Before/after comparison:** check out and run each branch in turn (the
103
- `building` skill), recording the **same** scripted interaction against each —
104
- `session-before.webm` (base branch) and `session-after.webm` (PR head). Only the
105
- behaviour should differ.
114
+ ```bash
115
+ git fetch --depth 1 origin <baseBranch>
116
+ git checkout -B <baseBranch>-base FETCH_HEAD # re-install deps + restart the dev-server after switching
117
+ ```
118
+
119
+ **Verify the checkout did what you think before you composite:** read the
120
+ disambiguating element on *both* recordings (the driver's `text`/`assertText`).
121
+ It MUST be present in *after* and absent in *before*. If both look identical, the
122
+ checkout is wrong (a stale/fictional branch, deps not rebuilt, or a dev-server
123
+ still serving the old branch) — STOP and fix it. Never ship a side-by-side that
124
+ shows no difference and call it a before/after; that is the failure this skill
125
+ exists to prevent.
106
126
 
107
127
  ## Compose the video
108
128
 
@@ -114,34 +134,40 @@ Composite the raw recording(s) into the final mp4 with the bundled wrapper:
114
134
  --output <artifact-dir>/demo.mp4 \
115
135
  --title "PR #42 — Add dark mode toggle" \
116
136
  --subtitle "Toggling persists the theme across reloads" \
117
- --layout single --speed 1.5 --target-size-mb 5 \
137
+ --layout single --speed 1.5 \
118
138
  /tmp/demo-cap/session.webm
119
139
 
120
- # Before/after comparison
140
+ # Before/after comparison — first clip = left/BEFORE, second = right/AFTER
121
141
  <demo-dir>/scripts/compose-demo.sh \
122
142
  --output <artifact-dir>/demo.mp4 \
123
143
  --title "PR #51 — Fix flicker on load" \
124
144
  --layout side-by-side --labels "BEFORE (main)" "AFTER (PR)" \
125
- --target-size-mb 5 \
126
145
  /tmp/before/session-before.webm /tmp/after/session-after.webm
127
146
  ```
128
147
 
129
148
  Flags: `--title` (required, on the card), `--subtitle`, `--layout
130
149
  single|side-by-side`, `--labels A B` (side-by-side), `--speed N`, `--trim
131
- START:END` (seconds, single layout), `--target-size-mb` (default 5),
132
- `--width/--height` (default 1280×720), `--title-secs`. The script normalizes,
133
- lays out, adds the title card, and does a **two-pass** encode to land under the
134
- size cap (`+faststart`, `yuv420p`). It prints a JSON summary line:
135
- `{"output":"…","resolution":"1280x720","duration":"38.4","size_mb":3.1}`.
150
+ START:END` (seconds, single layout), `--crf` (default 18 — lower = higher
151
+ quality), `--target-size-mb` (default 10), `--width/--height` (default
152
+ 1920×1080), `--title-secs`. The script normalizes (lanczos scale), lays out, adds
153
+ the title card, then does a **quality-first single-pass CRF** encode
154
+ (`+faststart`, `yuv420p`); only if that overshoots `--target-size-mb` does it
155
+ fall back to a size-capped two-pass encode. It prints a JSON summary line:
156
+ `{"output":"…","resolution":"1920x1080","duration":"38.4","size_mb":3.1}`.
136
157
 
137
158
  ## Guardrails — self-check before reporting
138
159
 
139
160
  Confirm the output with `ffprobe` (the script's summary already reports these):
140
161
 
141
- - **Resolution** matches the requested size (default 1920-free 1280×720).
142
- - **Duration** is sane — not 0s, within a watchable band (~15–90s).
143
- - **Size 5 MB** so GitHub embeds it inline (25 MB hard limit). If over, re-run
144
- with a lower `--target-size-mb` or a higher `--speed`.
162
+ - **Resolution** matches the requested size (default 1920×1080).
163
+ - **Duration** is sane — not 0s, within a watchable band (~15–90s). Don't crank
164
+ `--speed` so high (or so low) that the clip becomes a sub-second flash or a
165
+ near-static crawl; the agent that shipped a 0.2s recording at `--speed 0.05`
166
+ proved nothing.
167
+ - **Size ≤ 10 MB** so GitHub embeds it inline (25 MB hard limit). The CRF encode
168
+ usually lands far under this on its own; if it's over, the script auto-falls
169
+ back to a size-capped encode, but you can also lower `--target-size-mb`, raise
170
+ `--crf`, or raise `--speed`.
145
171
 
146
172
  If the recording is too short to be meaningful (a couple of seconds), go back
147
173
  and add interaction steps — don't ship a clip that proves nothing.
@@ -9,16 +9,19 @@
9
9
  # compose-demo.sh --output demo.mp4 --title "PR #42 — Add dark mode" \
10
10
  # [--subtitle "..."] [--layout single|side-by-side] \
11
11
  # [--labels "BEFORE (main)" "AFTER (PR)"] [--speed 1.5] \
12
- # [--trim START:END] [--target-size-mb 5] [--width 1280] [--height 720] \
13
- # [--title-secs 2.5] <clip1.webm> [clip2.webm]
12
+ # [--trim START:END] [--target-size-mb 10] [--crf 18] \
13
+ # [--width 1920] [--height 1080] [--title-secs 2.5] <clip1.webm> [clip2.webm]
14
14
  #
15
15
  # What it does:
16
16
  # 1. Normalizes each input clip (optional trim, speed-up, scale+pad to the
17
- # panel size, fps/pixfmt/SAR normalize, optional per-panel label).
17
+ # panel size with lanczos, fps/pixfmt/SAR normalize, optional per-panel
18
+ # label).
18
19
  # 2. Lays out the body: a single panel, or two panels side-by-side (hstack).
19
20
  # 3. Prepends a title/subtitle card (solid bg + drawtext).
20
- # 4. Two-pass H.264 encode targeting --target-size-mb so the mp4 lands under
21
- # GitHub's inline-embed limit; yuv420p + +faststart for web streaming.
21
+ # 4. Quality-first H.264 encode: a single-pass CRF encode (sharp for UI
22
+ # screencasts) that usually lands well under --target-size-mb; only if it
23
+ # overshoots does it fall back to a size-targeted two-pass ABR encode.
24
+ # yuv420p + +faststart for web streaming.
22
25
  # 5. Prints an ffprobe summary (resolution / duration / size).
23
26
  #
24
27
  # Prerequisites: ffmpeg, ffprobe (baked into lastlight-sandbox-qa:latest).
@@ -40,9 +43,10 @@ LABEL1=""
40
43
  LABEL2=""
41
44
  SPEED="1"
42
45
  TRIM=""
43
- TARGET_MB="5"
44
- WIDTH="1280"
45
- HEIGHT="720"
46
+ TARGET_MB="10"
47
+ CRF="18"
48
+ WIDTH="1920"
49
+ HEIGHT="1080"
46
50
  TITLE_SECS="2.5"
47
51
  CLIPS=()
48
52
 
@@ -57,6 +61,7 @@ while [[ $# -gt 0 ]]; do
57
61
  --speed) SPEED="$2"; shift 2;;
58
62
  --trim) TRIM="$2"; shift 2;;
59
63
  --target-size-mb) TARGET_MB="$2"; shift 2;;
64
+ --crf) CRF="$2"; shift 2;;
60
65
  --width) WIDTH="$2"; shift 2;;
61
66
  --height) HEIGHT="$2"; shift 2;;
62
67
  --title-secs) TITLE_SECS="$2"; shift 2;;
@@ -94,11 +99,19 @@ if [[ -n "$FONT" ]]; then FONTARG="fontfile=${FONT}"; else FONTARG="font=Sans";
94
99
  # gracefully on a build without it (omit text overlays) rather than failing the
95
100
  # whole compose — the demo video is still worth shipping.
96
101
  HAS_DRAWTEXT=0
97
- if ffmpeg -hide_banner -filters 2>/dev/null | grep -q 'drawtext'; then
98
- HAS_DRAWTEXT=1
99
- else
100
- echo "compose-demo.sh: warning: this ffmpeg lacks the drawtext filter title card and labels omitted." >&2
101
- fi
102
+ # Detect the drawtext filter WITHOUT a pipe. Under `set -o pipefail`, any
103
+ # `ffmpeg -filters | grep -q drawtext` (or even `printf "$var" | grep -q`) is a
104
+ # false-negative trap: `grep -q` exits on the first match and closes the pipe,
105
+ # the writer (still emitting `drawtext` sits ~16 KB into ~38 KB of output)
106
+ # dies with SIGPIPE (141), and `pipefail` promotes that to the pipeline's exit
107
+ # status, so the `if` wrongly concludes drawtext is absent (~19 of 20 runs).
108
+ # Capture into a variable and match with a pure bash `case` — no subprocess,
109
+ # no pipe, no SIGPIPE.
110
+ FFMPEG_FILTERS="$(ffmpeg -hide_banner -filters 2>/dev/null || true)"
111
+ case "$FFMPEG_FILTERS" in
112
+ *drawtext*) HAS_DRAWTEXT=1 ;;
113
+ *) echo "compose-demo.sh: warning: this ffmpeg lacks the drawtext filter — title card and labels omitted." >&2 ;;
114
+ esac
102
115
 
103
116
  # Panel size: side-by-side splits the width into two equal panels.
104
117
  PH="$HEIGHT"
@@ -115,17 +128,19 @@ build_panel() {
115
128
  [[ -n "$end" ]] && trim_args+=(-to "$end")
116
129
  fi
117
130
  local vf="setpts=PTS/${SPEED}"
118
- vf+=",scale=${PW}:${PH}:force_original_aspect_ratio=decrease"
131
+ vf+=",scale=${PW}:${PH}:force_original_aspect_ratio=decrease:flags=lanczos"
119
132
  vf+=",pad=${PW}:${PH}:(ow-iw)/2:(oh-ih)/2:color=black"
120
133
  vf+=",fps=30,format=yuv420p,setsar=1"
121
134
  if [[ -n "$labelfile" && "$HAS_DRAWTEXT" == "1" ]]; then
122
- vf+=",drawtext=${FONTARG}:textfile=${labelfile}:x=(w-text_w)/2:y=h-th-24"
123
- vf+=":fontsize=26:fontcolor=white:box=1:boxcolor=black@0.55:boxborderw=12"
135
+ # Anchor labels to the TOP of the panel. At the bottom they're hidden behind
136
+ # the browser/GitHub video player's control bar (scrubber + play button).
137
+ vf+=",drawtext=${FONTARG}:textfile=${labelfile}:x=(w-text_w)/2:y=28"
138
+ vf+=":fontsize=34:fontcolor=white:box=1:boxcolor=black@0.55:boxborderw=14"
124
139
  fi
125
140
  # `${arr[@]+"${arr[@]}"}` expands to nothing when the array is empty without
126
141
  # tripping `set -u` (an empty `"${arr[@]}"` errors on bash < 4.4, e.g. macOS).
127
142
  ffmpeg -y -loglevel error ${trim_args[@]+"${trim_args[@]}"} -i "$input" \
128
- -vf "$vf" -an -c:v libx264 -crf 20 -preset veryfast "$outfile"
143
+ -vf "$vf" -an -c:v libx264 -crf "$CRF" -preset medium "$outfile"
129
144
  }
130
145
 
131
146
  # Per-panel labels (side-by-side) written to files to avoid drawtext escaping.
@@ -140,9 +155,16 @@ build_panel "${CLIPS[0]}" "$WORK/panel0.mp4" "$LABELFILE1" 1
140
155
  BODY="$WORK/body.mp4"
141
156
  if [[ "$LAYOUT" == "side-by-side" ]]; then
142
157
  build_panel "${CLIPS[1]}" "$WORK/panel1.mp4" "$LABELFILE2" 1
158
+ # Pad the shorter panel by freezing its last frame so both stay aligned for the
159
+ # full body. Without this, hstack lets the shorter side end early and go
160
+ # blank/black — which reads as "the before didn't render".
161
+ D0="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/panel0.mp4" || echo 0)"
162
+ D1="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/panel1.mp4" || echo 0)"
163
+ PAD0="$(awk -v a="$D0" -v b="$D1" 'BEGIN{d=b-a; if(d<0)d=0; printf "%.3f", d}')"
164
+ PAD1="$(awk -v a="$D0" -v b="$D1" 'BEGIN{d=a-b; if(d<0)d=0; printf "%.3f", d}')"
143
165
  ffmpeg -y -loglevel error -i "$WORK/panel0.mp4" -i "$WORK/panel1.mp4" \
144
- -filter_complex "[0:v][1:v]hstack=inputs=2,format=yuv420p,setsar=1[v]" \
145
- -map "[v]" -an -c:v libx264 -crf 20 -preset veryfast "$BODY"
166
+ -filter_complex "[0:v]tpad=stop_mode=clone:stop_duration=${PAD0}[p0];[1:v]tpad=stop_mode=clone:stop_duration=${PAD1}[p1];[p0][p1]hstack=inputs=2,format=yuv420p,setsar=1[v]" \
167
+ -map "[v]" -an -c:v libx264 -crf "$CRF" -preset medium "$BODY"
146
168
  else
147
169
  BODY="$WORK/panel0.mp4"
148
170
  fi
@@ -159,25 +181,14 @@ if [[ "$HAS_DRAWTEXT" == "1" ]]; then
159
181
  fi
160
182
  TITLE_VF+=",format=yuv420p"
161
183
  ffmpeg -y -loglevel error -f lavfi -i "color=c=0x101418:s=${WIDTH}x${HEIGHT}:d=${TITLE_SECS}:r=30" \
162
- -vf "$TITLE_VF" -c:v libx264 -crf 20 -preset veryfast "$WORK/title.mp4"
184
+ -vf "$TITLE_VF" -c:v libx264 -crf "$CRF" -preset medium "$WORK/title.mp4"
163
185
  TITLE_CLIP="$WORK/title.mp4"
164
186
  TITLE_DUR="$TITLE_SECS"
165
187
  fi
166
188
 
167
- # ── Stage C: (concat title +) body, two-pass size cap, faststart ─────────────
168
- BODY_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$BODY" || echo 0)"
169
- TOTAL_DUR="$(awk -v a="$TITLE_DUR" -v b="$BODY_DUR" 'BEGIN{printf "%.3f", a + b}')"
170
- # Target video bitrate (kbit/s) = target_bytes * 8 / duration / 1000, with a
171
- # small headroom factor for the muxer; floored so very short clips still encode.
172
- BR_K="$(awk -v mb="$TARGET_MB" -v d="$TOTAL_DUR" 'BEGIN{
173
- if (d <= 0) d = 1;
174
- br = (mb * 1024 * 1024 * 8) / d / 1000 * 0.92;
175
- if (br < 200) br = 200;
176
- printf "%d", br;
177
- }')"
178
-
189
+ # ── Stage C: (concat title +) body final mp4 ───────────────────────────────
179
190
  # With a title card: concat it ahead of the body. Without: pass the body
180
- # through (`null`) so the same two-pass/faststart encode applies either way.
191
+ # through (`null`) so the same encode applies either way.
181
192
  if [[ -n "$TITLE_CLIP" ]]; then
182
193
  IN_ARGS=(-i "$TITLE_CLIP" -i "$BODY")
183
194
  FILTER='[0:v][1:v]concat=n=2:v=1:a=0[v]'
@@ -185,16 +196,43 @@ else
185
196
  IN_ARGS=(-i "$BODY")
186
197
  FILTER='[0:v]null[v]'
187
198
  fi
188
- PASSLOG="$WORK/ff2pass"
189
- echo "compose-demo.sh: encoding (${TOTAL_DUR}s, target ${TARGET_MB}MB ${BR_K}kbit/s, two-pass)…" >&2
190
- ffmpeg -y -loglevel error "${IN_ARGS[@]}" \
191
- -filter_complex "$FILTER" -map "[v]" -an \
192
- -c:v libx264 -b:v "${BR_K}k" -pass 1 -passlogfile "$PASSLOG" -preset medium -f mp4 /dev/null
199
+
200
+ # Quality-first: a single-pass CRF encode (visually crisp for UI screencasts
201
+ # constant quality, not a starved average bitrate) usually lands well under the
202
+ # size cap. The old always-two-pass-ABR path starved short, near-static clips
203
+ # down to a few tens of KB; CRF spends the bits the content needs.
204
+ echo "compose-demo.sh: encoding (crf ${CRF}, preset slow)…" >&2
193
205
  ffmpeg -y -loglevel error "${IN_ARGS[@]}" \
194
206
  -filter_complex "$FILTER" -map "[v]" -an \
195
- -c:v libx264 -b:v "${BR_K}k" -pass 2 -passlogfile "$PASSLOG" -preset medium \
207
+ -c:v libx264 -crf "$CRF" -preset slow \
196
208
  -pix_fmt yuv420p -movflags +faststart "$OUTPUT"
197
209
 
210
+ # Size cap: only if the quality encode overshoots --target-size-mb do we redo it
211
+ # as a size-targeted two-pass ABR encode (keeps it under GitHub's inline limit).
212
+ OUT_BYTES="$(wc -c < "$OUTPUT" | tr -d ' ')"
213
+ CAP_BYTES="$(awk -v mb="$TARGET_MB" 'BEGIN{printf "%d", mb*1024*1024}')"
214
+ if [[ "$OUT_BYTES" -gt "$CAP_BYTES" ]]; then
215
+ BODY_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$BODY" || echo 0)"
216
+ TOTAL_DUR="$(awk -v a="$TITLE_DUR" -v b="$BODY_DUR" 'BEGIN{printf "%.3f", a + b}')"
217
+ # Target video bitrate (kbit/s) = target_bytes * 8 / duration / 1000, with a
218
+ # small headroom factor for the muxer; floored so very short clips still encode.
219
+ BR_K="$(awk -v mb="$TARGET_MB" -v d="$TOTAL_DUR" 'BEGIN{
220
+ if (d <= 0) d = 1;
221
+ br = (mb * 1024 * 1024 * 8) / d / 1000 * 0.92;
222
+ if (br < 200) br = 200;
223
+ printf "%d", br;
224
+ }')"
225
+ PASSLOG="$WORK/ff2pass"
226
+ echo "compose-demo.sh: crf encode was $(awk -v b="$OUT_BYTES" 'BEGIN{printf "%.1f", b/1024/1024}')MB > ${TARGET_MB}MB cap — re-encoding size-capped (${TOTAL_DUR}s → ${BR_K}kbit/s, two-pass)…" >&2
227
+ ffmpeg -y -loglevel error "${IN_ARGS[@]}" \
228
+ -filter_complex "$FILTER" -map "[v]" -an \
229
+ -c:v libx264 -b:v "${BR_K}k" -pass 1 -passlogfile "$PASSLOG" -preset medium -f mp4 /dev/null
230
+ ffmpeg -y -loglevel error "${IN_ARGS[@]}" \
231
+ -filter_complex "$FILTER" -map "[v]" -an \
232
+ -c:v libx264 -b:v "${BR_K}k" -pass 2 -passlogfile "$PASSLOG" -preset medium \
233
+ -pix_fmt yuv420p -movflags +faststart "$OUTPUT"
234
+ fi
235
+
198
236
  # ── Stage D: report ───────────────────────────────────────────────────────────
199
237
  RES="$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "$OUTPUT" || echo '?')"
200
238
  DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT" || echo '?')"
@@ -0,0 +1,41 @@
1
+ kind: agent
2
+ name: bash-smoke
3
+ description: "Smoke test for deterministic bash + script phases (run in sandbox)"
4
+ phases:
5
+ - name: phase_0
6
+ type: context
7
+
8
+ # Deterministic shell command in the sandbox. stdout is captured and exposed
9
+ # downstream via output_var.
10
+ - name: emit
11
+ type: bash
12
+ command: "echo hello-from-bash"
13
+ output_var: greeting
14
+
15
+ # A second bash phase reads the first phase's output via template substitution
16
+ # AND via the forwarded LL_OUT_<PHASE> env var.
17
+ - name: consume
18
+ type: bash
19
+ command: "echo template={{phaseOutputs.emit}} ; echo env=$LL_OUT_EMIT"
20
+
21
+ # Inline JS via node.
22
+ - name: js
23
+ type: script
24
+ runtime: js
25
+ script: |
26
+ console.log("node version:", process.version);
27
+ console.log("answer:", 6 * 7);
28
+
29
+ # Inline Python via uv. The PEP 723 block declares a dependency that uv
30
+ # resolves from PyPI (pypi.org is on the strict egress allowlist) into a
31
+ # cached venv. unrestricted_egress is NOT needed for PyPI.
32
+ - name: py
33
+ type: script
34
+ runtime: python
35
+ timeout_seconds: 120
36
+ script: |
37
+ # /// script
38
+ # dependencies = ["cowsay"]
39
+ # ///
40
+ import cowsay
41
+ cowsay.cow("hello from uv")
@@ -44,11 +44,33 @@ so `doctor` should pass.
44
44
 
45
45
  ## Workspace + run the app
46
46
 
47
- You are already inside the **{{repo}}** repo at branch {{branch}} the harness
48
- pre-cloned it and your cwd is the repo root (no `cd`). Follow the `building`
49
- skill to install dependencies and start the dev-server in the background; poll
50
- with `curl` until it answers on `localhost:<port>`. For a before/after demo,
51
- run each branch in turn and record the same scripted interaction against each.
47
+ You are already inside the **{{repo}}** repo the harness pre-cloned it and your
48
+ cwd is the repo root (no `cd`){{#if baseBranch}}, **checked out at the PR head
49
+ branch `{{branch}}`**, so the workspace already holds the PR's code: this checkout
50
+ *is* your "after" state{{/if}}. Follow the `building` skill to install
51
+ dependencies and start the dev-server in the background; poll with `curl` until
52
+ it answers on `localhost:<port>`.
53
+
54
+ {{#if baseBranch}}**This is a PR → record a `side-by-side` before/after.** Record
55
+ the **after** first, straight from the current checkout (`{{branch}}`) — do not
56
+ re-checkout for it. Then record the **before** from the base branch
57
+ `{{baseBranch}}`. The pre-clone is shallow + single-branch, so the base isn't
58
+ present locally yet — fetch it explicitly by ref (never trust an ambiguously
59
+ named local branch):
60
+
61
+ ```
62
+ git fetch --depth 1 origin {{baseBranch}}
63
+ git checkout -B {{baseBranch}}-base FETCH_HEAD
64
+ ```
65
+
66
+ Re-run `building`'s install + restart the dev-server after switching. When you
67
+ composite, the **first** clip is the left/BEFORE panel and the **second** is the
68
+ right/AFTER panel.
69
+
70
+ **Verify before you ship:** the disambiguating change must actually appear in the
71
+ *after* recording and be absent from the *before* (use the driver's `text`/
72
+ `assertText` to read it on both). If both states look identical, your checkout is
73
+ wrong — STOP and fix it; do not ship a side-by-side that proves nothing.{{/if}}
52
74
 
53
75
  ## Capture and compose
54
76
 
@@ -76,7 +76,7 @@ phases:
76
76
  # workflow-level `final_message`. Pure composition — no skills, no repo work.
77
77
  - name: synthesize
78
78
  label: Summary
79
- depends_on: [qa_test]
79
+ depends_on: [qa_test, qa_browser]
80
80
  prompt: prompts/qa-synth.md
81
81
  model: "{{models.qa-test}}"
82
82
  variant: "{{variants.qa-test}}"
@@ -74,7 +74,7 @@ phases:
74
74
  # workflow-level `final_message`. Pure composition — no skills, no repo work.
75
75
  - name: synthesize
76
76
  label: Summary
77
- depends_on: [verify]
77
+ depends_on: [verify, verify_browser]
78
78
  prompt: prompts/verify-synth.md
79
79
  model: "{{models.verify}}"
80
80
  variant: "{{variants.verify}}"