demobite 1.3.1 → 1.5.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Ask your coding agent to record your product. Edit the result. Download the MP4.**
4
4
 
5
- Your agent drives a real browser on your machine and records the flow you describe. Connect a free DemoBites account to turn the take into an editable video, with text-to-speech, zooms, captions, and MP4 export included.
5
+ Your agent drives a real browser on your machine, records the flow you describe, and delivers the take into your free DemoBites account as an editable video, with text-to-speech, zooms, captions, and MP4 export included.
6
6
 
7
7
  **Sign in to your app locally. You do not hand DemoBites your app password or upload your recording browser profile.**
8
8
 
@@ -28,7 +28,7 @@ Before installing:
28
28
 
29
29
  - **Node.js 18 or newer** and npm.
30
30
  - **Google Chrome**, recommended. The recorder also supports Playwright's Chromium.
31
- - A **free DemoBites account** for the Studio workflow below.
31
+ - A **free DemoBites account**. The recorder works only when this project is connected to it.
32
32
 
33
33
  Run these commands from your project directory:
34
34
 
@@ -61,7 +61,7 @@ For Cursor or Codex, make the [recorder instructions](skill/SKILL.md) available
61
61
  1. **Describe the flow.** Give your agent the app URL and what you want to demonstrate.
62
62
  2. **Approve the storyboard.** Review the steps and estimated length before filming. If your app needs a login, sign in yourself in the recorder's local browser window.
63
63
  3. **Let it record.** The agent checks the flow and films it. Recording normally runs in the background.
64
- 4. **Review the raw take.** Open the review link printed in the terminal. Check the footage, then approve it to create a Bite, an editable video project.
64
+ 4. **Watch it arrive.** The take is delivered by itself and becomes a Bite, an editable video project. The terminal prints a link to your Demos grid in DemoBites, where the take's card shows it coming in. If your account is out of recording minutes, the take is kept and waits on the same grid until minutes are back.
65
65
  5. **Tweak and download.** In the Studio, change a sentence, generate its voice, adjust the zooms or timing, and export your MP4.
66
66
 
67
67
  The storyboard's narration is a starting point. DemoBites rewrites and fits it to the footage; you can edit the final wording in the Studio.
@@ -103,24 +103,18 @@ The recorder uses a separate, persistent Chrome profile at `.recorder/profile/`.
103
103
  | --- | --- |
104
104
  | Your app's browser session | Stays in the local recording profile; the standard local workflow does not upload that profile to DemoBites. |
105
105
  | DemoBites recorder key | Saved in `.recorder/config.json` and used to authenticate recorder requests to DemoBites. This is separate from your app login. |
106
- | Footage and recording metadata | Uploaded to DemoBites for review and processing, including the storyboard, narration intent, cursor events, and camera targets. |
106
+ | Footage and recording metadata | Uploaded to DemoBites for processing, including the storyboard, narration intent, cursor events, and camera targets. |
107
107
 
108
108
  Recording happens locally. Studio processing and editing use DemoBites online. Anything visible or typed into the recorded flow can appear in the footage or recording metadata, so use suitable demo data. Your coding agent's own data handling still follows its provider and your settings.
109
109
 
110
110
  Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
111
111
 
112
- ## Local output without a DemoBites account
113
-
114
- The repository also contains a [standalone recorder skill](recorder/SKILL.md) and local finishing scripts. These produce a local MP4; optional voiceover uses your own ElevenLabs key.
115
-
116
- This is an advanced, manual workflow. The default `npx demobite@latest` command installs the Studio-connected skill, not the standalone skill. The standalone finishing reference contains legacy instructions; use the standalone skill as the entry point rather than treating it as a second ending of the installed Studio skill.
117
-
118
112
  ## A few boundaries
119
113
 
120
114
  - The agentic recorder films **browser workflows**. It does not record native desktop apps or phone screens.
121
115
  - The Studio-connected skill is designed for short demos, usually **30–45 seconds**, with a **90-second maximum per Bite**. Split longer stories into separate videos.
122
116
  - Some sites block automated browsers. The workflow may need a human sign-in or verification step, and some sites may refuse recording.
123
- - Filming does not publish anything. You review the take before creating the Bite; public publishing is a separate action.
117
+ - Filming does not publish anything. You approve the storyboard before anything is filmed; the delivered Bite stays in your workspace, and public publishing is a separate action.
124
118
 
125
119
  ## Optional: Retake and management
126
120
 
@@ -130,7 +124,11 @@ When your UI changes, **Retake** can refilm an existing Bite from its saved reci
130
124
  npx demobite retake <biteId> --note "Export moved to the header"
131
125
  ```
132
126
 
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.
127
+ **In your app's repository.** Run the skill from the project that is your app and the agent reads the routes, the navigation, the control labels and the data handlers before it plans, then verifies everything on the live page. Without the code it works from the live app alone and asks when it cannot find a screen.
128
+
129
+ **Workspace rules.** A workspace admin can write standing rules for the recorder in plain words in the DemoBites settings tab Agentic Recorder Rules, one per line: what to mask, which pages never to open, which words to use. The agent reads them at the start of every take and applies them under its own filming laws; `npx demobite rules` prints them. Every take records the rules version it was filmed under.
130
+
131
+ **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. Like every take, a delivered take becomes a Bite in DemoBites by itself and shows up on your Demos grid. Nothing is published or shared.
134
132
 
135
133
  The package also includes a DemoBites management MCP for releases and centers:
136
134
 
@@ -149,7 +147,6 @@ The default installer also attempts to register that MCP with Claude Code when t
149
147
  | `launcher/` | CLI setup, connection commands, and MCP registration. |
150
148
  | `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
151
149
  | `scripts/` | Shared browser recording, trimming, and timing calibration. |
152
- | `recorder/` | Standalone skill and local video finishing tools. |
153
150
 
154
151
  The same package is published under three names, `demobite`, `agentic-recorder` and `demobites`, always at the same version; `npx agentic-recorder@latest` and `npx demobites@latest` do exactly what `npx demobite@latest` does. Run it again to update the installed skill. For publishing instructions, see [RELEASING.md](RELEASING.md).
155
152
 
