demobite 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,8 @@ When your UI changes, **Retake** can refilm an existing Bite from its saved reci
130
130
  npx demobite retake <biteId> --note "Export moved to the header"
131
131
  ```
132
132
 
133
+ **Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. A delivered take becomes a Bite in DemoBites by itself; there is no second review click for these takes. Nothing is published or shared.
134
+
133
135
  The package also includes a DemoBites management MCP for releases and centers:
134
136
 
135
137
  ```bash
@@ -156,6 +156,21 @@ if (arg === "retake") {
156
156
  process.exit(r.status ?? 1);
157
157
  }
158
158
 
159
+ // Batch of briefs (2026-09-13): `npx demobite briefs list <batchId>` etc. and
160
+ // `npx demobite status <takeDir|stagingId>` hand straight to the skill scripts.
161
+ if (arg === "briefs" || arg === "status") {
162
+ let cfg = readCfg();
163
+ if (!cfg?.api_key) {
164
+ console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
165
+ const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
166
+ if (r.status !== 0) process.exit(r.status ?? 1);
167
+ cfg = readCfg();
168
+ }
169
+ if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
170
+ const r = spawnSync("node", [path.join(dest, "scripts", `${arg}.mjs`), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
171
+ process.exit(r.status ?? 1);
172
+ }
173
+
159
174
  if (arg === "mcp") {
160
175
  let cfg = readCfg();
161
176
  if (!cfg?.api_key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
package/skill/SKILL.md CHANGED
@@ -9,7 +9,7 @@ You are the camera operator, the director, and the editor. You film a real brows
9
9
 
10
10
  All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+. `npx demobite` installs Playwright, ffmpeg and ffprobe beside the skill; every script resolves the media tools through `scripts/media-tools.mjs` (a compatible system build first, then the packaged one). Never call `ffmpeg` or `ffprobe` by bare name in a new script. Run every script from the project directory so `.recorder/` lands next to the project.
11
11
 
12
- Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
12
+ Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word. For a batch of briefs the word was given twice already, on the batch and on each storyboard: a delivered take becomes a bite by itself (see Batch of briefs).
13
13
 
14
14
  ## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
15
15
 
@@ -82,6 +82,29 @@ When a page you need shows a login wall (login form, auth redirect, checkpoint p
82
82
 
83
83
  Write the storyboard as JSON before touching the camera.
84
84
 
85
+ ### Phase 3a: Harvest the product's vocabulary FIRST
86
+
87
+ The demo speaks the product's CURRENT words, never the brief's, never the pull request's, never your memory's. Products get renamed between the moment a brief is written and the moment you film (founder, 2026-09-14: a take said "Release Readiness" while the app's rail said "Assignments").
88
+
89
+ ```bash
90
+ node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
91
+ ```
92
+
93
+ It opens each screen on the recorder profile, without video, hovers the rail so tooltips render, and writes `<takeDir>/vocab.json`: nav labels with their tooltips and aria labels, page headings, button and link labels, dialog titles. **Every noun in `narration` and `on_screen` must appear in vocab.json.** The brief's and the PR's words are hints about WHAT changed and where to look; when a brief's noun is missing from the app, say so in the storyboard presentation ("the brief says allowlist, the app says Who can enter") and use the app's word.
94
+
95
+ ### LAW: the camera shows an action to its end
96
+
97
+ The agent sits on the running product with a signed-in account. It knows the flow. It performs it. A take that walks into an empty page and narrates "if there were something here" is forbidden; so is "here you would see" (founder, 2026-09-14).
98
+
99
+ - **a. Reversible actions are performed for real.** Create the briefing, add the bites, create the assignment with safe people, move the zoom, press Save. Reversible means you can return the workspace to its prior state after the cut.
100
+ - **b. Every take returns the workspace to its initial state, after the camera stops.** The storyboard declares the plan in `cleanup[]` (steps, same schema, run headless by `cleanup.mjs` after `record.mjs`, before `upload.mjs`) and in `cleanup_plan[]` (plain sentences the human reads: "After the cut: delete briefing X, remove assignment Y"). `checks.after[]` proves it (expect / absent selectors). A step you cannot revert is not performed.
101
+ - **c. Irreversible actions are not performed.** An export that spends minutes, an email to real people, a payment, a publish to a real customer's live page, deleting existing content. The cursor goes to the control, the narration names what it does, the button is not pressed. That is the 99 percent rule: bring the viewer to the last click and name it. Mark these beats in the storyboard with `"pointed": true` and in the presentation with "pointed at, not pressed".
102
+ - **d. Cancel is never a beat.** Never say "we cancel because this is a demo", never zoom on a Cancel button, never make the escape a scene. A dialog that must close without committing closes through the X, the backdrop or Escape, off narration, without a zoom, in the gap between beats. When the dialog's confirm IS reversible, press it (rule a).
103
+ - **e. Empty states are a failure of preparation, not a scene.** If the flow needs data, `prep[]` creates it before the camera (`node scripts/cleanup.mjs <takeDir> --prep`, checked by `checks.before[]`), the take shows the flow, `cleanup[]` removes it.
104
+ - **f. Never present a screen you did not reach.**
105
+
106
+ `upload.mjs` refuses to stage a take whose storyboard declares `cleanup[]` until `cleanup.json` says the cleanup ran and its checks passed (`--allow-uncleaned` overrides, and prints that it did).
107
+
85
108
  ### LAW: the video is the metronome, not the script
86
109
 
87
110
  **The narration is INTENT, never final copy.** In the DemoBites ending it is handed to the ingestion, which rescripts it and refits it to the video exactly as it does for a customer's own uploaded voice. So never stretch a shot to cover a sentence. A shot is as long as the ACTION needs, and the words get fitted to it afterwards.
@@ -132,6 +155,34 @@ Storyboard schema:
132
155
 
133
156
  Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
134
157
 
158
+ Beyond `steps`, a storyboard may carry the off-camera blocks (Phase 3a/law above); `cleanup.mjs` runs them on the same profile without video:
159
+
160
+ ```json
161
+ {
162
+ "prep": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Create')", "after": 2000 } ],
163
+ "checks": { "before": [ { "action": "expect", "selector": "text=Demo briefing" } ], "after": [ { "action": "absent", "selector": "text=Demo briefing" } ] },
164
+ "cleanup": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Delete')", "after": 1500 } ],
165
+ "cleanup_plan": [ "After the cut: delete the briefing 'Demo briefing' created for this take.", "After the cut: remove the assignment for demo@acme.com." ]
166
+ }
167
+ ```
168
+
169
+ Off-camera steps add `press` (`"key": "Escape"`), `wait` (`"ms"`), `expect` and `absent` (checks).
170
+
171
+ A real cleanup, taken from a filmed take (DemoBites, the Enablement Center list): the row menu is a button with `title="Briefing options"`, Delete opens a dialog that asks the name to be typed, then "Permanently Delete".
172
+
173
+ ```json
174
+ "cleanup": [
175
+ { "action": "goto", "url": "https://app.demobites.com/enablement-center", "after": 4000 },
176
+ { "action": "click", "selector": "div.group:has-text('Onboarding briefing (demo)') button[title='Briefing options']", "after": 800 },
177
+ { "action": "click", "selector": "[role='menu'] [role='menuitem']:has-text('Delete')", "after": 1200 },
178
+ { "action": "type", "selector": "[role='dialog'] input", "text": "Onboarding briefing (demo)" },
179
+ { "action": "click", "selector": "[role='dialog'] button:has-text('Permanently Delete')", "after": 4000 }
180
+ ],
181
+ "checks": { "after": [ { "action": "absent", "selector": "text=Onboarding briefing (demo)" } ] }
182
+ ```
183
+
184
+ A step with `"required": false` may fail without stopping the rest. A camera step with `"pointed": true` is an irreversible action the cursor reaches and names but never presses (law c).
185
+
135
186
  Two fields carry the whole advantage of this lane, so fill them in:
136
187
 
137
188
  - **`on_screen`** describes what the viewer is looking at during the beat. It rides into the ingestion's rescripting stage, so the model writes narration while KNOWING the cursor is on the degree badge and the menu just opened. A microphone can never supply this. Write it for every narrated beat.
@@ -141,6 +192,8 @@ Use `hideCss` for chat widgets and cookie banners that would pollute the picture
141
192
 
142
193
  **Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
143
194
 
195
+ The presentation has three blocks, always: the shot list (irreversible beats marked "pointed at, not pressed"), **"Before the camera"** (what `prep[]` creates) and **"After the cut"** (the `cleanup_plan[]` sentences). A storyboard whose flow needs data and has no prep, or creates anything and has no cleanup plan, is not ready to show.
196
+
144
197
  ## LAW: bot walls — one human checkpoint, never a disguise
145
198
 
146
199
  Some sites challenge automated browsers. The protocol, in order, no
@@ -192,7 +245,9 @@ Only come back to the human when a PRODUCT question remains that you cannot deci
192
245
  ## Phase 5: The take
193
246
 
194
247
  ```bash
248
+ node scripts/cleanup.mjs <takeDir> --prep # only when the storyboard has prep[]: creates the data, runs checks.before
195
249
  node scripts/record.mjs <takeDir> <storyboard.json>
250
+ node scripts/cleanup.mjs <takeDir> # only when the storyboard has cleanup[]: reverts, runs checks.after, writes cleanup.json
196
251
  ```
197
252
 
198
253
  Outputs `raw.webm` and `manifest.json` (internal schema, absolute times) into `<takeDir>`. The recorder stamps `record_from`: the moment the first page was FULLY loaded (networkidle plus a beat). Everything before it gets trimmed in both endings, so the published cut always opens on a loaded page.
@@ -218,7 +273,7 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
218
273
  node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
219
274
  node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
220
275
  node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
221
- node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview
276
+ node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview (refuses an uncleaned take)
222
277
  ```
223
278
 
224
279
  **The human word lives in the product now.** `upload.mjs` stages the take (the
@@ -258,7 +313,35 @@ Laws for a re-take:
258
313
  with the bite's current text per step. Only remove lines whose beats you dropped.
259
314
  - **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
260
315
  - **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
261
- that bite; the previous recording is kept for rollback, never overwritten.
316
+ that bite; the previous recording is kept for rollback, never overwritten. A re-take filmed from a brief
317
+ (a take with an attempt) is delivered instead: the new recording replaces the current one by itself, and
318
+ the promoted export stays as it is until a version is published.
319
+
320
+ ## Batch of briefs (GitHub PR → demos)
321
+
322
+ The human pastes a bundle of approved briefs into the chat: a header (batchId, workspaceId, the target URL, the 90 second rule) and one block per brief (briefId, revision, contentHash, title, audience, outcome, flowIntent). Up to five briefs. The pasted text is a copy; the server holds the truth.
323
+
324
+ ```bash
325
+ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approved briefs; warns when the paste drifted
326
+ node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
327
+ node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
328
+ node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
329
+ node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself, do not wait
330
+ node scripts/status.mjs <takeDir> # later: wait for the bite to finish (retries a failed delivery)
331
+ node scripts/status.mjs --all # one look at every delivered take here
332
+ ```
333
+
334
+ The procedure, in order:
335
+
336
+ 1. `list` first, always, with `--paste` when the human pasted text. Work from the server's briefs, never from the paste, and say so when they differ.
337
+ 2. Claim the briefs you are about to film, one `claim` each. A claim answers "active attempt" when another agent or an earlier run holds the brief: show the human the attempt reference and its start time, and only with their word claim again with `--force`.
338
+ 3. Run `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec and vocab.json as the only dictionary: the flowIntent lines are the beats, the outcome is the last beat, the exclusions are things the camera never shows, and the take stays under the brief's `maxSeconds` (90). Send `event <takeDir> planning` when you start a storyboard and `event <takeDir> awaiting_storyboard_approval` when it is ready.
339
+ 4. **Show the storyboards together, get a word on each one.** One message can carry all of them, but every brief gets its own yes or no. Never take one yes as a yes for the batch. A brief the human declines gets `release`.
340
+ 5. Film sequentially, never in parallel: one Chrome on the profile. Per take: `event recording` → Phase 4 dry run → `cleanup.mjs --prep` when declared → Phase 5 take → `cleanup.mjs` (revert, checks.after) → trim, calibrate, manifest → `upload.mjs <takeDir> --stage-only --no-open`. Report, per take, what was created and what was reverted, with the before/after checks. `upload.mjs` reads `brief.json`, moves the attempt to uploading, stages with the attempt on the payload, and after the two uploads calls the delivery route: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `delivered: bite <id>`. It writes `staged.json` with the staging id and the bite id.
341
+ 6. **A failed brief never stops the others.** On a failure send `event <takeDir> failed --note "<what happened>"`, keep the take directory for diagnosis, and continue with the next brief. Report every failure plainly at the end.
342
+ 7. When all takes are delivered, tell the human: N takes were delivered and are becoming bites in DemoBites by themselves, with the bite ids. `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed). A take the server would not deliver (upload.mjs printed the error) waits in the review queue; `status.mjs <takeDir>` tries the delivery again, and on an older DemoBites waits for the word in the app as before. Deliver each take; never publish, never share, never send invitations.
343
+
344
+ Resume after an interruption from what is on disk and on the server: a `take-*` directory with `brief.json` is claimed; with `raw.webm` it was filmed; with `clean.mp4` and `manifest.demobites.json` it is ready to stage; with `staged.json` it is delivered or staged (check it with `status.mjs --no-wait`). `list` shows the server's view of every attempt. Never re-claim a brief that already has your own live attempt; never re-stage one that `staged.json` says is delivered or staged unless the human asked for a new take (`upload.mjs --supersede`).
262
345
 
263
346
  ## The wire manifest (fixed contract, version 2)
264
347
 
@@ -313,7 +396,12 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
313
396
  -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
314
397
 
315
398
  GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
316
- -> { status: 'pending'|'approving'|'approved'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
399
+ -> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
400
+
401
+ PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: brief takes only, after both uploads
402
+ {} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded"); this path is the fallback
403
+ -> 200 { biteId, videoId } | 202 { biteId, queued:true } | 200 { pending:true } (older server: wait for the word) | 404/409 { error }
404
+ // idempotent: a repeat returns the same bite
317
405
 
318
406
  GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
319
407
  -> { status, title, durationSec, narrationReady, narrationTotal, zooms } (STARTS the pipeline, not done)
@@ -328,5 +416,5 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
328
416
 
329
417
  - Anything the human sees (storyboard presentation, review page, questions) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
330
418
  - Never touch credentials. Never print the api_key. Config and key files are chmod 600.
331
- - Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page.
332
- - One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on.
419
+ - Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page. For a batch of briefs the word was given on the batch and on each storyboard, and delivery ingests by itself. Never publish, never share, never send invitations.
420
+ - One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on. A take claimed from a brief is `take-<briefId>-r<revision>`.
@@ -0,0 +1,166 @@
1
+ // Batch of briefs (GitHub PR → demos, 2026-09-13). The human pastes a bundle
2
+ // of approved briefs into the agent; this script is the agent's hands:
3
+ //
4
+ // node briefs.mjs list <batchId> [--paste <file>] the authenticated truth for the batch (warns when a pasted bundle drifted)
5
+ // node briefs.mjs claim <batchId> <briefId> [--force] claim one brief → mints an attempt, creates take-<briefId>-r<revision>/brief.json
6
+ // node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
7
+ // planning | awaiting_storyboard_approval | recording | uploading | failed | cancelled
8
+ // node briefs.mjs release <takeDir|attemptRef> [--note "..."] = event cancelled (give the brief back)
9
+ //
10
+ // Server contract (dbrec_ key, scope record):
11
+ // GET /api/recorder/briefs?batch=<batchId>
12
+ // PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
13
+ // PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
14
+ // The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json, and
15
+ // after the uploads calls the delivery route from the claim's `api.uploaded`
16
+ // (1.3.0): a take filmed from a brief becomes a bite by itself.
17
+ //
18
+ // Laws: one storyboard approval per brief, never one word for the batch.
19
+ // Sequential takes, one Chrome on the profile. A failed brief never stops the
20
+ // others. Never print the api_key.
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import crypto from "node:crypto";
24
+
25
+ const [, , cmd, ...rest] = process.argv;
26
+ const flag = (name) => rest.includes(name);
27
+ const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
28
+ const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note"].includes(rest[i - 1])));
29
+
30
+ function usage(code = 2) {
31
+ console.error(`Usage:
32
+ node briefs.mjs list <batchId> [--paste <file>]
33
+ node briefs.mjs claim <batchId> <briefId> [--force]
34
+ node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
35
+ node briefs.mjs release <takeDir|attemptRef> [--note "..."]`);
36
+ process.exit(code);
37
+ }
38
+ if (!cmd || !["list", "claim", "event", "release"].includes(cmd)) usage();
39
+
40
+ const cfgPath = path.resolve(".recorder", "config.json");
41
+ let cfg = {};
42
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
43
+ if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
44
+ const base = cfg.base.replace(/\/+$/, "");
45
+ const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
46
+
47
+ async function api(method, p, body) {
48
+ let res;
49
+ try {
50
+ res = await fetch(`${base}${p}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
51
+ } catch (e) { console.error(`DemoBites unreachable (${e.message}).`); process.exit(1); }
52
+ const json = await res.json().catch(() => null);
53
+ if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
54
+ return { status: res.status, ok: res.ok, json };
55
+ }
56
+
57
+ export function takeDirFor(briefId, revision) {
58
+ const safe = String(briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
59
+ return `take-${safe}-r${revision}`;
60
+ }
61
+
62
+ /** The pasted bundle: one block per brief with briefId / revision / contentHash lines. */
63
+ function parsePaste(text) {
64
+ const out = new Map();
65
+ const blocks = text.split(/\n(?=\s*briefId\s*[:=])/i);
66
+ for (const b of blocks) {
67
+ const id = b.match(/briefId\s*[:=]\s*([A-Za-z0-9._-]+)/i)?.[1];
68
+ if (!id) continue;
69
+ const revision = b.match(/revision\s*[:=]\s*(\d+)/i)?.[1];
70
+ const hash = b.match(/contentHash\s*[:=]\s*([A-Za-z0-9:_-]+)/i)?.[1];
71
+ out.set(id, { revision: revision !== undefined ? Number(revision) : null, contentHash: hash ?? null });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function printBatch(data) {
77
+ const { batch, briefs } = data;
78
+ const src = batch?.source ? `${batch.source.repo}#${batch.source.prNumber}` : "";
79
+ console.log(`Batch ${batch?.id ?? "?"} ${src} target ${batch?.target?.url ?? "?"}${batch?.target?.environment ? ` (${batch.target.environment})` : ""}`);
80
+ console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most 90 seconds. One storyboard approval per brief.`);
81
+ for (const b of briefs) {
82
+ const att = b.attempt ? ` · attempt ${b.attempt.ref} (${b.attempt.state})` : "";
83
+ console.log(`\n ${b.briefId} r${b.revision} ${b.status}${att}\n ${b.title}\n audience: ${b.audience}\n outcome: ${b.outcome}${b.estimatedDurationSec ? `\n about ${b.estimatedDurationSec}s` : ""}`);
84
+ for (const f of b.flowIntent ?? []) console.log(` · ${f}`);
85
+ if (b.prerequisites?.length) console.log(` needs: ${b.prerequisites.join("; ")}`);
86
+ if (b.exclusions?.length) console.log(` never: ${b.exclusions.join("; ")}`);
87
+ }
88
+ }
89
+
90
+ async function fetchBatch(batchId) {
91
+ const r = await api("GET", `/api/recorder/briefs?batch=${encodeURIComponent(batchId)}`);
92
+ if (r.status === 403 && r.json?.error === "workspace_mismatch") {
93
+ console.error("This key's workspace is not the batch's workspace. Log in to the right workspace (node scripts/login.mjs) and try again.");
94
+ process.exit(1);
95
+ }
96
+ if (!r.ok || !r.json?.briefs) { console.error(`Could not read batch ${batchId}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
97
+ return r.json;
98
+ }
99
+
100
+ if (cmd === "list") {
101
+ const batchId = positional[0];
102
+ if (!batchId) usage();
103
+ const data = await fetchBatch(batchId);
104
+ printBatch(data);
105
+ const pasteFile = opt("--paste");
106
+ if (pasteFile) {
107
+ let text = "";
108
+ try { text = fs.readFileSync(pasteFile, "utf8"); } catch (e) { console.error(`--paste ${pasteFile}: ${e.message}`); process.exit(2); }
109
+ const pasted = parsePaste(text);
110
+ let drift = 0;
111
+ for (const b of data.briefs) {
112
+ const p = pasted.get(String(b.briefId));
113
+ if (!p) { console.error(`\nWARNING: brief ${b.briefId} is in the batch but not in the pasted text.`); drift++; continue; }
114
+ if (p.contentHash && p.contentHash !== b.contentHash) { console.error(`\nWARNING: brief ${b.briefId}: the pasted contentHash differs from the approved revision r${b.revision}. Work from the approved text above, not the paste.`); drift++; }
115
+ if (p.revision !== null && p.revision !== b.revision) { console.error(`\nWARNING: brief ${b.briefId}: pasted revision r${p.revision}, approved revision r${b.revision}.`); drift++; }
116
+ }
117
+ for (const id of pasted.keys()) if (!data.briefs.some((b) => String(b.briefId) === id)) { console.error(`\nWARNING: pasted brief ${id} is not in batch ${batchId}.`); drift++; }
118
+ if (drift === 0) console.log("\nThe pasted bundle matches the approved briefs.");
119
+ else process.exitCode = 3;
120
+ }
121
+ }
122
+
123
+ if (cmd === "claim") {
124
+ const [batchId, briefId] = positional;
125
+ if (!batchId || !briefId) usage();
126
+ const data = await fetchBatch(batchId);
127
+ const brief = data.briefs.find((b) => String(b.briefId) === String(briefId));
128
+ if (!brief) { console.error(`Brief ${briefId} is not in batch ${batchId}.`); process.exit(1); }
129
+ const idempotencyKey = crypto.createHash("sha256").update(`${batchId}:${brief.briefId}:${brief.revision}:${brief.contentHash}`).digest("hex").slice(0, 32);
130
+ const body = { briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, idempotencyKey, ...(flag("--force") ? { force: true } : {}) };
131
+ const r = await api("PUT", "/api/recorder/briefs/claim", body);
132
+ if (r.status === 409 && r.json?.error === "active_attempt") {
133
+ const a = r.json.active_attempt ?? r.json;
134
+ console.error(`Brief ${briefId} already has a live attempt (${a.attemptRef ?? "?"}${a.since ? `, since ${a.since}` : ""}). Show this to the human; with their word, claim again with --force to supersede it.`);
135
+ process.exit(1);
136
+ }
137
+ if (r.status === 409 && r.json?.error === "hash_mismatch") { console.error(`Brief ${briefId}: the content hash does not match the approved revision. Run list again and work from the approved text.`); process.exit(1); }
138
+ if (r.status === 410) { console.error(`Brief ${briefId} r${brief.revision} was superseded by a newer revision. Run list again.`); process.exit(1); }
139
+ if (!r.ok || !r.json?.attemptRef) { console.error(`Claim failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
140
+ const dir = takeDirFor(brief.briefId, brief.revision);
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ const record = {
143
+ batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
144
+ brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
145
+ source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null, claimedAt: new Date().toISOString(),
146
+ };
147
+ fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
148
+ console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
149
+ console.log(`Title: ${record.brief.title}\nTarget: ${record.target?.url ?? "?"}`);
150
+ }
151
+
152
+ if (cmd === "event" || cmd === "release") {
153
+ const ref = positional[0];
154
+ const event = cmd === "release" ? "cancelled" : positional[1];
155
+ const EVENTS = ["planning", "awaiting_storyboard_approval", "recording", "uploading", "failed", "cancelled"];
156
+ if (!ref || !EVENTS.includes(event)) usage();
157
+ let attemptRef = ref;
158
+ if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
159
+ try { attemptRef = JSON.parse(fs.readFileSync(path.join(ref, "brief.json"), "utf8")).attemptRef; } catch { console.error(`${ref}/brief.json not found or unreadable.`); process.exit(1); }
160
+ }
161
+ const note = opt("--note");
162
+ const r = await api("PUT", `/api/recorder/briefs/attempts/${encodeURIComponent(attemptRef)}`, { event, ...(note ? { note: note.slice(0, 600) } : {}) });
163
+ if (r.status === 409) { console.error(`Event "${event}" refused for ${attemptRef}: ${r.json?.message ?? r.json?.error ?? "state does not allow it"} (current: ${r.json?.state ?? "?"}).`); process.exit(1); }
164
+ if (!r.ok) { console.error(`Event failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
165
+ console.log(`${attemptRef}: ${r.json?.state ?? event}`);
166
+ }
@@ -0,0 +1,104 @@
1
+ // Prep and cleanup around a take, off camera (law: the camera shows an action
2
+ // to its end, and every take returns the workspace to its initial state).
3
+ //
4
+ // node cleanup.mjs <takeDir> --prep run storyboard.prep[] then storyboard.checks.before[]
5
+ // node cleanup.mjs <takeDir> run storyboard.cleanup[] then storyboard.checks.after[]
6
+ //
7
+ // Steps use the storyboard's own step schema, no video, headless on the
8
+ // recorder profile: goto | click | type | press | hover | wait | expect | absent.
9
+ // { "action": "press", "key": "Escape" } close a dialog through the keyboard
10
+ // { "action": "wait", "ms": 1500 }
11
+ // { "action": "expect", "selector": "text=Foo" } must be visible (a check)
12
+ // { "action": "absent", "selector": "text=Foo" } must NOT be visible (a check)
13
+ // `checks.before` runs after prep, `checks.after` runs after cleanup; both are
14
+ // lists of expect/absent steps. Writes <takeDir>/prep.json or cleanup.json
15
+ // { ran, checks, ok, at }. upload.mjs refuses to stage a take whose storyboard
16
+ // declares cleanup[] until cleanup.json says ok:true (--allow-uncleaned to
17
+ // override, loudly).
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { chromium } from "playwright";
21
+
22
+ const dir = process.argv[2];
23
+ const prep = process.argv.includes("--prep");
24
+ if (!dir) { console.error("Usage: node cleanup.mjs <takeDir> [--prep]"); process.exit(2); }
25
+ const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8"));
26
+ const steps = prep ? (sb.prep ?? []) : (sb.cleanup ?? []);
27
+ const checks = prep ? (sb.checks?.before ?? []) : (sb.checks?.after ?? []);
28
+ const outName = prep ? "prep.json" : "cleanup.json";
29
+ if (steps.length === 0 && checks.length === 0) {
30
+ console.log(`${prep ? "prep" : "cleanup"}: nothing declared in storyboard.json`);
31
+ fs.writeFileSync(path.join(dir, outName), JSON.stringify({ ran: [], checks: [], ok: true, nothingDeclared: true, at: new Date().toISOString() }, null, 2) + "\n");
32
+ process.exit(0);
33
+ }
34
+
35
+ const profileDir = path.resolve(".recorder", "profile");
36
+ const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless: true, viewport: { width: 1920, height: 1080 } }).catch(async () =>
37
+ chromium.launchPersistentContext(profileDir, { headless: true, viewport: { width: 1920, height: 1080 } }),
38
+ );
39
+ const page = await ctx.newPage();
40
+ if (sb.hideCss) page.on("load", () => page.addStyleTag({ content: sb.hideCss }).catch(() => {}));
41
+
42
+ async function visibleTarget(selector, minY = 0) {
43
+ const els = page.locator(selector);
44
+ const deadline = Date.now() + 15000;
45
+ while (Date.now() < deadline) {
46
+ const n = await els.count();
47
+ for (let i = 0; i < n; i++) {
48
+ const el = els.nth(i);
49
+ if (await el.isVisible().catch(() => false)) {
50
+ const box = await el.boundingBox();
51
+ if (box && box.y >= minY) return el;
52
+ }
53
+ }
54
+ await page.waitForTimeout(250);
55
+ }
56
+ return null;
57
+ }
58
+
59
+ const report = { ran: [], checks: [], ok: true, at: new Date().toISOString() };
60
+ async function run(step, isCheck) {
61
+ const label = step.label || `${step.action} ${step.selector || step.url || step.key || ""}`.trim();
62
+ const rec = { label, action: step.action, ok: true };
63
+ try {
64
+ if (step.action === "goto") { await page.goto(step.url, { waitUntil: "load", timeout: 60000 }); await page.waitForTimeout(step.after ?? 1500); }
65
+ else if (step.action === "wait") { await page.waitForTimeout(step.ms ?? 1000); }
66
+ else if (step.action === "press") { await page.keyboard.press(step.key || "Escape"); await page.waitForTimeout(step.after ?? 600); }
67
+ else if (step.action === "expect" || step.action === "absent") {
68
+ const el = await visibleTarget(step.selector, step.minY ?? 0);
69
+ const present = !!el;
70
+ rec.ok = step.action === "expect" ? present : !present;
71
+ rec.detail = present ? "visible" : "not visible";
72
+ }
73
+ else if (step.action === "click" || step.action === "hover" || step.action === "type") {
74
+ const el = await visibleTarget(step.selector, step.minY ?? 0);
75
+ if (!el) throw new Error(`no visible target for ${step.selector}`);
76
+ if (step.action === "hover") await el.hover();
77
+ if (step.action === "click") { await el.click(); await page.waitForTimeout(step.after ?? 1200); }
78
+ if (step.action === "type") { await el.click(); if (step.clear) { await el.fill("").catch(async () => { await page.keyboard.press("ControlOrMeta+A"); await page.keyboard.press("Backspace"); }); } await page.keyboard.type(String(step.text ?? ""), { delay: 20 }); if (step.enter) await page.keyboard.press("Enter"); await page.waitForTimeout(step.after ?? 500); }
79
+ }
80
+ else throw new Error(`unknown action ${step.action}`);
81
+ } catch (e) { rec.ok = false; rec.error = e.message; }
82
+ rec.url = page.url(); // where the step left the page (a failed prep leaves its draft's url here)
83
+ (isCheck ? report.checks : report.ran).push(rec);
84
+ // An optional step (required:false) may fail without failing the report:
85
+ // the checks decide. Required steps and checks decide the verdict.
86
+ if (!rec.ok && (isCheck || step.required !== false)) report.ok = false;
87
+ console.log(`${rec.ok ? "✓" : "✗"} ${isCheck ? "check " : ""}${label}${rec.detail ? ` (${rec.detail})` : ""}${rec.error ? ` — ${rec.error}` : ""}`);
88
+ return rec.ok;
89
+ }
90
+
91
+ for (const step of steps) { if (!(await run(step, false)) && step.required !== false) { console.error(`stopping: "${step.label || step.action}" failed and is required`); break; } }
92
+ // Checks need a page under them: when nothing navigated yet, open the
93
+ // storyboard's own url first (a prep with only checks, or a cleanup whose
94
+ // steps never left the blank tab).
95
+ if (checks.length > 0 && page.url() === "about:blank" && sb.url) { await page.goto(sb.url, { waitUntil: "load", timeout: 60000 }).catch(() => {}); await page.waitForTimeout(3500); }
96
+ for (const step of checks) await run(step, true);
97
+ await ctx.close();
98
+ // The verdict: when checks are declared they ARE the proof (a rerun after a
99
+ // partial revert legitimately finds nothing left to do); without checks, every
100
+ // required step must have passed.
101
+ if (checks.length > 0) report.ok = report.checks.every((c) => c.ok);
102
+ fs.writeFileSync(path.join(dir, outName), JSON.stringify(report, null, 2) + "\n");
103
+ console.log(`${outName} written: ${report.ok ? "ok" : "NOT ok"} (${report.ran.length} steps, ${report.checks.length} checks)`);
104
+ process.exit(report.ok ? 0 : 1);
@@ -0,0 +1,113 @@
1
+ // Shared wait for a staged take: poll while the human decides in the app,
2
+ // then until the bite is READY, then print the receipt. Used by upload.mjs
3
+ // (single take) and status.mjs (a batch resumes here per take).
4
+ //
5
+ // LAW (founder 2026-08-08): never hand a human a studio link before the bite
6
+ // is finished. Approve only STARTS the pipeline; transcode, rescript, fit,
7
+ // synthesize and finalize happen after. The link exists ONLY behind a
8
+ // confirmed "completed".
9
+ //
10
+ // Returns { exitCode, status, biteId, studioUrl } and never throws.
11
+ //
12
+ // DELIVERY (1.3.0, founder ruling 2026-09-14): a take filmed from a brief
13
+ // becomes a bite by itself once its ZIP is uploaded; no Approve click. The
14
+ // server answers status "delivered" from then on, and the wait here is only
15
+ // for the bite to finish. Free-prompt takes still wait for the word.
16
+
17
+ /** PUT the delivery route for a staged take. `template` is the claim's
18
+ * api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
19
+ * the fallback is the same path under the configured base. Idempotent on the
20
+ * server: a repeat returns the same bite. Never throws. */
21
+ export async function deliverStaged({ base, apiKey, stagingId, template }) {
22
+ const url = template && template.includes("{id}")
23
+ ? template.replace("{id}", encodeURIComponent(stagingId))
24
+ : `${base}/api/recorder/stage/${encodeURIComponent(stagingId)}/uploaded`;
25
+ let res;
26
+ try {
27
+ res = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: "{}" });
28
+ } catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
29
+ const json = await res.json().catch(() => null);
30
+ if ((res.status === 200 || res.status === 202) && json?.biteId) {
31
+ return { delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status };
32
+ }
33
+ // An older server: { pending: true }, or no such route at all (a 404 without an error body).
34
+ if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
35
+ return { delivered: false, httpStatus: res.status, error: json?.error ? `${json.error}${json.message ? `: ${json.message}` : ""}` : `HTTP ${res.status}` };
36
+ }
37
+
38
+ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delivered = false, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
39
+ const headers = { Authorization: `Bearer ${apiKey}` };
40
+ const deadline = Date.now() + decisionTimeoutMs;
41
+ let announced = false;
42
+ let completed = false;
43
+ let approvedBiteId = null;
44
+ let finalStudioUrl = null;
45
+ process.stdout.write(delivered ? "Waiting for the bite to finish" : "Waiting for your word in the browser");
46
+ while (Date.now() < deadline) {
47
+ await new Promise((r) => setTimeout(r, pollMs));
48
+ let res;
49
+ try {
50
+ res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers });
51
+ } catch { process.stdout.write("."); continue; }
52
+ if (!res.ok) { process.stdout.write("."); continue; }
53
+ const st = await res.json().catch(() => null);
54
+ if (!st) { process.stdout.write("."); continue; }
55
+ if (st.status === "rejected") {
56
+ process.stdout.write("\n");
57
+ console.error("Discarded in the app. Adjust the storyboard and film again.");
58
+ return { exitCode: 1, status: "rejected", biteId: null, studioUrl: null };
59
+ }
60
+ if (st.status === "approved" || st.status === "delivered") {
61
+ if (!announced) {
62
+ process.stdout.write("\n");
63
+ console.log(st.status === "delivered" ? `delivered: bite ${st.biteId} is being created` : `Approved — bite ${st.biteId} is being created`);
64
+ announced = true;
65
+ approvedBiteId = st.biteId;
66
+ finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
67
+ if (st.biteStatus !== "completed") process.stdout.write("Waiting for the bite to finish");
68
+ }
69
+ if (st.biteStatus === "completed") { completed = true; process.stdout.write("\n"); break; }
70
+ if (st.biteStatus === "failed") {
71
+ process.stdout.write("\n");
72
+ console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
73
+ return { exitCode: 1, status: "failed", biteId: approvedBiteId, studioUrl: null };
74
+ }
75
+ }
76
+ process.stdout.write(".");
77
+ }
78
+ if (!announced) {
79
+ process.stdout.write("\n");
80
+ console.error(delivered ? `The server does not show the delivered bite yet. Look again later: ${pageUrl}` : `No decision yet. The preview stays available at:\n ${pageUrl}`);
81
+ return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
82
+ }
83
+ if (!completed) {
84
+ console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.`);
85
+ return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
86
+ }
87
+
88
+ // Final receipt via the status endpoint (same gate as before).
89
+ let last = null;
90
+ try {
91
+ const res = await fetch(`${base}/api/recorder/status?biteId=${approvedBiteId}`, { headers });
92
+ if (res.ok) last = await res.json().catch(() => null);
93
+ } catch { /* summary is best-effort; readiness was confirmed above */ }
94
+ if (last && last.status === "completed") {
95
+ console.log(
96
+ `Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
97
+ `${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
98
+ );
99
+ if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
100
+ else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
101
+ if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
102
+ }
103
+ if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
104
+ return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
105
+ }
106
+
107
+ /** One look, no waiting: the staged take's current status as the server sees it. */
108
+ export async function peekStaged({ base, apiKey, stagingId }) {
109
+ const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
110
+ if (!res.ok) return { ok: false, httpStatus: res.status };
111
+ const st = await res.json().catch(() => null);
112
+ return st ? { ok: true, ...st } : { ok: false, httpStatus: res.status };
113
+ }
@@ -0,0 +1,89 @@
1
+ // Where a staged take stands, and the wait for its word.
2
+ //
3
+ // node status.mjs <takeDir> wait for the decision (or, delivered, for the bite to finish); reads <takeDir>/staged.json
4
+ // a take whose delivery failed is delivered again here first (idempotent on the server)
5
+ // node status.mjs <stagingId> same, by id
6
+ // node status.mjs <takeDir> --no-wait one look, no waiting: pending | delivered (+bite status) | approved (+bite status) | rejected
7
+ // node status.mjs --all one look at every take-*/staged.json under the current directory
8
+ //
9
+ // A batch stages every take with `upload.mjs --stage-only --no-open`, then the
10
+ // agent (or the human, later) comes back here per take. The same law as
11
+ // upload.mjs: no studio link before the bite is completed.
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
15
+
16
+ const args = process.argv.slice(2);
17
+ const noWait = args.includes("--no-wait");
18
+ const all = args.includes("--all");
19
+ const target = args.find((a) => !a.startsWith("--"));
20
+ if (!target && !all) {
21
+ console.error("Usage: node status.mjs <takeDir|stagingId> [--no-wait] | node status.mjs --all");
22
+ process.exit(2);
23
+ }
24
+
25
+ const cfgPath = path.resolve(".recorder", "config.json");
26
+ let cfg = {};
27
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
28
+ if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
29
+ const base = cfg.base.replace(/\/+$/, "");
30
+
31
+ function readStaged(dir) {
32
+ const p = path.join(dir, "staged.json");
33
+ if (!fs.existsSync(p)) return null;
34
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
35
+ }
36
+
37
+ function describe(st) {
38
+ if (!st.ok) return `unreachable (${st.httpStatus})`;
39
+ if (st.status === "rejected") return "discarded in the app";
40
+ if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
41
+ if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
42
+ return "waiting for the word in the app";
43
+ }
44
+
45
+ if (all) {
46
+ const dirs = fs.readdirSync(".").filter((d) => d.startsWith("take-") && fs.existsSync(path.join(d, "staged.json")));
47
+ if (dirs.length === 0) { console.log("No staged takes here."); process.exit(0); }
48
+ for (const d of dirs) {
49
+ const staged = readStaged(d);
50
+ const st = staged?.stagingId ? await peekStaged({ base, apiKey: cfg.api_key, stagingId: staged.stagingId }) : { ok: false, httpStatus: 0 };
51
+ console.log(`${d.padEnd(40)} ${describe(st)}${staged?.previewUrl ? `\n${"".padEnd(40)} ${staged.previewUrl}` : ""}`);
52
+ }
53
+ process.exit(0);
54
+ }
55
+
56
+ let stagingId = target;
57
+ let pageUrl = null;
58
+ let delivered = false;
59
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
60
+ const staged = readStaged(target);
61
+ if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
62
+ stagingId = staged.stagingId;
63
+ pageUrl = staged.previewUrl ?? null;
64
+ delivered = staged.delivered === true;
65
+ // A brief take whose delivery failed (network, 409) is delivered again here;
66
+ // the server is idempotent. An older server (pending) is left alone.
67
+ if (staged.attemptRef && staged.delivered === false && staged.pending !== true) {
68
+ const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
69
+ if (d.delivered) {
70
+ delivered = true;
71
+ console.log(`delivered: bite ${d.biteId}${d.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.`);
72
+ try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, delivered: true, biteId: d.biteId, videoId: d.videoId ?? null, queued: d.queued ?? null, deliveryError: null }, null, 2) + "\n"); } catch {}
73
+ } else if (d.pending) {
74
+ try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, pending: true }, null, 2) + "\n"); } catch {}
75
+ console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
76
+ } else console.error(`Not delivered: ${d.error}. The take waits in the review queue.`);
77
+ }
78
+ }
79
+ if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
80
+
81
+ if (noWait) {
82
+ const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
83
+ console.log(`${stagingId}: ${describe(st)}`);
84
+ if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
85
+ process.exit(st.ok ? 0 : 1);
86
+ }
87
+
88
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
89
+ process.exit(outcome.exitCode);
@@ -40,6 +40,35 @@ if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0))
40
40
  // history show why this version was filmed. Never a secret, never required.
41
41
  const noteIdx = process.argv.indexOf("--note");
42
42
  const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim().slice(0, 600) : "";
43
+ // BATCH OF BRIEFS (2026-09-13): a take claimed from a brief carries its attempt
44
+ // (briefId, revision, contentHash, attemptRef) so provenance rides into the
45
+ // staged take and the bite. Read from <takeDir>/brief.json (written by
46
+ // briefs.mjs claim) unless --attempt <file> points elsewhere or --no-attempt
47
+ // opts out. `--stage-only` returns right after the two uploads (the batch
48
+ // waits with status.mjs); `--supersede` replaces a stage already pinned to
49
+ // this attempt (the server refuses a second one otherwise).
50
+ // DELIVERY (1.3.0, founder ruling 2026-09-14): a take with an attempt becomes
51
+ // a bite by itself. After both uploads this script PUTs the delivery route
52
+ // (the claim's api.uploaded, fallback <base>/api/recorder/stage/<id>/uploaded)
53
+ // and prints "delivered: bite <id>". No Approve click, no review queue for
54
+ // brief batches. Free-prompt takes (no attempt) still stage for review.
55
+ const stageOnly = process.argv.includes("--stage-only");
56
+ const supersede = process.argv.includes("--supersede");
57
+ const attemptIdx = process.argv.indexOf("--attempt");
58
+ const attemptPath = process.argv.includes("--no-attempt")
59
+ ? null
60
+ : attemptIdx >= 0 ? String(process.argv[attemptIdx + 1] ?? "") : path.join(dir, "brief.json");
61
+ let attempt = null;
62
+ let uploadedTemplate = null;
63
+ if (attemptPath && fs.existsSync(attemptPath)) {
64
+ try {
65
+ const b = JSON.parse(fs.readFileSync(attemptPath, "utf8"));
66
+ if (b.briefId && b.revision !== undefined && b.contentHash && b.attemptRef) {
67
+ attempt = { briefId: String(b.briefId), revision: b.revision, contentHash: String(b.contentHash), attemptRef: String(b.attemptRef) };
68
+ uploadedTemplate = typeof b.api?.uploaded === "string" ? b.api.uploaded : null;
69
+ } else console.error(`${attemptPath} is missing briefId/revision/contentHash/attemptRef; staging without an attempt`);
70
+ } catch (e) { console.error(`${attemptPath} unreadable (${e.message}); staging without an attempt`); }
71
+ } else if (attemptIdx >= 0) { console.error(`--attempt ${attemptPath} not found`); process.exit(2); }
43
72
  const cfgPath = path.resolve(".recorder", "config.json");
44
73
  let cfg = {};
45
74
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
@@ -48,6 +77,14 @@ if (!cfg.api_key || !cfg.base) {
48
77
  process.exit(1);
49
78
  }
50
79
  const base = cfg.base.replace(/\/+$/, "");
80
+ // A delivered take is a bite already; staging it again would make a second one.
81
+ try {
82
+ const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
83
+ if (prev?.delivered === true && prev.biteId && !supersede) {
84
+ console.error(`${dir} was already delivered as bite ${prev.biteId}. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
85
+ process.exit(1);
86
+ }
87
+ } catch { /* not staged yet */ }
51
88
 
52
89
  const cleanPath = path.join(dir, "clean.mp4");
53
90
  const wirePath = path.join(dir, "manifest.demobites.json");
@@ -65,6 +102,22 @@ try {
65
102
  if (retakeNote) recipe.config = { ...(recipe.config ?? {}), retake_note: retakeNote };
66
103
  }
67
104
  } catch (e) { console.error("recipe skipped:", e.message); }
105
+ // LAW (founder 2026-09-14): a take that created anything returns the workspace
106
+ // to its initial state before it is staged. The storyboard declares cleanup[];
107
+ // cleanup.mjs writes cleanup.json with the checks. No passing cleanup.json,
108
+ // no stage. --allow-uncleaned overrides, and says so out loud.
109
+ try {
110
+ const sbp = path.join(dir, "storyboard.json");
111
+ const sb = fs.existsSync(sbp) ? JSON.parse(fs.readFileSync(sbp, "utf8")) : {};
112
+ if (Array.isArray(sb.cleanup) && sb.cleanup.length > 0) {
113
+ const cp = path.join(dir, "cleanup.json");
114
+ const rep = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : null;
115
+ if (!rep || rep.ok !== true) {
116
+ if (process.argv.includes("--allow-uncleaned")) console.error("WARNING: staging a take whose cleanup did not run or did not pass (--allow-uncleaned). The workspace may still carry what the take created.");
117
+ else { console.error(`This take declares cleanup[] but ${rep ? "cleanup.json says NOT ok" : "cleanup.json is missing"}. Run: node scripts/cleanup.mjs ${dir} (then stage again)`); process.exit(1); }
118
+ }
119
+ }
120
+ } catch (e) { console.error(`cleanup check skipped: ${e.message}`); }
68
121
  if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }
69
122
  if (!fs.existsSync(wirePath)) { console.error(`${wirePath} not found. Run: node scripts/manifest.mjs ${dir}`); process.exit(1); }
70
123
  const manifest = JSON.parse(fs.readFileSync(wirePath, "utf8"));
@@ -140,17 +193,27 @@ if (!zipped) {
140
193
  fs.rmSync(staging, { recursive: true, force: true });
141
194
  const sizeBytes = fs.statSync(zipPath).size;
142
195
  console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
143
- if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite once approved.`);
196
+ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite ${attempt ? "by itself once delivered" : "once approved"}.`);
144
197
 
145
198
  // ── stage ──────────────────────────────────────────────────────────────────
146
199
  const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
200
+ if (attempt) {
201
+ // The attempt moves to "uploading" before the stage call; a 409 here means
202
+ // the server already sees it at or past that state, which is fine.
203
+ try {
204
+ const ev = await fetch(`${base}/api/recorder/briefs/attempts/${encodeURIComponent(attempt.attemptRef)}`, {
205
+ method: "PUT", headers: authHeaders, body: JSON.stringify({ event: "uploading" }),
206
+ });
207
+ if (!ev.ok && ev.status !== 409) console.error(`attempt event "uploading" → ${ev.status} (continuing)`);
208
+ } catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
209
+ }
147
210
  const previewSizeBytes = fs.statSync(cleanPath).size;
148
211
  let stageRes;
149
212
  try {
150
213
  stageRes = await fetch(`${base}/api/recorder/stage`, {
151
214
  method: "PUT",
152
215
  headers: authHeaders,
153
- body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}) }),
216
+ body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest, ...(recipe ? { recipe } : {}), ...(retakeOfBiteId ? { retakeOfBiteId } : {}), ...(attempt ? { attempt } : {}), ...(attempt && supersede ? { supersede: true } : {}) }),
154
217
  });
