demobite 1.3.1 → 1.4.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,10 @@ 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
+ **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.
134
+
135
+ **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.
136
+
133
137
  **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
138
 
135
139
  The package also includes a DemoBites management MCP for releases and centers:
@@ -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.4.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
@@ -52,7 +52,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
52
52
 
53
53
  - **app**: the product's name as it should appear in titles.
54
54
  - **url**: the starting URL of the flow.
55
- - **frame**: fixed at 1920x1080 for now, do not ask, just record it.
55
+ - **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
56
  - **base**: defaults to `https://app.demobites.com`, only ask if the human mentions a different environment.
57
57
 
58
58
  Write the answers to `.recorder/config.json` and never ask again:
@@ -61,13 +61,29 @@ Write the answers to `.recorder/config.json` and never ask again:
61
61
  {
62
62
  "app": "Acme",
63
63
  "url": "https://app.acme.com",
64
- "frame": { "width": 1920, "height": 1080 },
65
64
  "base": "https://app.demobites.com"
66
65
  }
67
66
  ```
68
67
 
69
68
  `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
69
 
70
+ ## Phase 1b: Workspace rules, EVERY run
71
+
72
+ 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:
73
+
74
+ ```bash
75
+ node scripts/rules.mjs [<takeDir>] # fetches the rules fresh, writes .recorder/rules.json, prints them numbered
76
+ ```
77
+
78
+ 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:
79
+
80
+ - 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.
81
+ - 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.
82
+ - The prep, take and cleanup legs obey the rules too (a "never open" page is never opened, not even off camera).
83
+ - 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.
84
+ - 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.
85
+ - `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.
86
+
71
87
  ## Phase 2: Target-app sign-in, only when a login wall appears
72
88
 
73
89
  The camera browser uses a persistent profile at `.recorder/profile`. Signed in sessions survive between takes.
@@ -92,6 +108,17 @@ node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
92
108
 
93
109
  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
110
 
111
+ ### Phase 3b: When you sit in the app's repository, read it FIRST
112
+
113
+ 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:
114
+
115
+ - **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.
116
+ - **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.
117
+ - **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.
118
+ - **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.
119
+
120
+ 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.
121
+
95
122
  ### LAW: the camera shows an action to its end
96
123
 
97
124
  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 +161,7 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
134
161
 
135
162
  **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
163
 
137
- Storyboard schema:
164
+ Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
138
165
 
139
166
  ```json
140
167
  {
@@ -153,7 +180,7 @@ Storyboard schema:
153
180
  }
154
181
  ```
155
182
 
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`.
183
+ 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
184
 
158
185
  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
186
 
@@ -335,7 +362,7 @@ The procedure, in order:
335
362
 
336
363
  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
364
  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.
365
+ 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
366
  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
367
  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
368
  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.
@@ -387,6 +414,9 @@ PUT <base>/api/recorder/device { device_code } (poll every `interval`
387
414
  DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
388
415
  -> { revoked: true } (logout)
389
416
 
417
+ GET <base>/api/recorder/rules (Authorization: Bearer <api_key>) // WORKSPACE RULES (1.4): never cached
418
+ -> { workspaceId, rules: string | null, version, updatedAt } (the claim's api.rules is the same url; workspaceRules on the claim is the snapshot)
419
+
390
420
  GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
391
421
  -> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
392
422
 
@@ -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,
@@ -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
+ }