@@ -158,7 +158,8 @@ if (arg === "retake") {
158
158
 
159
159
  // Batch of briefs (2026-09-13): `npx demobite briefs list <batchId>` etc. and
160
160
  // `npx demobite status <takeDir|stagingId>` hand straight to the skill scripts.
161
- if (arg === "briefs" || arg === "status") {
161
+ // WORKSPACE RULES (1.4): `npx demobite rules` prints the workspace's standing rules.
162
+ if (arg === "briefs" || arg === "status" || arg === "rules") {
162
163
  let cfg = readCfg();
163
164
  if (!cfg?.api_key) {
164
165
  console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "The DemoBites agentic recorder — 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"
@@ -20,6 +20,38 @@ if (!outArg || !storyArg) {
20
20
  }
21
21
 
22
22
  const STORYBOARD = JSON.parse(fs.readFileSync(storyArg, "utf8"));
23
+
24
+ // BROWSER HEADER (founder ruling 2026-09-16, final): the recording stays
25
+ // 1920x1080 and DemoBites ADDS the dark macOS header on top at ingest, as it
26
+ // does for an uploaded video; the studio places the taller container inside
27
+ // its 16:9 canvas. The header is on by default; a workspace rule negating it
28
+ // ("No browser header on the takes.", same phrase test as the cloud runner)
29
+ // turns it off, and so does storyboard.browserHeader === false. The manifest
30
+ // and the recipe carry browserHeader for the ingest to read. The cloud runner
31
+ // passes --browser-header=on|off; --viewport=WxH and storyboard.viewport can
32
+ // still change the frame when a lane needs it, never by default.
33
+ const NO_HEADER_RE = /no browser header|without (a |the )?browser header|browser header off/i;
34
+ function resolveDesign() {
35
+ const flag = process.argv.slice(2).find((a) => a.startsWith("--viewport="));
36
+ const m = flag && /^--viewport=(\d{3,4})x(\d{3,4})$/.exec(flag);
37
+ if (m) return { width: Number(m[1]), height: Number(m[2]), why: "--viewport" };
38
+ const v = STORYBOARD.viewport;
39
+ if (v && Number.isInteger(v.width) && Number.isInteger(v.height)) return { width: v.width, height: v.height, why: "storyboard.viewport" };
40
+ return { width: 1920, height: 1080, why: "default" };
41
+ }
42
+ function resolveHeader() {
43
+ // --browser-header=on|off (the cloud runner passes it) → storyboard.browserHeader → the rule → on.
44
+ const flag = process.argv.slice(2).find((a) => a.startsWith("--browser-header="));
45
+ if (flag) { const v = flag.slice("--browser-header=".length).toLowerCase(); if (v === "on" || v === "off") return { on: v === "on", why: "--browser-header" }; console.error(`--browser-header must be on or off (got ${v}); ignoring it`); }
46
+ if (typeof STORYBOARD.browserHeader === "boolean") return { on: STORYBOARD.browserHeader, why: "storyboard.browserHeader" };
47
+ let rulesText = "";
48
+ try { rulesText = JSON.parse(fs.readFileSync(path.resolve(".recorder/rules.json"), "utf8")).text ?? ""; } catch {}
49
+ if (NO_HEADER_RE.test(rulesText)) return { on: false, why: "workspace rule: no browser header" };
50
+ return { on: true, why: "default, DemoBites adds the browser header at ingest" };
51
+ }
52
+ const { why: DESIGN_WHY, ...DESIGN } = resolveDesign();
53
+ const { on: BROWSER_HEADER, why: HEADER_WHY } = resolveHeader();
54
+ console.log(`viewport ${DESIGN.width}x${DESIGN.height} (${DESIGN_WHY}); browser header: ${BROWSER_HEADER ? "yes" : "no"} (${HEADER_WHY})`);
23
55
  const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
24
56
  if (!Array.isArray(STORYBOARD.steps) || STORYBOARD.steps.length === 0) {
25
57
  console.error("Storyboard has no steps.");
@@ -51,7 +83,11 @@ try {
51
83
  const cfgPath = path.resolve(".recorder/config.json");
52
84
  const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
53
85
  // Only the public shape — NEVER the api_key or workspace.
54
- const config = { app: STORYBOARD.app ?? cfg.app ?? null, url: STORYBOARD.url ?? cfg.url ?? null, frame: cfg.frame ?? { width: 1920, height: 1080 }, base: cfg.base ?? "https://app.demobites.com" };
86
+ // WORKSPACE RULES (1.4): the storyboard names the rules version it was written under
87
+ // (rules.mjs prints it); the recipe carries it so a re-take can refuse an older rule set.
88
+ let rulesVersion = Number.isInteger(STORYBOARD.rulesVersion) ? STORYBOARD.rulesVersion : null;
89
+ if (rulesVersion === null) { try { const r = JSON.parse(fs.readFileSync(path.resolve(".recorder/rules.json"), "utf8")); if (Number.isInteger(r.version)) rulesVersion = r.version; } catch {} }
90
+ const config = { app: STORYBOARD.app ?? cfg.app ?? null, url: STORYBOARD.url ?? cfg.url ?? null, frame: DESIGN, browserHeader: BROWSER_HEADER, base: cfg.base ?? "https://app.demobites.com", ...(rulesVersion !== null ? { rulesVersion } : {}) };
55
91
  fs.writeFileSync(path.join(DIR, "recipe.json"), JSON.stringify({ version: 1, lane: (process.env.CDP_WS_URL || STORYBOARD.cdpWsUrl) ? "cloud" : "skill", engine: ENGINE_VERSION, config }, null, 2));
56
92
  } catch (e) { console.error("recipe.json not written:", e.message); }
57
93
  fs.mkdirSync(DIR, { recursive: true });
@@ -86,7 +122,6 @@ fs.mkdirSync(DIR, { recursive: true });
86
122
  // display-level capture: a separate chapter. The coordinate plumbing below is
87
123
  // kept so flipping this constant is the only change when it lands.
88
124
  const SUPERSAMPLE = 1;
89
- const DESIGN = { width: 1920, height: 1080 };
90
125
  const VIEW = { width: DESIGN.width * SUPERSAMPLE, height: DESIGN.height * SUPERSAMPLE };
91
126
  // Persistent camera-browser profile: the human's signed-in sessions live here.
92
127
  // The auth checkpoint (SKILL.md) fills it; record only ever reads it.
@@ -281,6 +316,7 @@ const manifest = {
281
316
  title: STORYBOARD.title ?? null,
282
317
  url: STORYBOARD.url ?? null,
283
318
  frame: DESIGN,
319
+ browserHeader: BROWSER_HEADER,
284
320
  supersample: SUPERSAMPLE,
285
321
  started_at: new Date(T0).toISOString(),
286
322
  steps: [],
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. 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).
12
+ Follow the phases in order. Never skip the storyboard approval: the human's yes on the storyboard, in the chat, is the word for the take. After that the take is delivered by itself. It becomes a bite in DemoBites without a second click, and the human watches it come in on their Demos grid (`<base>/demos`). Never send them to a preview page to approve it.
13
13
 
14
14
  ## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
15
15
 
@@ -39,10 +39,13 @@ surprise the human with a browser page):**
39
39
  for their word.
40
40
 
41
41
  Gating first is deliberate: fail before minutes of filming and know the
42
- target workspace up front. Bite-plan limits are NOT your concern and never
43
- block you: staging always succeeds, takes wait in the product queue, and the
44
- plan gate lives on the Approve button in DemoBites. Never mention quota in
45
- the terminal — if the workspace is full, the product does the talking.
42
+ target workspace up front. Plan limits are NOT your concern and never block
43
+ you: staging always succeeds. When the account has no recording minutes
44
+ left, DemoBites KEEPS the take and it waits on the Demos grid, where a strip
45
+ shows the waiting takes with the upgrade door. `upload.mjs` prints one line
46
+ for that ("Kept. The take is waiting for recording minutes..."); relay it
47
+ as it is. Never add numbers, prices or quota talk of your own, the product
48
+ does the talking.
46
49
  To sign out: `node scripts/login.mjs --logout` (revokes the key server-side
47
50
  AND strips it locally). "Log me out of DemoBites" means exactly that command.
48
51
 
@@ -52,7 +55,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
52
55
 
53
56
  - **app**: the product's name as it should appear in titles.
54
57
  - **url**: the starting URL of the flow.
55
- - **frame**: fixed at 1920x1080 for now, do not ask, just record it.
58
+ - **frame**: 1920x1080, never asked. DemoBites adds the dark macOS browser header on top of every take at ingest, as for an uploaded video; a workspace rule that negates it ("No browser header on the takes.") turns it off. The manifest and the recipe carry `frame` and `browserHeader` for the record.
56
59
  - **base**: defaults to `https://app.demobites.com`, only ask if the human mentions a different environment.
57
60
 
58
61
  Write the answers to `.recorder/config.json` and never ask again:
@@ -61,13 +64,29 @@ Write the answers to `.recorder/config.json` and never ask again:
61
64
  {
62
65
  "app": "Acme",
63
66
  "url": "https://app.acme.com",
64
- "frame": { "width": 1920, "height": 1080 },
65
67
  "base": "https://app.demobites.com"
66
68
  }
67
69
  ```
68
70
 
69
71
  `login.mjs` later merges `api_key` and `workspace` into this same file and chmods it 600. Treat the file as secret once a key is in it. Never print `api_key`.
70
72
 
73
+ ## Phase 1b: Workspace rules, EVERY run
74
+
75
+ The workspace admin can write standing rules for the recorder in plain words, one per line, in the DemoBites settings tab "Agentic Recorder Rules" ("Mask any number with a dollar sign.", "Never open the Billing page.", "Say Update Center, never changelog."). They apply to every take filmed in that workspace. At the start of EVERY run, batch or free prompt, right after the key resolves the workspace and BEFORE any storyboard:
76
+
77
+ ```bash
78
+ node scripts/rules.mjs [<takeDir>] # fetches the rules fresh, writes .recorder/rules.json, prints them numbered
79
+ ```
80
+
81
+ It fetches `GET <base>/api/recorder/rules` with the recorder key (never cached). When the fetch fails it falls back to the snapshot the claim wrote into `<takeDir>/brief.json` (`workspaceRules`), then to no rules, and prints which of the three it used; repeat that line in your report. Then:
82
+
83
+ - Fold the rules into your storyboard thinking as **standing rules of the workspace, BELOW the filming laws**. A rule never lifts a law: an irreversible action stays pointed at and never pressed, Cancel is never a beat, the human still approves every storyboard, and nothing is published or shared.
84
+ - Every beat a rule shaped carries `"rules": [1, 3]` (the rule numbers as printed), and the storyboard carries `"rulesVersion": <version>` at the top. The presentation shows "Rules applied: 1, 3" on those beats and lists the rules once. A rule that cannot be honoured in this flow is said out loud in the presentation, never silently dropped.
85
+ - The prep, take and cleanup legs obey the rules too (a "never open" page is never opened, not even off camera).
86
+ - Masking rules ("mask emails", "hide amounts") become `hideCss` rules or text masks with what the skill has today: find the element on the live page (Phase 4) and blank it with `color: transparent` plus a soft `text-shadow`, `filter: blur(6px)`, or `display: none` when the element may vanish. Keep masked words out of `narration` and `on_screen` too.
87
+ - Vocabulary rules ("say X, never Y") win over vocab.json for that noun, as long as X appears in the app or the rule; the rule is the admin's word.
88
+ - `record.mjs` copies `rulesVersion` into `recipe.config`, and `manifest.mjs` into the stage manifest, so a re-take can tell which rule set it was filmed under.
89
+
71
90
  ## Phase 2: Target-app sign-in, only when a login wall appears
72
91
 
73
92
  The camera browser uses a persistent profile at `.recorder/profile`. Signed in sessions survive between takes.
@@ -92,6 +111,17 @@ node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
92
111
 
93
112
  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
113
 
114
+ ### Phase 3b: When you sit in the app's repository, read it FIRST
115
+
116
+ A developer runs this skill from the project that IS the app (founder, 2026-09-17: developers have the code; product and marketing people do not). When the working directory holds the app's source, use it before you write a storyboard, the way you would read a map:
117
+
118
+ - **Routes and navigation**: the router (app or pages directory, the route table, the nav component) tells you the address of every screen the brief names. Start each storyboard on that address; never open the root and search.
119
+ - **Controls and selectors**: the components give you the real labels, titles, roles and test ids of the controls the flow presses. Prefer a selector from the source (text, title, aria, data attributes) over one guessed from a screenshot; never a positional chain.
120
+ - **Data and its undo**: the handlers behind Create, Add, Move, Delete tell you what an action makes and what puts it back. That is the `prep[]` and `cleanup[]` plan; write it from the code, then verify it on the live page.
121
+ - **Gates**: feature flags and plan gates in the code tell you which screens need a plan or a role before the camera walks into a wall.
122
+
123
+ The live page stays the truth: what the code names a thing is not what the demo calls it (Phase 3a, vocab.json wins for every noun in narration and on_screen), and a route in the code is not a screen until you have seen it deployed at the target address. Read only what the flow touches (routing, navigation, UI strings, the mutation handlers), not the whole codebase. Never put code names, file paths, flags or internal state names into narration or on screen. Without a repository (a brief pasted into an empty project) you have the live app alone: harvest, probe, and ask when a screen cannot be found.
124
+
95
125
  ### LAW: the camera shows an action to its end
96
126
 
97
127
  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).
@@ -134,7 +164,7 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
134
164
 
135
165
  **LAW: page transitions are cut and faded, never watched.** When the story moves to another page, the viewer sees page one, a short fade, page two — never the loading blank. record.mjs stamps every mid-take `goto` and manifest.mjs cuts that window out with a fade (`cuts` in the wire manifest); the ingestion lays it on the bite as a timeline cut. No zoom and no narration live inside a cut (the studio forbids both), so put the line about the new page on the beat AFTER it has landed, and say goodbye to the old page BEFORE the goto.
136
166
 
137
- Storyboard schema:
167
+ Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
138
168
 
139
169
  ```json
140
170
  {
@@ -153,7 +183,7 @@ Storyboard schema:
153
183
  }
154
184
  ```
155
185
 
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`.
186
+ Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. `rules` (optional, any step) lists the numbers of the workspace rules that shaped the beat; the storyboard's top-level `rulesVersion` names the rule set (Phase 1b). **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`.
157
187
 
158
188
  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
189
 
@@ -273,23 +303,23 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
273
303
  node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
274
304
  node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
275
305
  node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
276
- node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview (refuses an uncleaned take)
306
+ node scripts/upload.mjs <takeDir> # STAGE + DELIVER the take, open the Demos grid (refuses an uncleaned take)
277
307
  ```
278
308
 
279
- **The human word lives in the product now.** `upload.mjs` stages the take (the
280
- ZIP for ingestion plus a playable MP4 for the player), opens the DemoBites
281
- preview page in the human's browser, and polls while they decide THERE.
282
- Approve on that page runs the ingest; Discard deletes the staged take and this
283
- script reports it so you adjust and refilm. There is no local review.html for
284
- this ending — the preview page is the review.
309
+ **Every take is delivered by itself** (founder ruling 2026-09-25). `upload.mjs` stages the take (the ZIP for ingestion plus a playable MP4), uploads both, then calls the delivery door. There is no Approve click and no preview page to send the human to: the word was the storyboard yes in the chat. The script opens the human's Demos grid (`<base>/demos`, `--no-open` skips it) and prints one of two lines:
285
310
 
286
- What the human approves on that page is the **picture and the coverage**, never the script. The page deliberately shows no quoted lines and no timestamps, because the ingestion rewrites the narration and refits it to the video. Presenting "this line at 0:05" promises something the system does not deliver. A retake is only for a wrong picture: private data on screen, or a missing step in the flow.
311
+ - **Delivered:** `Delivered. Watch it come in: <base>/demos`. The card for the take shows it ingesting on the grid. Without `--stage-only` the script then waits for the bite to finish and prints the receipt (see the law below).
312
+ - **Kept, waiting for minutes:** `Kept. The take is waiting for recording minutes (back on <date>). Watch it here: <base>/demos`. The account has no recording minutes left. The take is safe on the server and waits on the same grid, next to the upgrade door. Tell the human exactly that, nothing more; do not refilm, do not stage it again.
313
+
314
+ Any other answer is an error: the script says what failed and `node scripts/status.mjs <takeDir>` tries the delivery again (the server is idempotent). `staged.json` in the take directory records `stagingId`, `dashboardUrl`, `delivered`, `waiting`, `resetsAt` and `biteId` (plus `previewUrl`, kept only for older readers; never give it to the human). Tell the human to watch the Demos grid, never the preview link.
315
+
316
+ If the take is wrong (private data on screen, a missing step in the flow), say so, adjust and film again; the human removes the unwanted bite in the app.
287
317
 
288
318
  ### LAW: never hand over a studio link before the bite is ready
289
319
 
290
320
  `ingest` only STARTS the pipeline. Transcode, rescript, fit, synthesize and finalize all happen after the call returns, so a link printed at that moment leads to a half built bite with grey silent rows, which is exactly what the founder walked into on 2026-08-08.
291
321
 
292
- `upload.mjs` now polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
322
+ After a delivered take, `upload.mjs` polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
293
323
 
294
324
  ## Phase 7: Re-take (Launch plan and up)
295
325
 
@@ -312,10 +342,9 @@ Laws for a re-take:
312
342
  - **The narration in the recipe is the ORIGINAL intent.** Do not rewrite it to taste: the server replaces it
313
343
  with the bite's current text per step. Only remove lines whose beats you dropped.
314
344
  - **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
315
- - **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
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.
345
+ - **The re-take is delivered like every take.** The new recording replaces the current one in that bite by
346
+ itself; the previous recording is kept for rollback, never overwritten, and the promoted export stays as it
347
+ is until a version is published. The human watches it on the Demos grid.
319
348
 
320
349
  ## Batch of briefs (GitHub PR → demos)
321
350
 
@@ -326,22 +355,22 @@ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approve
326
355
  node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
327
356
  node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
328
357
  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
358
+ node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself (or waits for minutes), do not wait
330
359
  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
360
+ node scripts/status.mjs --all # one look at every delivered or waiting take here
332
361
  ```
333
362
 
334
363
  The procedure, in order:
335
364
 
336
365
  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
366
  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.
367
+ 3. Run `rules.mjs <takeDir>` (Phase 1b; the claim stored the batch's rules snapshot in brief.json as the fallback) and `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec, the workspace rules below the laws, 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
368
  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.
369
+ 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, as for every take: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `Delivered. Watch it come in: <base>/demos`. With no recording minutes left the line reads `Kept. The take is waiting for recording minutes...`; the take waits on the grid, go on with the next brief. It writes `staged.json` with the staging id, the grid link, and the bite id or the waiting state.
341
370
  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.
371
+ 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, and they can watch them come in on the Demos grid (`<base>/demos`). Name any take that is kept, waiting for recording minutes; it waits on the same grid. `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): `status.mjs <takeDir>` tries the delivery again. Deliver each take; never publish, never share, never send invitations.
343
372
 
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`).
373
+ 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, waiting for minutes, 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, waiting or staged unless the human asked for a new take (`upload.mjs --supersede`).
345
374
 
346
375
  ## The wire manifest (fixed contract, version 2)
347
376
 
@@ -387,6 +416,9 @@ PUT <base>/api/recorder/device { device_code } (poll every `interval`
387
416
  DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
388
417
  -> { revoked: true } (logout)
389
418
 
419
+ GET <base>/api/recorder/rules (Authorization: Bearer <api_key>) // WORKSPACE RULES (1.4): never cached
420
+ -> { workspaceId, rules: string | null, version, updatedAt } (the claim's api.rules is the same url; workspaceRules on the claim is the snapshot)
421
+
390
422
  GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
391
423
  -> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
392
424
 
@@ -396,11 +428,13 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
396
428
  -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
397
429
 
398
430
  GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
399
- -> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
431
+ -> { status: 'pending'|'approving'|'approved'|'delivered'|'waiting'|'rejected', biteId, biteUKey, biteStatus, studioUrl, resetsAt? }
400
432
 
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 }
433
+ PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: EVERY take, after both uploads (1.5)
434
+ {} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded") when there is one; this path is the fallback
435
+ -> 202 { delivered:true, biteId, studioUrl, dashboardUrl } (also 200 { biteId, videoId } from older servers)
436
+ | 403 { kept:true, waiting:'minutes', resetsAt, dashboardUrl:'/demos', stagingId } (no recording minutes: kept, waits on the grid)
437
+ | 200 { pending:true } (older server: waits for the word in the app) | 404/409 { error }
404
438
  // idempotent: a repeat returns the same bite
405
439
 
406
440
  GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
@@ -414,7 +448,7 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
414
448
 
415
449
  ## Standing rules
416
450
 
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.
451
+ - Anything the human sees (storyboard presentation, questions, reports) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
418
452
  - Never touch credentials. Never print the api_key. Config and key files are chmod 600.
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.
453
+ - Never film without the human's explicit word on the storyboard. That yes, in the chat, is the word for the take: delivery ingests by itself, and the human watches the take on the Demos grid, never on a preview page. For a batch of briefs the word is given on the batch and on each storyboard. Never publish, never share, never send invitations.
420
454
  - 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>`.
@@ -142,7 +142,9 @@ if (cmd === "claim") {
142
142
  const record = {
143
143
  batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
144
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(),
145
+ source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null,
146
+ // WORKSPACE RULES (1.4): the claim carries a snapshot { version, text }; rules.mjs fetches fresh and falls back to it.
147
+ workspaceRules: r.json.workspaceRules ?? data.workspaceRules ?? null, claimedAt: new Date().toISOString(),
146
148
  };
147
149
  fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
148
150
  console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
@@ -47,6 +47,8 @@ for (let i = 1; i < args.length; i++) {
47
47
  const manPath = path.join(dir, "manifest.json");
48
48
  if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
49
49
  const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
50
+ // The recording's own frame (1920x1080; --viewport can change it).
51
+ const FW = man.frame?.width ?? 1920, FH = man.frame?.height ?? 1080;
50
52
  const round2 = (x) => Math.round(x * 100) / 100;
51
53
 
52
54
  // TIMEBASE — see the law in trim.mjs. Every time in manifest.json is WALL
@@ -233,7 +235,7 @@ if (fs.existsSync(cleanPath)) {
233
235
  if (!m) return null;
234
236
  const [w, h, x, y] = m.slice(1).map(Number);
235
237
  if (!(w > 0 && h > 0)) return null;
236
- const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * 1920 * 1080 * SS * SS : 0;
238
+ const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * FW * FH * SS * SS : 0;
237
239
  return { x: x / SS, y: y / SS, w: w / SS, h: h / SS, changedPx: changedPx / (SS * SS) };
238
240
  };
239
241
  for (const st of steps) {
@@ -256,7 +258,7 @@ if (fs.existsSync(cleanPath)) {
256
258
  console.log(`consequence: step ${st.n} change too sparse (${Math.round(change.changedPx)}px over ${Math.round(change.w)}x${Math.round(change.h)}) — ignored as noise`);
257
259
  continue;
258
260
  }
259
- if (change.w * change.h > 1920 * 1080 * 0.85) {
261
+ if (change.w * change.h > FW * FH * 0.85) {
260
262
  // A navigation (the whole page swapped). The tight control shot must
261
263
  // NOT linger clamped over the new page — reset to wide, so the camera
262
264
  // "zooms back out" at the cut (founder, 2026-08-09: "you never zoomed
@@ -267,7 +269,7 @@ if (fs.existsSync(cleanPath)) {
267
269
  const wideStart = round2(st.click.t);
268
270
  const wideEnd = round2(Math.min(duration, st.t_end));
269
271
  if (wideEnd - wideStart >= 0.6) {
270
- camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: 1920, h: 1080, n: st.n, label: `${st.label || "click"}, new page` });
272
+ camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: FW, h: FH, n: st.n, label: `${st.label || "click"}, new page` });
271
273
  camera.sort((a, b) => a.t_start - b.t_start);
272
274
  }
273
275
  console.log(`consequence: step ${st.n} navigation — reset to wide at ${wideStart}s`);
@@ -296,7 +298,7 @@ const mouseEvents = rawEvents
296
298
  .filter((e) => e.time >= 0 && e.time <= duration);
297
299
  const interactions = mouseEvents.length
298
300
  ? {
299
- viewport: man.interactions?.viewport ?? { width: 1920, height: 1080 },
301
+ viewport: man.interactions?.viewport ?? { width: FW, height: FH },
300
302
  mouseEvents,
301
303
  }
302
304
  : null;
@@ -322,11 +324,17 @@ if (cuts.length > 0) {
322
324
  camera.push(...clipped.sort((a, b) => a.t_start - b.t_start));
323
325
  }
324
326
 
327
+ // WORKSPACE RULES (1.4): the stage manifest names the rules version the take was filmed under.
328
+ let rulesVersion = null;
329
+ try { const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8")); if (Number.isInteger(sb.rulesVersion)) rulesVersion = sb.rulesVersion; } catch {}
325
330
  const wire = {
326
331
  version: 2,
332
+ ...(rulesVersion !== null ? { rulesVersion } : {}),
327
333
  app: man.app ?? "App",
328
334
  title: titleArg ?? man.title ?? `${man.app ?? "App"} demo`,
329
- frame: { width: 1920, height: 1080 },
335
+ frame: { width: FW, height: FH },
336
+ // BROWSER HEADER (2026-09-16): true by default; DemoBites adds the dark macOS bar on top at ingest. A workspace rule "No browser header" sets it false.
337
+ ...(typeof man.browserHeader === "boolean" ? { browserHeader: man.browserHeader } : {}),
330
338
  duration,
331
339
  steps,
332
340
  camera,
@@ -8,8 +8,8 @@
8
8
  // Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
9
9
  //
10
10
  // Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
11
- // manifest -> upload --retake-of <biteId> (stages; the human approves in-app,
12
- // where the preview says "Re-take of <bite>").
11
+ // manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
12
+ // the person watches it on the Demos grid).
13
13
  //
14
14
  // A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
15
15
  // That is the moment for the agent to look at the live page, fix the
@@ -0,0 +1,60 @@
1
+ // WORKSPACE RULES (1.4, founder law 2026-09-15): standing rules the workspace
2
+ // admin wrote in plain words, one per line, in the "Agentic Recorder Rules"
3
+ // settings tab. Every run reads them FIRST, before any storyboard:
4
+ //
5
+ // node rules.mjs [<takeDir>] fetch GET <base>/api/recorder/rules with the recorder key,
6
+ // write .recorder/rules.json, print the rules numbered
7
+ //
8
+ // Fallback order, and the run log says which: fetched fresh → the snapshot in
9
+ // <takeDir>/brief.json (workspaceRules from the claim) → no rules.
10
+ // The rules never lift the filming laws: irreversible actions stay pointed at,
11
+ // never pressed; Cancel is never a beat. Never print the api_key.
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const takeDir = process.argv.slice(2).find((a) => !a.startsWith("--")) ?? null;
16
+ const cfgPath = path.resolve(".recorder", "config.json");
17
+ let cfg = {};
18
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
19
+ if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
20
+ const base = cfg.base.replace(/\/+$/, "");
21
+
22
+ let snapshot = null;
23
+ let rulesUrl = `${base}/api/recorder/rules`;
24
+ if (takeDir) {
25
+ try {
26
+ const b = JSON.parse(fs.readFileSync(path.join(takeDir, "brief.json"), "utf8"));
27
+ if (b.workspaceRules && typeof b.workspaceRules.text === "string") snapshot = { version: Number(b.workspaceRules.version) || 0, text: b.workspaceRules.text };
28
+ if (typeof b.api?.rules === "string" && /^https?:\/\//.test(b.api.rules)) rulesUrl = b.api.rules;
29
+ } catch { /* no brief.json: a free-prompt run */ }
30
+ }
31
+
32
+ let result = null; // { version, text, source, updatedAt }
33
+ try {
34
+ const res = await fetch(rulesUrl, { headers: { Authorization: `Bearer ${cfg.api_key}`, "Cache-Control": "no-cache" } });
35
+ if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
36
+ const json = await res.json().catch(() => null);
37
+ if (res.ok && json && "version" in json) {
38
+ result = { version: Number(json.version) || 0, text: typeof json.rules === "string" ? json.rules : "", source: "fetched", updatedAt: json.updatedAt ?? null, workspaceId: json.workspaceId ?? null };
39
+ } else if (res.status === 404 && !json?.error) {
40
+ console.error("This DemoBites has no workspace rules route yet (older server).");
41
+ } else {
42
+ console.error(`Rules fetch answered ${res.status}${json?.error ? ` ${json.error}` : ""}.`);
43
+ }
44
+ } catch (e) { console.error(`Rules fetch failed: ${e.message}`); }
45
+ if (!result && snapshot) result = { ...snapshot, source: "snapshot", updatedAt: null, workspaceId: null };
46
+ if (!result) result = { version: 0, text: "", source: "none", updatedAt: null, workspaceId: null };
47
+
48
+ // One rule per line; a leading bullet or dash the admin typed is not part of the rule.
49
+ const lines = result.text.split(/\r?\n/).map((s) => s.trim().replace(/^[•·\-*]+\s*/, "").trim()).filter(Boolean);
50
+ const out = { ...result, lines, fetchedAt: new Date().toISOString() };
51
+ try { fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); fs.writeFileSync(path.resolve(".recorder", "rules.json"), JSON.stringify(out, null, 2) + "\n"); } catch (e) { console.error(`rules.json not written: ${e.message}`); }
52
+
53
+ const where = result.source === "fetched" ? "fetched from DemoBites" : result.source === "snapshot" ? "from the batch snapshot in brief.json (the fetch failed)" : "none (the fetch failed and no snapshot)";
54
+ console.log(`Workspace rules: version ${result.version}, ${where}.`);
55
+ if (lines.length === 0) console.log("No standing rules. The filming laws alone apply.");
56
+ else {
57
+ console.log("Standing rules of the workspace, below the filming laws (they never lift them):");
58
+ lines.forEach((l, i) => console.log(` ${i + 1}. ${l}`));
59
+ console.log(`Record "rulesVersion": ${result.version} in the storyboard and list the rules applied on each beat.`);
60
+ }
@@ -10,14 +10,43 @@
10
10
  // Returns { exitCode, status, biteId, studioUrl } and never throws.
11
11
  //
12
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.
13
+ // becomes a bite by itself once its ZIP is uploaded; no Approve click.
14
+ // DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): EVERY take is delivered
15
+ // that way, plain prompt takes too. The person watches it come in on the
16
+ // Demos grid (<base>/demos), never on the preview page. A take the account
17
+ // has no recording minutes for is KEPT by the server (403 { kept: true,
18
+ // waiting: "minutes", resetsAt, dashboardUrl, stagingId }) and waits on the
19
+ // same grid; the stage status reads "waiting" until minutes are back.
20
+
21
+ /** Where the person watches their takes: the Demos grid. `dashboardUrl` from
22
+ * the server wins (relative or absolute), else <base>/demos. */
23
+ export function gridUrl(base, dashboardUrl) {
24
+ try { return new URL(dashboardUrl || "/demos", base + "/").toString(); } catch { return `${base}/demos`; }
25
+ }
26
+
27
+ /** "October 1, 2026" from an ISO date, or null when there is none. */
28
+ export function formatResetDate(resetsAt) {
29
+ if (!resetsAt) return null;
30
+ const d = new Date(resetsAt);
31
+ if (Number.isNaN(d.getTime())) return null;
32
+ return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
33
+ }
34
+
35
+ /** The one line a person reads when a take is kept for recording minutes. */
36
+ export function waitingLine(grid, resetsAt) {
37
+ const when = formatResetDate(resetsAt);
38
+ return `Kept. The take is waiting for recording minutes${when ? ` (back on ${when})` : ""}. Watch it here: ${grid}`;
39
+ }
16
40
 
17
41
  /** PUT the delivery route for a staged take. `template` is the claim's
18
42
  * api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
19
43
  * the fallback is the same path under the configured base. Idempotent on the
20
- * server: a repeat returns the same bite. Never throws. */
44
+ * server: a repeat returns the same bite. Never throws.
45
+ * Returns one of:
46
+ * { delivered: true, biteId, videoId, studioUrl, dashboardUrl, queued }
47
+ * { delivered: false, waiting: "minutes", kept: true, resetsAt, dashboardUrl }
48
+ * { delivered: false, pending: true } (older server)
49
+ * { delivered: false, error } */
21
50
  export async function deliverStaged({ base, apiKey, stagingId, template }) {
22
51
  const url = template && template.includes("{id}")
23
52
  ? template.replace("{id}", encodeURIComponent(stagingId))
@@ -28,7 +57,17 @@ export async function deliverStaged({ base, apiKey, stagingId, template }) {
28
57
  } catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
29
58
  const json = await res.json().catch(() => null);
30
59
  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 };
60
+ return {
61
+ delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, studioUrl: json.studioUrl ?? null,
62
+ dashboardUrl: json.dashboardUrl ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status,
63
+ };
64
+ }
65
+ // No recording minutes left: the server KEEPS the take; it waits on the grid.
66
+ if (res.status === 403 && json?.kept === true) {
67
+ return {
68
+ delivered: false, kept: true, waiting: json.waiting ?? "minutes", resetsAt: json.resetsAt ?? null,
69
+ dashboardUrl: json.dashboardUrl ?? null, httpStatus: res.status,
70
+ };
32
71
  }
33
72
  // An older server: { pending: true }, or no such route at all (a 404 without an error body).
34
73
  if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
@@ -42,7 +81,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
42
81
  let completed = false;
43
82
  let approvedBiteId = null;
44
83
  let finalStudioUrl = null;
45
- process.stdout.write(delivered ? "Waiting for the bite to finish" : "Waiting for your word in the browser");
84
+ process.stdout.write(delivered ? "Waiting for the bite to finish" : "Checking the take in DemoBites");
46
85
  while (Date.now() < deadline) {
47
86
  await new Promise((r) => setTimeout(r, pollMs));
48
87
  let res;
@@ -52,6 +91,12 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
52
91
  if (!res.ok) { process.stdout.write("."); continue; }
53
92
  const st = await res.json().catch(() => null);
54
93
  if (!st) { process.stdout.write("."); continue; }
94
+ if (st.status === "waiting") {
95
+ // Kept for recording minutes: nothing will finish until they are back.
96
+ process.stdout.write("\n");
97
+ console.log(waitingLine(pageUrl, st.resetsAt));
98
+ return { exitCode: 0, status: "waiting", biteId: null, studioUrl: null };
99
+ }
55
100
  if (st.status === "rejected") {
56
101
  process.stdout.write("\n");
57
102
  console.error("Discarded in the app. Adjust the storyboard and film again.");
@@ -77,11 +122,11 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
77
122
  }
78
123
  if (!announced) {
79
124
  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}`);
125
+ console.error(delivered ? `The server does not show the delivered bite yet. Watch it here: ${pageUrl}` : `No decision yet. The take stays on your Demos grid:\n ${pageUrl}`);
81
126
  return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
82
127
  }
83
128
  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.`);
129
+ console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — watch it on your Demos grid: ${pageUrl}`);
85
130
  return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
86
131
  }
87
132
 
@@ -1,17 +1,18 @@
1
1
  // Where a staged take stands, and the wait for its word.
2
2
  //
3
- // node status.mjs <takeDir> wait for the decision (or, delivered, for the bite to finish); reads <takeDir>/staged.json
3
+ // node status.mjs <takeDir> wait for the delivered bite to finish; reads <takeDir>/staged.json
4
4
  // a take whose delivery failed is delivered again here first (idempotent on the server)
5
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
6
+ // node status.mjs <takeDir> --no-wait one look, no waiting: delivered (+bite status) | waiting for recording minutes | pending | rejected
7
7
  // node status.mjs --all one look at every take-*/staged.json under the current directory
8
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.
9
+ // A batch delivers every take with `upload.mjs --stage-only --no-open`, then
10
+ // the agent (or the human, later) comes back here per take. The page the
11
+ // person opens is the Demos grid (<base>/demos), never the preview page. The
12
+ // same law as upload.mjs: no studio link before the bite is completed.
12
13
  import fs from "node:fs";
13
14
  import path from "node:path";
14
- import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
15
+ import { waitForDecision, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
  const noWait = args.includes("--no-wait");
@@ -27,6 +28,7 @@ let cfg = {};
27
28
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
28
29
  if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
29
30
  const base = cfg.base.replace(/\/+$/, "");
31
+ const defaultGrid = gridUrl(base, null);
30
32
 
31
33
  function readStaged(dir) {
32
34
  const p = path.join(dir, "staged.json");
@@ -39,6 +41,10 @@ function describe(st) {
39
41
  if (st.status === "rejected") return "discarded in the app";
40
42
  if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
41
43
  if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
44
+ if (st.status === "waiting") {
45
+ const when = formatResetDate(st.resetsAt);
46
+ return `waiting for recording minutes${when ? ` (back on ${when})` : ""}`;
47
+ }
42
48
  return "waiting for the word in the app";
43
49
  }
44
50
 
@@ -48,8 +54,9 @@ if (all) {
48
54
  for (const d of dirs) {
49
55
  const staged = readStaged(d);
50
56
  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}` : ""}`);
57
+ console.log(`${d.padEnd(40)} ${describe(st)}`);
52
58
  }
59
+ console.log(`Watch them here: ${defaultGrid}`);
53
60
  process.exit(0);
54
61
  }
55
62
 
@@ -60,27 +67,36 @@ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
60
67
  const staged = readStaged(target);
61
68
  if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
62
69
  stagingId = staged.stagingId;
63
- pageUrl = staged.previewUrl ?? null;
70
+ pageUrl = staged.dashboardUrl ?? null;
64
71
  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) {
72
+ // A take whose delivery failed (network, 409) is delivered again here; the
73
+ // server is idempotent. An older server (pending) and a kept take (waiting
74
+ // for minutes, the server holds it) are left alone.
75
+ if (staged.delivered === false && staged.pending !== true && !staged.waiting) {
68
76
  const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
77
+ const grid = gridUrl(base, d.dashboardUrl ?? staged.dashboardUrl);
78
+ pageUrl = grid;
79
+ const write = (patch) => { try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, dashboardUrl: grid, ...patch }, null, 2) + "\n"); } catch {} };
69
80
  if (d.delivered) {
70
81
  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 {}
82
+ console.log(`Delivered. Watch it come in: ${grid}`);
83
+ write({ delivered: true, waiting: null, biteId: d.biteId, videoId: d.videoId ?? null, studioUrl: d.studioUrl ?? null, queued: d.queued ?? null, deliveryError: null });
84
+ } else if (d.waiting) {
85
+ console.log(waitingLine(grid, d.resetsAt));
86
+ write({ waiting: d.waiting, resetsAt: d.resetsAt ?? null, deliveryError: null });
87
+ process.exit(0);
73
88
  } 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.`);
89
+ write({ pending: true });
90
+ console.log(`This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
91
+ } else console.error(`Not delivered: ${d.error}. Try again later with: node scripts/status.mjs ${target}\nYour demos: ${grid}`);
77
92
  }