155
218
  } catch (e) {
156
219
  console.error(`Could not reach ${base}: ${e.message}`);
@@ -168,6 +231,14 @@ if (!stageRes.ok) {
168
231
  console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
169
232
  process.exit(1);
170
233
  }
234
+ if (errBody?.error === "invalid_attempt") {
235
+ console.error(`DemoBites refused the attempt on this take: ${errBody.message ?? "invalid_attempt"}. Re-claim the brief (node scripts/briefs.mjs claim …) and stage again.`);
236
+ process.exit(1);
237
+ }
238
+ if (errBody?.error === "attempt_already_staged") {
239
+ console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Review that one, or stage again with --supersede to replace it.`);
240
+ process.exit(1);
241
+ }
171
242
  console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
172
243
  process.exit(1);
173
244
  }
@@ -192,13 +263,47 @@ async function putS3(url, contentType, filePath, label) {
192
263
  await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
193
264
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
194
265
 
266
+ // ── delivery (brief batches): the take becomes a bite by itself ────────────
267
+ // Only a take carrying an attempt is delivered. Free-prompt takes never call
268
+ // this route, so their flow is unchanged: staged, then the word in the app.
269
+ let delivery = null;
270
+ if (attempt) {
271
+ const { deliverStaged } = await import("./stage-wait.mjs");
272
+ delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
273
+ }
274
+ // The staging id used to be printed only; a batch resumes from disk, so it is
275
+ // persisted next to the take (status.mjs reads it, and retries a delivery
276
+ // that failed).
277
+ const pageUrl = new URL(previewUrl, base).toString();
278
+ try {
279
+ fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
280
+ stagingId, previewUrl: pageUrl, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
281
+ pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
282
+ delivered: delivery ? delivery.delivered : null, biteId: delivery?.biteId ?? null, videoId: delivery?.videoId ?? null,
283
+ queued: delivery?.queued ?? null, pending: delivery?.pending ?? null, deliveryError: delivery?.error ?? null,
284
+ api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
285
+ }, null, 2) + "\n");
286
+ } catch (e) { console.error(`staged.json not written: ${e.message}`); }
287
+
288
+ if (delivery?.delivered) {
289
+ console.log(`delivered: bite ${delivery.biteId}${delivery.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.\n ${pageUrl}`);
290
+ if (stageOnly) { console.log(`Delivered only. Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
291
+ const { waitForDecision } = await import("./stage-wait.mjs");
292
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered: true });
293
+ process.exit(outcome.exitCode);
294
+ }
295
+ if (delivery && !delivery.pending) {
296
+ console.error(`Staged, but not delivered: ${delivery.error}. The take waits in the review queue:\n ${pageUrl}\nTry the delivery again later with: node scripts/status.mjs ${dir}`);
297
+ process.exit(1);
298
+ }
299
+ if (delivery?.pending) console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
300
+
195
301
  // ── open the in-app preview — the review happens THERE ─────────────────────
