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.
- package/.claude-plugin/marketplace.json +14 -0
- package/README.md +56 -6
- package/agent-context/rules.md +0 -38
- package/agent-context/soul.md +0 -11
- package/deploy/entrypoint.sh +21 -6
- package/deploy/sandbox-entrypoint.sh +1 -1
- package/dist/cli.js +24 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +13 -2
- package/dist/config.js +29 -9
- package/dist/config.js.map +1 -1
- package/dist/connectors/messaging/base.js +22 -9
- package/dist/connectors/messaging/base.js.map +1 -1
- package/dist/connectors/slack/connector.d.ts +62 -9
- package/dist/connectors/slack/connector.js +216 -63
- package/dist/connectors/slack/connector.js.map +1 -1
- package/dist/connectors/slack/connector.test.d.ts +1 -0
- package/dist/connectors/slack/connector.test.js +135 -0
- package/dist/connectors/slack/connector.test.js.map +1 -0
- package/dist/connectors/types.d.ts +7 -0
- package/dist/engine/agent-executor.d.ts +29 -144
- package/dist/engine/agent-executor.js +280 -962
- package/dist/engine/agent-executor.js.map +1 -1
- package/dist/engine/agent-executor.seam.test.d.ts +1 -0
- package/dist/engine/agent-executor.seam.test.js +57 -0
- package/dist/engine/agent-executor.seam.test.js.map +1 -0
- package/dist/engine/dispatcher.d.ts +13 -0
- package/dist/engine/dispatcher.js +26 -7
- package/dist/engine/dispatcher.js.map +1 -1
- package/dist/engine/dispatcher.test.js.map +1 -1
- package/dist/engine/executors/backends.d.ts +41 -0
- package/dist/engine/executors/backends.js +541 -0
- package/dist/engine/executors/backends.js.map +1 -0
- package/dist/engine/executors/shared.d.ts +189 -0
- package/dist/engine/executors/shared.js +612 -0
- package/dist/engine/executors/shared.js.map +1 -0
- package/dist/engine/message-batcher.d.ts +32 -0
- package/dist/engine/message-batcher.js +109 -0
- package/dist/engine/message-batcher.js.map +1 -0
- package/dist/engine/message-batcher.test.d.ts +1 -0
- package/dist/engine/message-batcher.test.js +109 -0
- package/dist/engine/message-batcher.test.js.map +1 -0
- package/dist/engine/profiles.d.ts +9 -0
- package/dist/engine/profiles.js.map +1 -1
- package/dist/evals-api.d.ts +24 -0
- package/dist/evals-api.js +22 -0
- package/dist/evals-api.js.map +1 -0
- package/dist/index.js +41 -4
- package/dist/index.js.map +1 -1
- package/dist/sandbox/command-exec.integration.test.d.ts +1 -0
- package/dist/sandbox/command-exec.integration.test.js +183 -0
- package/dist/sandbox/command-exec.integration.test.js.map +1 -0
- package/dist/sandbox/docker.d.ts +32 -0
- package/dist/sandbox/docker.js +109 -4
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/smol.d.ts +162 -0
- package/dist/sandbox/smol.integration.test.d.ts +1 -0
- package/dist/sandbox/smol.integration.test.js +130 -0
- package/dist/sandbox/smol.integration.test.js.map +1 -0
- package/dist/sandbox/smol.js +485 -0
- package/dist/sandbox/smol.js.map +1 -0
- package/dist/skills-install.d.ts +12 -0
- package/dist/skills-install.js +179 -0
- package/dist/skills-install.js.map +1 -0
- package/dist/workflows/loader.test.js +83 -0
- package/dist/workflows/loader.test.js.map +1 -1
- package/dist/workflows/phase-executor.d.ts +28 -3
- package/dist/workflows/phase-executor.js +170 -45
- package/dist/workflows/phase-executor.js.map +1 -1
- package/dist/workflows/phase-executor.test.js +64 -1
- package/dist/workflows/phase-executor.test.js.map +1 -1
- package/dist/workflows/runner.test.js +32 -27
- package/dist/workflows/runner.test.js.map +1 -1
- package/dist/workflows/schema.d.ts +20 -0
- package/dist/workflows/schema.js +72 -2
- package/dist/workflows/schema.js.map +1 -1
- package/package.json +15 -3
- package/plugins/lastlight/.claude-plugin/plugin.json +12 -0
- package/plugins/lastlight/README.md +44 -0
- package/plugins/lastlight/skills/lastlight-client/SKILL.md +82 -0
- package/plugins/lastlight/skills/lastlight-evals/SKILL.md +97 -0
- package/plugins/lastlight/skills/lastlight-evals/references/instance-schema.md +84 -0
- package/plugins/lastlight/skills/lastlight-evals/references/models-json.md +42 -0
- package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +72 -0
- package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +55 -0
- package/plugins/lastlight/skills/lastlight-overlay/references/overlay-layout.md +52 -0
- package/plugins/lastlight/skills/lastlight-server/SKILL.md +124 -0
- package/plugins/lastlight/skills/lastlight-server/references/env-schema.md +103 -0
- package/plugins/lastlight/skills/lastlight-server/references/operations.md +60 -0
- package/sandbox.Dockerfile +7 -0
- package/skills/browser-qa/scripts/agent-browser.mjs +15 -0
- package/skills/demo/SKILL.md +45 -19
- package/skills/demo/scripts/compose-demo.sh +77 -39
- package/workflows/examples/bash-smoke.yaml +41 -0
- package/workflows/prompts/demo.md +27 -5
- package/workflows/qa-test.yaml +1 -1
- 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
|
+
```
|
package/sandbox.Dockerfile
CHANGED
|
@@ -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) {
|
package/skills/demo/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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), `--
|
|
132
|
-
`--
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`
|
|
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
|
|
142
|
-
- **Duration** is sane — not 0s, within a watchable band (~15–90s).
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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.
|
|
21
|
-
#
|
|
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="
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 -
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
package/workflows/qa-test.yaml
CHANGED
|
@@ -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}}"
|
package/workflows/verify.yaml
CHANGED
|
@@ -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}}"
|