78
93
  }
79
- if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
94
+ if (!pageUrl) pageUrl = defaultGrid;
80
95
 
81
96
  if (noWait) {
82
97
  const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
83
98
  console.log(`${stagingId}: ${describe(st)}`);
99
+ console.log(`Watch it here: ${pageUrl}`);
84
100
  if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
85
101
  process.exit(st.ok ? 0 : 1);
86
102
  }
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // DemoBites ending — STAGE the take and hand the word to the product.
2
+ // DemoBites ending — STAGE the take and DELIVER it.
3
3
  //
4
- // The human word lives in the app now (founder, 2026-08-09): this script
5
- // zips clean.mp4, stages it together with a playable preview MP4, opens the
6
- // in-app preview page, and POLLS while the human decides there. Approve on
7
- // that page runs the same ingest as the old machine lane; Discard reports
8
- // back here so the operator adjusts and refilms. No local review.html for
9
- // this ending anymore.
4
+ // DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): this script zips
5
+ // clean.mp4, stages it together with a playable MP4, uploads both, then calls
6
+ // the uploaded door. The take becomes a bite by itself; the person watches it
7
+ // come in on their Demos grid (<base>/demos), never on the preview page. When
8
+ // the account has no recording minutes left the server KEEPS the take and it
9
+ // waits on the same grid, next to the upgrade door.
10
10
  //