196
302
  // Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
197
303
  // review sprint, auto-opening a tab per take is spam. `--no-open` (or
198
304
  // config.open_preview === false) stages silently — the queue pill and the
199
305
  // printed URL carry the message. Default stays open: for a single take the
200
306
  // opened page IS the consent moment.
201
- const pageUrl = new URL(previewUrl, base).toString();
202
307
  console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
203
308
  if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
204
309
  console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
@@ -211,83 +316,15 @@ if (!noOpen) {
211
316
  } catch { /* printing the URL above is the fallback */ }
212
317
  }
213
318
 
214
- // ── poll while the human decides, then until the bite is READY ─────────────
215
- // LAW (founder 2026-08-08): never hand a human a studio link before the bite
216
- // is finished. Approve only STARTS the pipeline.
217
- const POLL_MS = 4000;
218
- const DECISION_TIMEOUT_MS = 30 * 60 * 1000;
219
- const deadline = Date.now() + DECISION_TIMEOUT_MS;
220
- let announced = false;
221
- let completed = false;
222
- let approvedBiteId = null;
223
- let finalStudioUrl = null;
224
- process.stdout.write("Waiting for your word in the browser");
225
- while (Date.now() < deadline) {
226
- await new Promise((r) => setTimeout(r, POLL_MS));
227
- let res;
228
- try {
229
- res = await fetch(`${base}/api/recorder/stage?id=${stagingId}`, {
230
- headers: { Authorization: `Bearer ${cfg.api_key}` },
231
- });
232
- } catch { process.stdout.write("."); continue; }
233
- if (!res.ok) { process.stdout.write("."); continue; }
234
- const st = await res.json().catch(() => null);
235
- if (!st) { process.stdout.write("."); continue; }
236
- if (st.status === "rejected") {
237
- process.stdout.write("\n");
238
- console.error("Discarded in the app. Adjust the storyboard and film again.");
239
- process.exit(1);
240
- }
241
- if (st.status === "approved") {
242
- if (!announced) {
243
- process.stdout.write("\n");
244
- console.log(`Approved — bite ${st.biteId} is being created`);
245
- announced = true;
246
- approvedBiteId = st.biteId;
247
- finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
248
- process.stdout.write("Waiting for the bite to finish");
249
- }
250
- if (st.biteStatus === "completed") {
251
- completed = true;
252
- process.stdout.write("\n");
253
- break;
254
- }
255
- if (st.biteStatus === "failed") {
256
- process.stdout.write("\n");
257
- console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
258
- process.exit(1);
259
- }
260
- }
261
- process.stdout.write(".");
262
- }
263
- if (!announced) {
264
- process.stdout.write("\n");
265
- console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
266
- process.exit(1);
267
- }
268
- // LAW: the studio link exists ONLY behind a confirmed 'completed'. A deadline
269
- // expiry after approval is NOT completion (review finding: the fallthrough
270
- // here once printed the link for an unfinished bite).
271
- if (!completed) {
272
- console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
273
- process.exit(1);
319
+ if (stageOnly) {
320
+ console.log(`Staged only. Wait for the decision later with: node scripts/status.mjs ${dir}`);
321
+ process.exit(0);
274
322
  }
275
323
 
276
- // ── final receipt via the status endpoint (same gate as before) ────────────
277
- let last = null;
278
- try {
279
- const res = await fetch(`${base}/api/recorder/status?biteId=${approvedBiteId}`, {
280
- headers: { Authorization: `Bearer ${cfg.api_key}` },
281
- });
282
- if (res.ok) last = await res.json().catch(() => null);
283
- } catch { /* summary is best-effort; readiness was confirmed above */ }
284
- if (last && last.status === "completed") {
285
- console.log(
286
- `Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
287
- `${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
288
- );
289
- if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
290
- else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
291
- if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
292
- }
293
- if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
324
+ // ── poll while the human decides, then until the bite is READY ─────────────
325
+ // Shared with status.mjs (a batch waits there). The law it enforces: never
326
+ // hand a human a studio link before the bite is finished; Approve only
327
+ // STARTS the pipeline.
328
+ const { waitForDecision } = await import("./stage-wait.mjs");
329
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
330
+ process.exit(outcome.exitCode);
@@ -0,0 +1,88 @@
1
+ // Harvest the product's CURRENT vocabulary from the running app (Phase 3a).
2
+ //
3
+ // node vocab.mjs <takeDir> <url> [<url> ...] [--headless=false]
4
+ //
5
+ // Opens each URL headless on the recorder profile (signed in, no video) and
6
+ // writes <takeDir>/vocab.json: for every screen, the nav labels (visible text,
7
+ // aria-label, title, tooltips), the page headings, the button and link labels,
8
+ // and the titles of any open dialogs. The storyboard's narration and
9
+ // on_screen lines may only use nouns that appear here. The brief's and the
10
+ // PR's words are hints about WHAT changed, never the words the demo speaks
11
+ // (founder, 2026-09-14: a take said "Release Readiness" while the app said
12
+ // "Assignments").
13
+ //
14
+ // Prints a short inventory so the agent can read it in chat before writing.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { chromium } from "playwright";
18
+
19
+ const args = process.argv.slice(2);
20
+ const dir = args[0];
21
+ const urls = args.slice(1).filter((a) => /^https?:\/\//.test(a));
22
+ if (!dir || urls.length === 0) {
23
+ console.error("Usage: node vocab.mjs <takeDir> <url> [<url> ...]");
24
+ process.exit(2);
25
+ }
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ const profileDir = path.resolve(".recorder", "profile");
28
+ const headless = !args.includes("--headless=false");
29
+
30
+ const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless, viewport: { width: 1920, height: 1080 } }).catch(async () =>
31
+ chromium.launchPersistentContext(profileDir, { headless, viewport: { width: 1920, height: 1080 } }),
32
+ );
33
+ const page = await ctx.newPage();
34
+
35
+ const harvest = () => {
36
+ const clean = (s) => (s || "").replace(/\s+/g, " ").trim();
37
+ const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden"; };
38
+ const labelOf = (el) => clean(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("data-tooltip") || el.innerText);
39
+ const uniq = (xs) => [...new Set(xs.filter(Boolean))];
40
+ const inRail = (el) => el.getBoundingClientRect().x < 90;
41
+ const nav = uniq([...document.querySelectorAll("nav a, nav button, aside a, aside button, [role='navigation'] a, header a, header button")].filter(vis).map((el) => {
42
+ const label = labelOf(el);
43
+ const href = el.getAttribute("href") || "";
44
+ // Tooltips often live on a sibling/parent: look at the closest element that carries one.
45
+ const tip = clean(el.closest("[title],[aria-label],[data-tooltip]")?.getAttribute("title") || el.closest("[data-tooltip]")?.getAttribute("data-tooltip") || "");
46
+ return (label || tip) ? `${label || tip}${href ? ` (${href})` : ""}${inRail(el) ? " [rail]" : ""}` : "";
47
+ }));
48
+ const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t.length < 90));
49
+ // Editors keep their controls in header bars and side panels, so nothing is
50
+ // excluded by landmark; only the left rail (x < 90) is reported separately.
51
+ const buttons = uniq([...document.querySelectorAll("button, [role='button'], a[href], [role='tab'], [role='menuitem']")].filter(vis).filter((el) => !inRail(el)).map(labelOf).filter((t) => t && t.length < 60));
52
+ const fields = uniq([...document.querySelectorAll("input, textarea, [contenteditable='true']")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter((t) => t && t.length < 60));
53
+ const dialogs = uniq([...document.querySelectorAll("[role='dialog'], [role='alertdialog']")].map((d) => clean(d.querySelector("h1,h2,h3,[id*='title']")?.innerText || d.getAttribute("aria-label") || "")));
54
+ const tooltips = uniq([...document.querySelectorAll("[role='tooltip']")].map((el) => clean(el.innerText)));
55
+ return { title: document.title, nav, headings, buttons, fields, dialogs, tooltips };
56
+ };
57
+
58
+ const screens = [];
59
+ for (const url of urls) {
60
+ await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }).catch(() => page.goto(url, { waitUntil: "load", timeout: 60000 }).catch((e) => console.error(`${url}: ${e.message}`)));
61
+ // Entitlement-gated nav items paint late; give the app a moment, then hover
62
+ // every rail link (the icons carry their names as tooltips, not as text) so
63
+ // the tooltip portals render into the DOM.
64
+ await page.waitForTimeout(5000);
65
+ const rail = page.locator("a[href^='/']");
66
+ const n = Math.min(await rail.count(), 40);
67
+ const tips = new Set();
68
+ for (let i = 0; i < n; i++) {
69
+ const el = rail.nth(i);
70
+ const box = await el.boundingBox().catch(() => null);
71
+ if (!box || box.x > 90 || box.width > 80) continue;
72
+ await el.hover().catch(() => {});
73
+ await page.waitForTimeout(700);
74
+ const texts = await page.locator("[role='tooltip'], [data-slot='tooltip-content'], [data-slot='tooltip-popup']").allInnerTexts().catch(() => []);
75
+ for (const t of texts) if (t.trim()) tips.add(`${t.trim()} (${(await el.getAttribute("href")) || "?"})`);
76
+ }
77
+ await page.mouse.move(600, 600);
78
+ const h = await page.evaluate(harvest);
79
+ h.railTooltips = [...tips];
80
+ h.url = page.url();
81
+ screens.push(h);
82
+ console.log(`\n${h.url}\n title: ${h.title}\n headings: ${h.headings.join(" · ")}\n rail tooltips: ${h.railTooltips.join(" · ") || "(none rendered)"}\n nav: ${h.nav.slice(0, 20).join(" · ")}\n buttons/links: ${h.buttons.slice(0, 60).join(" · ")}${h.fields.length ? `\n fields: ${h.fields.join(" · ")}` : ""}${h.dialogs.filter(Boolean).length ? `\n dialogs: ${h.dialogs.filter(Boolean).join(" · ")}` : ""}`);
83
+ }
84
+ await ctx.close();
85
+
86
+ const out = { harvestedAt: new Date().toISOString(), screens };
87
+ fs.writeFileSync(path.join(dir, "vocab.json"), JSON.stringify(out, null, 2) + "\n");
88
+ console.log(`\nvocab.json written (${screens.length} screen${screens.length === 1 ? "" : "s"}). Narration may use only these nouns.`);