11
11
  // Contracts (fixed, coded verbatim):
12
12
  // PUT <base>/api/recorder/stage (Bearer)
13
13
  // { filename, sizeBytes, previewSizeBytes, manifest }
14
14
  // -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
15
+ // PUT <base>/api/recorder/stage/<stagingId>/uploaded (Bearer) after both uploads, EVERY take
16
+ // -> 202 { delivered: true, biteId, studioUrl, dashboardUrl }
17
+ // | 403 { kept: true, waiting: "minutes", resetsAt, dashboardUrl, stagingId }
15
18
  // GET <base>/api/recorder/stage?id=<stagingId> (Bearer)
16
19
  // -> { status, biteId, biteUKey, biteStatus, studioUrl }
17
20
  //
@@ -31,7 +34,7 @@ if (!dir) {
31
34
  }
32
35
  // RE-TAKE (2026-09-02): `--retake-of <biteId>` stages this take as a NEW RECORDING
33
36
  // of an existing bite instead of a new bite. The server keeps the bite's current
34
- // narration text, voice, intro/outro and look; the human approves in-app.
37
+ // narration text, voice, intro/outro and look; the take is delivered into it.
35
38
  const retakeIdx = process.argv.indexOf("--retake-of");
36
39
  const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
37
40
  if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
@@ -47,11 +50,9 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
47
50
  // opts out. `--stage-only` returns right after the two uploads (the batch
48
51
  // waits with status.mjs); `--supersede` replaces a stage already pinned to
49
52
  // 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.
53
+ // DELIVERY (1.3.0 for brief takes, 1.5.0 for every take): after both uploads
54
+ // this script PUTs the delivery route (the claim's api.uploaded when there is
55
+ // one, fallback <base>/api/recorder/stage/<id>/uploaded). No Approve click.
55
56
  const stageOnly = process.argv.includes("--stage-only");
56
57
  const supersede = process.argv.includes("--supersede");
57
58
  const attemptIdx = process.argv.indexOf("--attempt");
@@ -77,13 +78,18 @@ if (!cfg.api_key || !cfg.base) {
77
78
  process.exit(1);
78
79
  }
79
80
  const base = cfg.base.replace(/\/+$/, "");
80
- // A delivered take is a bite already; staging it again would make a second one.
81
+ // A delivered take is a bite already, and a kept take waits on the server;
82
+ // staging either again would make a second one.
81
83
  try {
82
84
  const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
83
85
  if (prev?.delivered === true && prev.biteId && !supersede) {
84
86
  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
87
  process.exit(1);
86
88
  }
89
+ if (prev?.waiting && !supersede) {
90
+ console.error(`${dir} is already kept in DemoBites, waiting for recording minutes. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
91
+ process.exit(1);
92
+ }
87
93
  } catch { /* not staged yet */ }
88
94
 
89
95
  const cleanPath = path.join(dir, "clean.mp4");
@@ -193,7 +199,7 @@ if (!zipped) {
193
199
  fs.rmSync(staging, { recursive: true, force: true });
194
200
  const sizeBytes = fs.statSync(zipPath).size;
195
201
  console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
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"}.`);
202
+ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite by itself once delivered.`);
197
203
 
198
204
  // ── stage ──────────────────────────────────────────────────────────────────
199
205
  const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
@@ -228,7 +234,7 @@ if (!stageRes.ok) {
228
234
  if (errBody?.error === "quota_exceeded") {
229
235
  // Should not happen anymore — staging is quota-free by design. Neutral
230
236
  // fallback if an older server answers this way.
231
- console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
237
+ console.error(`DemoBites declined the stage. Check ${base}/demos and try again.`);
232
238
  process.exit(1);
233
239
  }
234
240
  if (errBody?.error === "invalid_attempt") {
@@ -236,14 +242,14 @@ if (!stageRes.ok) {
236
242
  process.exit(1);
237
243
  }
238
244
  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.`);
245
+ console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Keep that one, or stage again with --supersede to replace it.`);
240
246
  process.exit(1);
241
247
  }
242
248
  console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
243
249
  process.exit(1);
244
250
  }
245
251
  const { stagingId, uploadUrl, previewUploadUrl, previewUrl, queueUrl, pendingCount } = await stageRes.json();
246
- if (!stagingId || !uploadUrl || !previewUploadUrl || !previewUrl) {
252
+ if (!stagingId || !uploadUrl || !previewUploadUrl) {
247
253
  console.error("Stage response missing fields.");
248
254
  process.exit(1);
249
255
  }
@@ -263,68 +269,59 @@ async function putS3(url, contentType, filePath, label) {
263
269
  await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
264
270
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
265
271
 
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
- }
272
+ // ── delivery: EVERY take becomes a bite by itself ─────────────────────────
273
+ const { deliverStaged, waitForDecision, gridUrl, waitingLine } = await import("./stage-wait.mjs");
274
+ const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
275
+ const grid = gridUrl(base, delivery.dashboardUrl);
274
276
  // The staging id used to be printed only; a batch resumes from disk, so it is
275
277
  // 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
+ // that failed). previewUrl stays for older readers; the person's page is
279
+ // dashboardUrl, the Demos grid.
280
+ const previewPage = previewUrl ? new URL(previewUrl, base).toString() : null;
278
281
  try {
279
282
  fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
280
- stagingId, previewUrl: pageUrl, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
283
+ stagingId, previewUrl: previewPage, dashboardUrl: grid, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
281
284
  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,
285
+ delivered: delivery.delivered === true, waiting: delivery.waiting ?? null, resetsAt: delivery.resetsAt ?? null,
286
+ biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
287
+ queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
284
288
  api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
285
289
  }, null, 2) + "\n");
286
290
  } catch (e) { console.error(`staged.json not written: ${e.message}`); }
287
291
 
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
-
301
- // ── open the in-app preview — the review happens THERE ─────────────────────
302
- // Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
303
- // review sprint, auto-opening a tab per take is spam. `--no-open` (or
304
- // config.open_preview === false) stages silently — the queue pill and the
305
- // printed URL carry the message. Default stays open: for a single take the
306
- // opened page IS the consent moment.
307
- console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
308
- if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
309
- console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
310
- }
292
+ // Single take: the grid opens in the browser, the person watches the card
293
+ // come in there. A batch passes --no-open (or config.open_preview === false)
294
+ // and the printed link carries the message.
311
295
  const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
312
- if (!noOpen) {
296
+ function openGrid() {
297
+ if (noOpen) return;
313
298
  try {
314
299
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
315
- spawnSync(opener, [pageUrl], { stdio: "ignore" });
316
- } catch { /* printing the URL above is the fallback */ }
300
+ spawnSync(opener, [grid], { stdio: "ignore" });
301
+ } catch { /* the printed link is the fallback */ }
317
302
  }
318
303
 
319
- if (stageOnly) {
320
- console.log(`Staged only. Wait for the decision later with: node scripts/status.mjs ${dir}`);
304
+ if (delivery.delivered) {
305
+ console.log(`Delivered. Watch it come in: ${grid}`);
306
+ openGrid();
307
+ if (stageOnly) { console.log(`Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
308
+ // LAW (founder 2026-08-08): no studio link before the bite is completed.
309
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid, delivered: true });
310
+ process.exit(outcome.exitCode);
311
+ }
312
+ if (delivery.waiting) {
313
+ console.log(waitingLine(grid, delivery.resetsAt));
314
+ openGrid();
321
315
  process.exit(0);
322
316
  }
323
-
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);
317
+ if (delivery.pending) {
318
+ // An older DemoBites that does not deliver by itself: the take waits for
319
+ // the word in the app. Still no preview link as the destination.
320
+ console.log(`Staged. This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
321
+ openGrid();
322
+ if (stageOnly) { console.log(`Wait for it later with: node scripts/status.mjs ${dir}`); process.exit(0); }
323
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid });
324
+ process.exit(outcome.exitCode);
325
+ }
326
+ console.error(`Staged, but not delivered: ${delivery.error}. Try the delivery again with: node scripts/status.mjs ${dir}\nYour demos: ${grid}`);
327
+ process.exit(1);