demobite 1.5.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,13 @@
2
2
  // of approved briefs into the agent; this script is the agent's hands:
3
3
  //
4
4
  // node briefs.mjs list <batchId> [--paste <file>] the authenticated truth for the batch (warns when a pasted bundle drifted)
5
+ // node briefs.mjs list --slug <slug> UPDATE RADAR (1.6.0): the batch behind a workflow's record code; writes
6
+ // .recorder/radar/<slug>/bundle.json (+ briefs/<briefId>.json, one draft
7
+ // per brief) and prints the briefs in order with their open questions
8
+ // node briefs.mjs refine <slug> <briefId> [--file <refined.json>] [--note "..."]
9
+ // REFACTOR PASS (1.7.0): post the brief refined on this machine (default file
10
+ // .recorder/radar/<slug>/refined/<briefId>.json) → a new revision; the Radar
11
+ // page shows it as "Refined on your machine"; rewrites the bundle
5
12
  // node briefs.mjs claim <batchId> <briefId> [--force] claim one brief → mints an attempt, creates take-<briefId>-r<revision>/brief.json
6
13
  // node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
7
14
  // planning | awaiting_storyboard_approval | recording | uploading | failed | cancelled
@@ -9,15 +16,23 @@
9
16
  //
10
17
  // Server contract (dbrec_ key, scope record):
11
18
  // GET /api/recorder/briefs?batch=<batchId>
19
+ // GET /api/recorder/briefs?slug=<slug> same payload + radar: { slug, name, workflowUrl }
20
+ // 404 { error: "unknown_slug" } · 409 { error: "not_approved" }
12
21
  // PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
13
22
  // PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
23
+ // PUT /api/recorder/briefs/<briefId>/refine { revision, contentHash, content, note? }
24
+ // -> { revision, contentHash } (409 hash_mismatch · 410 superseded)
14
25
  // The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json, and
15
26
  // after the uploads calls the delivery route from the claim's `api.uploaded`
16
27
  // (1.3.0): a take filmed from a brief becomes a bite by itself.
17
28
  //
18
- // Laws: one storyboard approval per brief, never one word for the batch.
19
- // Sequential takes, one Chrome on the profile. A failed brief never stops the
20
- // others. Never print the api_key.
29
+ // Laws (founder ruling 2026-09-26, "the fewest edits before Export and Go
30
+ // live"): every brief is refined against the repository and the running app
31
+ // BEFORE anything films, and posted back through `refine`; every open
32
+ // question is asked ONCE for the whole batch; the batch gets ONE approval;
33
+ // then batch.mjs films the takes in the background, in parallel, each on its
34
+ // own profile directory. A failed brief never stops the others. Never print
35
+ // the api_key.
21
36
  import fs from "node:fs";
22
37
  import path from "node:path";
23
38
  import crypto from "node:crypto";
@@ -25,22 +40,26 @@ import crypto from "node:crypto";
25
40
  const [, , cmd, ...rest] = process.argv;
26
41
  const flag = (name) => rest.includes(name);
27
42
  const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
28
- const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note"].includes(rest[i - 1])));
43
+ const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note", "--slug", "--file"].includes(rest[i - 1])));
44
+ // `npx demobite record <slug>` runs this script (DEMOBITE_CLI=1); its login hint is the launcher's command.
45
+ const loginHint = process.env.DEMOBITE_CLI ? "npx demobite login" : "node scripts/login.mjs";
29
46
 
30
47
  function usage(code = 2) {
31
48
  console.error(`Usage:
32
49
  node briefs.mjs list <batchId> [--paste <file>]
50
+ node briefs.mjs list --slug <slug>
51
+ node briefs.mjs refine <slug> <briefId> [--file <refined.json>] [--note "..."]
33
52
  node briefs.mjs claim <batchId> <briefId> [--force]
34
53
  node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
35
54
  node briefs.mjs release <takeDir|attemptRef> [--note "..."]`);
36
55
  process.exit(code);
37
56
  }
38
- if (!cmd || !["list", "claim", "event", "release"].includes(cmd)) usage();
57
+ if (!cmd || !["list", "claim", "event", "release", "refine"].includes(cmd)) usage();
39
58
 
40
59
  const cfgPath = path.resolve(".recorder", "config.json");
41
60
  let cfg = {};
42
61
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
43
- if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
62
+ if (!cfg.api_key || !cfg.base) { console.error(`Not connected to DemoBites. Run: ${loginHint}`); process.exit(1); }
44
63
  const base = cfg.base.replace(/\/+$/, "");
45
64
  const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
46
65
 
@@ -50,7 +69,7 @@ async function api(method, p, body) {
50
69
  res = await fetch(`${base}${p}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
51
70
  } catch (e) { console.error(`DemoBites unreachable (${e.message}).`); process.exit(1); }
52
71
  const json = await res.json().catch(() => null);
53
- if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
72
+ if (res.status === 401) { console.error(`The recorder key was refused. Run: ${loginHint}`); process.exit(1); }
54
73
  return { status: res.status, ok: res.ok, json };
55
74
  }
56
75
 
@@ -90,14 +109,116 @@ function printBatch(data) {
90
109
  async function fetchBatch(batchId) {
91
110
  const r = await api("GET", `/api/recorder/briefs?batch=${encodeURIComponent(batchId)}`);
92
111
  if (r.status === 403 && r.json?.error === "workspace_mismatch") {
93
- console.error("This key's workspace is not the batch's workspace. Log in to the right workspace (node scripts/login.mjs) and try again.");
112
+ console.error(`This key's workspace is not the batch's workspace. Log in to the right workspace (${loginHint}) and try again.`);
94
113
  process.exit(1);
95
114
  }
96
115
  if (!r.ok || !r.json?.briefs) { console.error(`Could not read batch ${batchId}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
97
116
  return r.json;
98
117
  }
99
118
 
100
- if (cmd === "list") {
119
+ /** UPDATE RADAR (1.6.0): the batch behind a workflow's record code. */
120
+ async function fetchBySlug(slug) {
121
+ const r = await api("GET", `/api/recorder/briefs?slug=${encodeURIComponent(slug)}`);
122
+ if (r.status === 404) { console.error("No batch with that code. Check the command on the Update Radar page."); process.exit(1); }
123
+ if (r.status === 409) { console.error("The briefs are not approved yet. Approve them on the Update Radar page, then run this again."); process.exit(1); }
124
+ if (r.status === 403 && r.json?.error === "workspace_mismatch") {
125
+ console.error(`This key's workspace is not the workflow's workspace. Log in to the right workspace (${loginHint}) and try again.`);
126
+ process.exit(1);
127
+ }
128
+ if (r.status === 403 && r.json?.error === "upgrade_required") { console.error("Update Radar is part of Product Awareness. Upgrade the workspace on the Update Radar page, then run this again."); process.exit(1); }
129
+ if (!r.ok || !r.json?.briefs) { console.error(`Could not read the batch for code ${slug}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
130
+ return r.json;
131
+ }
132
+
133
+ /** The record-by-code view: the workflow, the batch id, then the briefs in order (position, title, seconds). */
134
+ function printRadar(data, slug) {
135
+ const { batch, briefs, radar } = data;
136
+ const order = [...briefs].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
137
+ console.log(`Update Radar: ${radar?.name ?? slug}`);
138
+ // A radar batch has no target address: the agent films where the code
139
+ // already runs (the Record stage says so); print the target only when set.
140
+ console.log(`Batch ${batch?.id ?? "?"}${batch?.target?.url ? ` target ${batch.target.url}${batch.target.environment ? ` (${batch.target.environment})` : ""}` : ""}`);
141
+ console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most ${batch?.rules?.maxSeconds ?? 90} seconds, in this order:\n`);
142
+ const questions = [];
143
+ order.forEach((b, i) => {
144
+ const pos = String(i + 1).padStart(2, " ");
145
+ const secs = b.estimatedDurationSec ? `about ${b.estimatedDurationSec}s` : "length open";
146
+ const state = b.status && b.status !== "approved" ? ` [${b.status}${b.attempt ? `, attempt ${b.attempt.ref}` : ""}]` : "";
147
+ const refined = b.refinedLocally || b.refined ? " (refined on this machine)" : "";
148
+ console.log(` ${pos}. ${b.title} (${secs}) brief ${b.briefId} r${b.revision}${state}${refined}`);
149
+ for (const q of b.questions ?? []) questions.push({ n: i + 1, title: b.title, q: typeof q === "string" ? q : q?.text ?? JSON.stringify(q) });
150
+ });
151
+ // QUESTIONS ONCE (2026-09-26): the drafts' open questions, gathered here so
152
+ // the agent asks them in one message for the whole batch, never mid-filming.
153
+ if (questions.length) {
154
+ console.log(`\nOpen questions in the drafts (ask them once, for the whole batch, before anything films):`);
155
+ for (const { n, title, q } of questions) console.log(` brief ${n} (${title}): ${q}`);
156
+ }
157
+ if (radar?.workflowUrl) console.log(`\nWorkflow page: ${radar.workflowUrl}`);
158
+ }
159
+
160
+ /** The radar bundle on disk: bundle.json plus one draft file per brief (what
161
+ * the refactor pass starts from; the refined copy goes to refined/<briefId>.json). */
162
+ function writeRadarBundle(slug, data) {
163
+ const dir = path.resolve(".recorder", "radar", slug);
164
+ fs.mkdirSync(path.join(dir, "briefs"), { recursive: true });
165
+ const bundlePath = path.join(dir, "bundle.json");
166
+ fs.writeFileSync(bundlePath, JSON.stringify({ slug, fetchedAt: new Date().toISOString(), ...data }, null, 2) + "\n");
167
+ for (const b of data.briefs) {
168
+ const safe = String(b.briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
169
+ fs.writeFileSync(path.join(dir, "briefs", `${safe}.json`), JSON.stringify(b, null, 2) + "\n");
170
+ }
171
+ return bundlePath;
172
+ }
173
+
174
+ /** The brief's content for the refine door: ONLY the seven content fields
175
+ * the server's strict schema accepts, read from the draft file's top level or
176
+ * its `content` object; every other key a draft carries (identity, state,
177
+ * questions, author, attempts) is dropped, so a draft round-trips as is.
178
+ * The limits are the server's (a refine that breaks one answers 400
179
+ * invalid_refine): title ≤ 80, audience ≤ 60, outcome ≤ 200, flowIntent 2..8
180
+ * lines ≤ 120 each, prerequisites/exclusions ≤ 6 lines ≤ 160 each,
181
+ * estimatedDurationSec 15..90. They are checked here first, with the field
182
+ * named, so the agent trims before posting instead of reading a 400. */
183
+ const CONTENT_LIMITS = { title: 80, audience: 60, outcome: 200 };
184
+ const LIST_LIMITS = { flowIntent: { min: 2, max: 8, line: 120 }, prerequisites: { min: 0, max: 6, line: 160 }, exclusions: { min: 0, max: 6, line: 160 } };
185
+ function refineContent(obj) {
186
+ const src = obj && typeof obj === "object" && obj.content && typeof obj.content === "object" ? obj.content : obj ?? {};
187
+ const pick = (camel, snake) => src[camel] !== undefined ? src[camel] : src[snake];
188
+ const out = {};
189
+ for (const k of ["title", "audience", "outcome"]) if (src[k] !== undefined) out[k] = src[k];
190
+ const fi = pick("flowIntent", "flow_intent"); if (fi !== undefined) out.flowIntent = fi;
191
+ for (const k of ["prerequisites", "exclusions"]) if (src[k] !== undefined) out[k] = src[k];
192
+ const d = pick("estimatedDurationSec", "estimated_duration_sec"); if (d !== undefined) out.estimatedDurationSec = d;
193
+ return out;
194
+ }
195
+ /** The server's limits, checked before the PUT; returns the problems as "field: why" lines. */
196
+ function refineProblems(c) {
197
+ const out = [];
198
+ for (const [k, max] of Object.entries(CONTENT_LIMITS)) {
199
+ if (typeof c[k] !== "string" || !c[k].trim()) out.push(`${k}: required, a non-empty line`);
200
+ else if (c[k].trim().length > max) out.push(`${k}: ${c[k].trim().length} characters, the limit is ${max}`);
201
+ }
202
+ for (const [k, lim] of Object.entries(LIST_LIMITS)) {
203
+ const v = c[k] ?? [];
204
+ if (!Array.isArray(v)) { out.push(`${k}: must be a list of lines`); continue; }
205
+ if (v.length < lim.min || v.length > lim.max) out.push(`${k}: ${v.length} lines, allowed ${lim.min}..${lim.max}`);
206
+ v.forEach((line, i) => { if (typeof line !== "string" || !line.trim()) out.push(`${k}[${i}]: an empty line`); else if (line.trim().length > lim.line) out.push(`${k}[${i}]: ${line.trim().length} characters, the limit is ${lim.line}`); });
207
+ }
208
+ const d = c.estimatedDurationSec;
209
+ if (!Number.isInteger(d) || d < 15 || d > 90) out.push(`estimatedDurationSec: ${JSON.stringify(d)}, must be a whole number of seconds 15..90`);
210
+ return out;
211
+ }
212
+
213
+ if (cmd === "list" && opt("--slug") !== null) {
214
+ const slug = String(opt("--slug")).trim().toLowerCase();
215
+ if (!/^[a-z0-9-]{1,32}$/.test(slug)) { console.error("The record code is the short code on the Update Radar page, like k3fx9q."); process.exit(2); }
216
+ const data = await fetchBySlug(slug);
217
+ const bundlePath = writeRadarBundle(slug, data);
218
+ printRadar(data, slug);
219
+ const rel = path.relative(process.cwd(), bundlePath) || bundlePath;
220
+ console.log(`Bundle written: ${rel} (one draft per brief in ${path.dirname(rel)}/briefs/, refined copies go to ${path.dirname(rel)}/refined/)`);
221
+ } else if (cmd === "list") {
101
222
  const batchId = positional[0];
102
223
  if (!batchId) usage();
103
224
  const data = await fetchBatch(batchId);
@@ -120,6 +241,39 @@ if (cmd === "list") {
120
241
  }
121
242
  }
122
243
 
244
+ // REFACTOR PASS (1.7.0): post a brief refined on this machine. The server
245
+ // mints a new revision + contentHash for it and the Radar page shows the
246
+ // refined text ("Refined on your machine"); the claim then takes the new
247
+ // revision, so the bundle is rewritten from the server right after.
248
+ if (cmd === "refine") {
249
+ const [slugArg, briefId] = positional;
250
+ const slug = String(slugArg ?? "").trim().toLowerCase();
251
+ if (!/^[a-z0-9-]{1,32}$/.test(slug) || !briefId) usage();
252
+ const safe = String(briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
253
+ const file = opt("--file") ?? path.resolve(".recorder", "radar", slug, "refined", `${safe}.json`);
254
+ let refined;
255
+ try { refined = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { console.error(`Refined brief not readable at ${file}: ${e.message}\nWrite the refined brief there (copy .recorder/radar/${slug}/briefs/${safe}.json and edit it), or pass --file.`); process.exit(2); }
256
+ const content = refineContent(refined);
257
+ const problems = refineProblems(content);
258
+ if (problems.length) { console.error(`${file}: the refined brief breaks the server's limits — fix these and post again:\n ${problems.join("\n ")}`); process.exit(2); }
259
+ const data = await fetchBySlug(slug);
260
+ const brief = data.briefs.find((b) => String(b.briefId) === String(briefId));
261
+ if (!brief) { console.error(`Brief ${briefId} is not in the batch behind code ${slug}.`); process.exit(1); }
262
+ const note = opt("--note");
263
+ const r = await api("PUT", `/api/recorder/briefs/${encodeURIComponent(brief.briefId)}/refine`, {
264
+ revision: brief.revision, contentHash: brief.contentHash, content, ...(note ? { note: note.slice(0, 600) } : {}),
265
+ });
266
+ if (r.status === 404 && !r.json?.error) { console.error("This DemoBites has no refine route yet (older server). Film from the refined file on this machine; the Radar page keeps the cloud draft."); process.exit(1); }
267
+ if (r.status === 409 && (r.json?.error === "hash_mismatch" || r.json?.error === "active_attempt")) { console.error(`Brief ${briefId}: ${r.json.error === "active_attempt" ? "a live attempt holds it; a refine lands before the claim, never after" : "the server's revision moved; run list --slug again and refine from the current draft"}.`); process.exit(1); }
268
+ if (r.status === 410 && r.json?.error === "take_delivered") { console.error(`Brief ${briefId} is locked: a take was delivered for it, so its text on the Radar page is frozen. Film the re-take from your refined file; the Radar page keeps the delivered wording.`); process.exit(1); }
269
+ if (r.status === 410) { console.error(`Brief ${briefId} r${brief.revision} was superseded by a newer revision. Run list --slug again.`); process.exit(1); }
270
+ if (!r.ok || r.json?.revision === undefined) { console.error(`Refine failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
271
+ console.log(`Refined ${brief.briefId}: r${brief.revision} → r${r.json.revision} (${r.json.contentHash ?? "hash from the server"}). The Radar page shows it as refined on this machine.`);
272
+ const fresh = await fetchBySlug(slug);
273
+ writeRadarBundle(slug, fresh);
274
+ console.log(`Bundle rewritten: .recorder/radar/${slug}/bundle.json`);
275
+ }
276
+
123
277
  if (cmd === "claim") {
124
278
  const [batchId, briefId] = positional;
125
279
  if (!batchId || !briefId) usage();
@@ -32,7 +32,8 @@ if (steps.length === 0 && checks.length === 0) {
32
32
  process.exit(0);
33
33
  }
34
34
 
35
- const profileDir = path.resolve(".recorder", "profile");
35
+ // RECORDER_PROFILE (1.7.0): a batch's parallel takes each prep and clean on their own seeded profile.
36
+ const profileDir = path.resolve(process.env.RECORDER_PROFILE || path.join(".recorder", "profile"));
36
37
  const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless: true, viewport: { width: 1920, height: 1080 } }).catch(async () =>
37
38
  chromium.launchPersistentContext(profileDir, { headless: true, viewport: { width: 1920, height: 1080 } }),
38
39
  );
@@ -194,6 +194,9 @@ const camera = (man.shots ?? [])
194
194
  label: s.label ?? undefined,
195
195
  ...(s.n != null ? { n: s.n } : {}),
196
196
  ...(s.revealed ? { revealed: true } : {}),
197
+ // WIDE (founder 2026-09-27): the camera at 1.0 for the span — the server
198
+ // ends the previous zoom at its start and writes no zoom row for it.
199
+ ...(s.wide ? { wide: true } : {}),
197
200
  ...(s.glide ? { glide: { t_start: norm(s.glide.t_start), t_end: norm(s.glide.t_end) } } : {}),
198
201
  }))
199
202
  .filter((s) => s.t_end > s.t_start && s.t_start < duration && s.w > 0 && s.h > 0)
@@ -238,8 +241,15 @@ if (fs.existsSync(cleanPath)) {
238
241
  const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * FW * FH * SS * SS : 0;
239
242
  return { x: x / SS, y: y / SS, w: w / SS, h: h / SS, changedPx: changedPx / (SS * SS) };
240
243
  };
244
+ // FRAMING (founder 2026-09-27): a step the director framed `close` keeps its
245
+ // subject box — the page redrawing after an option pick is the landing, and
246
+ // the wide beat that follows frames it. The storyboard copy in the take dir
247
+ // is the recipe, so the frame is read from it by step number.
248
+ let framedClose = new Set();
249
+ try { const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8")); (sb.steps ?? []).forEach((s, i) => { if (s?.frame === "close") framedClose.add(i + 1); }); } catch {}
241
250
  for (const st of steps) {
242
251
  if (!st.click) continue;
252
+ if (framedClose.has(st.n)) continue;
243
253
  const hasRevealedShot = camera.some((c) => c.n === st.n && c.revealed);
244
254
  if (hasRevealedShot) continue;
245
255
  const control = camera.find((c) => c.n === st.n && !c.revealed);
@@ -269,7 +279,9 @@ if (fs.existsSync(cleanPath)) {
269
279
  const wideStart = round2(st.click.t);
270
280
  const wideEnd = round2(Math.min(duration, st.t_end));
271
281
  if (wideEnd - wideStart >= 0.6) {
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` });
282
+ // `wide: true` so the server frames it at 1.0 (an untagged full frame
283
+ // used to land on the 1.25 zoom floor — a phantom zoom on the new page).
284
+ camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: FW, h: FH, n: st.n, wide: true, label: `${st.label || "click"}, new page` });
273
285
  camera.sort((a, b) => a.t_start - b.t_start);
274
286
  }
275
287
  console.log(`consequence: step ${st.n} navigation — reset to wide at ${wideStart}s`);
@@ -5,16 +5,28 @@
5
5
  // is fetched from DemoBites; the server keeps the bite's CURRENT narration
6
6
  // text, voice, intro/outro and look, and refits camera, cuts and audio.
7
7
  //
8
- // Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
8
+ // Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>] [--fresh]
9
9
  //
10
- // Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
10
+ // --take <dir> film into an existing take dir. Its storyboard.json is the
11
+ // SOURCE OF TRUTH and is never overwritten: fix a selector in
12
+ // it and run again with --take, the fix stays. The recipe is
13
+ // written only when the file is absent.
14
+ // --fresh with --take: discard the dir's storyboard.json and write
15
+ // the bite's recipe again.
16
+ //
17
+ // Flow (the same legs, the same scripts, as a normal take — see batch.mjs):
18
+ // recipe -> storyboard.json -> cleanup.mjs --prep (when the storyboard
19
+ // declares prep[] / checks.before[]) -> record -> cleanup.mjs (when it
20
+ // declares cleanup[] / checks.after[]) -> trim -> calibrate (gate) ->
11
21
  // manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
12
- // the person watches it on the Demos grid).
22
+ // the person watches it on the Demos grid). upload.mjs refuses a take whose
23
+ // storyboard declares cleanup[] until cleanup.json passes, so the cleanup leg
24
+ // is not optional here.
13
25
  //
14
26
  // A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
15
27
  // That is the moment for the agent to look at the live page, fix the
16
28
  // storyboard (or drop the beat AND its line, and tell the human what is gone),
17
- // and run again. Nothing is staged until every step resolves.
29
+ // and run again with --take. Nothing is staged until every step resolves.
18
30
  import fs from "node:fs";
19
31
  import path from "node:path";
20
32
  import { fileURLToPath } from "node:url";
@@ -22,19 +34,34 @@ import { spawnSync } from "node:child_process";
22
34
 
23
35
  const here = path.dirname(fileURLToPath(import.meta.url));
24
36
  const args = process.argv.slice(2);
37
+ const USAGE = 'Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>] [--fresh]';
38
+ if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
25
39
  const biteId = Number(args[0]);
26
40
  if (!Number.isInteger(biteId) || biteId <= 0) {
27
- console.error('Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]');
41
+ console.error(USAGE);
28
42
  process.exit(2);
29
43
  }
30
44
  const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
31
45
  const note = opt("--note") ?? "";
46
+ const fresh = args.includes("--fresh");
32
47
  const cfgPath = path.resolve(".recorder", "config.json");
33
48
  let cfg = {};
34
49
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
35
50
  if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
36
51
  const base = cfg.base.replace(/\/+$/, "");
37
52
 
53
+ // Where the scripts are: installed, every script sits beside this file; in the
54
+ // package repository record/trim/calibrate live in ../../scripts (batch.mjs
55
+ // resolves the same two layouts).
56
+ function scriptPath(name) {
57
+ for (const dir of [here, path.resolve(here, "..", "..", "scripts")]) {
58
+ const p = path.join(dir, name);
59
+ if (fs.existsSync(p)) return p;
60
+ }
61
+ console.error(`${name} not found beside ${here}. Run \`npx demobite\` to refresh the skill.`);
62
+ process.exit(1);
63
+ }
64
+
38
65
  // 1. The recipe — the bite's DNA.
39
66
  const res = await fetch(`${base}/api/recorder/recipe?biteId=${biteId}`, { headers: { Authorization: `Bearer ${cfg.api_key}` } });
40
67
  if (res.status === 404) {
@@ -48,26 +75,57 @@ if (res.status === 402 || res.status === 403) {
48
75
  }
49
76
  if (!res.ok) { console.error(`Could not fetch the recipe (${res.status}).`); process.exit(1); }
50
77
  const recipe = await res.json();
51
- const storyboard = recipe.storyboard;
52
- if (!storyboard?.steps?.length) { console.error("The recipe has no storyboard steps."); process.exit(1); }
78
+ if (!recipe.storyboard?.steps?.length) { console.error("The recipe has no storyboard steps."); process.exit(1); }
53
79
 
54
- // 2. Take dir + files.
80
+ // 2. Take dir + files. With --take, an existing storyboard.json in the dir is
81
+ // the source of truth (the "fix a selector, run again" loop); the recipe is
82
+ // written only when the file is absent, or when --fresh asks for it.
55
83
  let takeDir = opt("--take");
56
84
  if (!takeDir) { let n = 1; while (fs.existsSync(`take-retake-${biteId}${n > 1 ? n : ""}`)) n++; takeDir = `take-retake-${biteId}${n > 1 ? n : ""}`; }
57
85
  fs.mkdirSync(takeDir, { recursive: true });
58
86
  const sbPath = path.join(takeDir, "storyboard.json");
59
- fs.writeFileSync(sbPath, JSON.stringify(storyboard, null, 2));
87
+ let storyboard;
88
+ if (fs.existsSync(sbPath) && !fresh) {
89
+ try { storyboard = JSON.parse(fs.readFileSync(sbPath, "utf8")); } catch (e) { console.error(`${sbPath} is not valid JSON (${e.message}). Fix it, or start over with --fresh.`); process.exit(1); }
90
+ if (!storyboard?.steps?.length) { console.error(`${sbPath} has no steps. Fix it, or start over with --fresh.`); process.exit(1); }
91
+ console.log(`Using the storyboard already in ${sbPath} (kept as is; --fresh replaces it from the recipe).`);
92
+ } else {
93
+ storyboard = recipe.storyboard;
94
+ fs.writeFileSync(sbPath, JSON.stringify(storyboard, null, 2));
95
+ if (fresh) console.log(`${sbPath} replaced from the recipe (--fresh).`);
96
+ }
60
97
  fs.writeFileSync(path.join(takeDir, "retake.json"), JSON.stringify({ biteId, note, engine: recipe.engine ?? null, fetchedAt: new Date().toISOString() }, null, 2));
61
- console.log(`Re-take of bite ${biteId} — ${storyboard.steps.length} steps from the recipe${recipe.engine ? ` (filmed by ${recipe.engine})` : ""}.`);
98
+ console.log(`Re-take of bite ${biteId} — ${storyboard.steps.length} steps${recipe.engine ? ` (filmed by ${recipe.engine})` : ""}.`);
62
99
  if (note) console.log(`Note from the human: ${note}`);
100
+ const hasPrep = (storyboard.prep?.length ?? 0) > 0 || (storyboard.checks?.before?.length ?? 0) > 0;
101
+ const hasCleanup = (storyboard.cleanup?.length ?? 0) > 0 || (storyboard.checks?.after?.length ?? 0) > 0;
63
102
 
64
- // 3. Film -> trim -> calibrate (gate) -> manifest -> stage.
65
- const run = (script, extra) => spawnSync("node", [path.join(here, script), ...extra], { stdio: "inherit", cwd: process.cwd() });
66
- let r = run("record.mjs", [takeDir, sbPath]);
103
+ // 3. Prep (off camera) -> film -> cleanup (off camera) -> trim -> calibrate
104
+ // (gate) -> manifest -> stage. The same child scripts a normal take runs.
105
+ const run = (script, extra) => spawnSync("node", [scriptPath(script), ...extra], { stdio: "inherit", cwd: process.cwd() });
106
+ const again = `node scripts/retake.mjs ${biteId} --take ${takeDir}${note ? ` --note ${JSON.stringify(note)}` : ""}`;
107
+ let r;
108
+ if (hasPrep) {
109
+ console.log("Prep, off camera (storyboard.prep[] then checks.before[])...");
110
+ r = run("cleanup.mjs", [takeDir, "--prep"]);
111
+ if (r.status !== 0) {
112
+ console.error(`\nPrep did not pass (see ${path.join(takeDir, "prep.json")}). Nothing was filmed. Fix prep[] / checks.before[] in ${sbPath}, then run:\n ${again}`);
113
+ process.exit(r.status ?? 1);
114
+ }
115
+ }
116
+ r = run("record.mjs", [takeDir, sbPath]);
67
117
  if (r.status !== 0) {
68
- console.error(`\nThe take stopped at a step that no longer resolves. Look at the live page, fix the storyboard in ${sbPath}\n(or drop the beat and its line, and tell the human what is gone), then run:\n node scripts/retake.mjs ${biteId} --take ${takeDir}${note ? ` --note ${JSON.stringify(note)}` : ""}`);
118
+ console.error(`\nThe take stopped at a step that no longer resolves. Look at the live page, fix the storyboard in ${sbPath}\n(or drop the beat and its line, and tell the human what is gone), then run:\n ${again}`);
69
119
  process.exit(r.status ?? 1);
70
120
  }
121
+ if (hasCleanup) {
122
+ console.log("Cleanup, off camera (storyboard.cleanup[] then checks.after[])...");
123
+ r = run("cleanup.mjs", [takeDir]);
124
+ if (r.status !== 0) {
125
+ console.error(`\nCleanup did not pass (see ${path.join(takeDir, "cleanup.json")}); the workspace may still carry what the take created, and upload.mjs will not stage it until cleanup.json says ok.\nFix cleanup[] / checks.after[] in ${sbPath} if a step no longer resolves, then, without filming again:\n node scripts/cleanup.mjs ${takeDir}\n node scripts/trim.mjs ${takeDir} && node scripts/calibrate.mjs ${takeDir} && node scripts/manifest.mjs ${takeDir}\n node scripts/upload.mjs ${takeDir} --retake-of ${biteId}${note ? ` --note ${JSON.stringify(note)}` : ""}`);
126
+ process.exit(4);
127
+ }
128
+ }
71
129
  r = run("trim.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
72
130
  r = run("calibrate.mjs", [takeDir]);
73
131
  if (r.status !== 0) { console.error("Calibration failed — do not stage this take. Investigate record_from / anchors and film again."); process.exit(3); }
@@ -149,6 +149,125 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
149
149
  return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
150
150
  }
151
151
 
152
+ // ── RE-TAKE: wait for the NEW version, not the bite ────────────────────────
153
+ // A re-take (`upload.mjs --retake-of <biteId>`) lands INSIDE a bite that is
154
+ // already `completed`: the server ingests the new recording as a new
155
+ // uploaded_videos row + a 'processing' recipe row, and finalize's atomic swap
156
+ // repoints the bite and flips that row to 'live'. The bite's status never
157
+ // leaves `completed`, so waitForDecision returned on its first poll and
158
+ // printed the PREVIOUS version's receipt (bite 1110 read 45.1s where the new
159
+ // take was 44.6s, 2026-09-27). The marker that moves exactly at the swap and
160
+ // that a recorder key can read is the LIVE recipe (GET /api/recorder/recipe):
161
+ // after the swap its manifest is the one this take staged. The ingest
162
+ // rewrites only steps[].narration.text (the bite's current lines), so the
163
+ // manifest's duration and step windows survive verbatim and identify THIS
164
+ // take. When the status route ever reports the bite's video id
165
+ // (`videoId`), it is compared to the delivery's videoId first.
166
+
167
+ /** What a staged manifest leaves on the live recipe: duration + step windows. */
168
+ export function manifestFingerprint(manifest) {
169
+ if (!manifest || typeof manifest !== "object") return null;
170
+ const steps = Array.isArray(manifest.steps) ? manifest.steps : [];
171
+ if (!Number.isFinite(Number(manifest.duration)) && steps.length === 0) return null;
172
+ return JSON.stringify({
173
+ d: manifest.duration ?? null,
174
+ s: steps.map((s, i) => [s?.n ?? i + 1, s?.t_start ?? null, s?.t_end ?? null]),
175
+ });
176
+ }
177
+
178
+ /** The live recipe's fingerprint as the server sees it now, or null when it cannot be read. */
179
+ export async function readLiveRecipeFingerprint({ base, apiKey, biteId }) {
180
+ try {
181
+ const res = await fetch(`${base}/api/recorder/recipe?biteId=${encodeURIComponent(biteId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
182
+ if (!res.ok) return null;
183
+ const recipe = await res.json().catch(() => null);
184
+ return manifestFingerprint(recipe?.manifest);
185
+ } catch { return null; }
186
+ }
187
+
188
+ /** GET /api/recorder/status for a bite, or null when it cannot be read. */
189
+ export async function readBiteStatus({ base, apiKey, biteId }) {
190
+ try {
191
+ const res = await fetch(`${base}/api/recorder/status?biteId=${encodeURIComponent(biteId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
192
+ if (!res.ok) return null;
193
+ return await res.json().catch(() => null);
194
+ } catch { return null; }
195
+ }
196
+
197
+ /** The numbers the receipt prints; two equal reads in a row mean the post-swap manifest application has settled. */
198
+ function receiptKey(st) {
199
+ return st ? JSON.stringify([st.status, st.durationSec ?? null, st.zooms ?? null, st.narrationTotal ?? null, st.narrationReady ?? null, st.title ?? null]) : null;
200
+ }
201
+
202
+ /** Wait until the re-take is the bite's live recording, then print ITS receipt.
203
+ * `manifest` is the staged wire manifest, `before` the live fingerprint read
204
+ * BEFORE staging (null when unknown), `videoId` the delivery's new video id.
205
+ * Returns { exitCode, status, biteId, studioUrl } and never throws. */
206
+ export async function waitForRetake({ base, apiKey, biteId, videoId = null, manifest = null, before = null, pageUrl, studioUrl = null, timeoutMs = 30 * 60 * 1000, pollMs = 4000, settleMs = 30 * 1000 }) {
207
+ const ours = manifestFingerprint(manifest);
208
+ if (ours && before && ours === before) {
209
+ console.error("Note: the bite's live recording already carries this take's manifest; the receipt below may describe it rather than a newer version.");
210
+ }
211
+ const deadline = Date.now() + timeoutMs;
212
+ let landed = false;
213
+ let how = null;
214
+ process.stdout.write(`Waiting for the new recording to land in bite ${biteId}`);
215
+ while (Date.now() < deadline) {
216
+ await new Promise((r) => setTimeout(r, pollMs));
217
+ const st = await readBiteStatus({ base, apiKey, biteId });
218
+ if (st?.status === "failed") {
219
+ process.stdout.write("\n");
220
+ console.error(`The pipeline FAILED while re-taking bite ${biteId}. The bite keeps its previous recording. Do not hand over any link — investigate.`);
221
+ return { exitCode: 1, status: "failed", biteId, studioUrl: null };
222
+ }
223
+ // 1. The server names the bite's video: exact.
224
+ if (videoId && st && Number.isFinite(Number(st.videoId))) {
225
+ if (Number(st.videoId) === Number(videoId)) { landed = true; how = "video"; break; }
226
+ process.stdout.write("."); continue;
227
+ }
228
+ // 2. The live recipe carries this take's manifest: exact. Or it moved
229
+ // away from what was live before staging: this take, or a newer one.
230
+ const fp = await readLiveRecipeFingerprint({ base, apiKey, biteId });
231
+ if (fp && ours && fp === ours) { landed = true; how = "recipe"; break; }
232
+ if (fp && before && fp !== before) { landed = true; how = "moved"; break; }
233
+ process.stdout.write(".");
234
+ }
235
+ process.stdout.write("\n");
236
+ if (!landed) {
237
+ console.error(`The new recording has not landed in bite ${biteId} within the wait window; the bite still plays its previous version. No receipt for the new one yet — watch it on your Demos grid: ${pageUrl}\n(wait again later with: node scripts/status.mjs <takeDir>)`);
238
+ return { exitCode: 1, status: "delivered", biteId, studioUrl: null };
239
+ }
240
+ console.log(`New recording landed in bite ${biteId}${how === "moved" ? " (the live recording changed after staging)" : ""}.`);
241
+
242
+ // The swap is followed by the manifest application (camera shots, narration
243
+ // rows) on the new version; read until the numbers hold still.
244
+ let last = await readBiteStatus({ base, apiKey, biteId });
245
+ const settleDeadline = Date.now() + settleMs;
246
+ let prevKey = receiptKey(last);
247
+ while (Date.now() < settleDeadline) {
248
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, 3000)));
249
+ const st = await readBiteStatus({ base, apiKey, biteId });
250
+ const key = receiptKey(st);
251
+ if (st && key === prevKey && st.status === "completed" && (st.narrationTotal ?? 0) === (st.narrationReady ?? 0)) { last = st; break; }
252
+ if (st) { last = st; prevKey = key; }
253
+ }
254
+ if (last && last.status === "completed") {
255
+ console.log(
256
+ `Ready (new recording): "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
257
+ `${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
258
+ );
259
+ if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
260
+ else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
261
+ if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
262
+ } else {
263
+ console.error(`The new recording landed but the status door did not answer with a completed bite${last?.status ? ` (${last.status})` : ""}. Watch it here: ${pageUrl}`);
264
+ return { exitCode: 1, status: last?.status ?? "unknown", biteId, studioUrl: null };
265
+ }
266
+ const finalStudioUrl = studioUrl ? new URL(studioUrl, base).toString() : null;
267
+ if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
268
+ return { exitCode: 0, status: "completed", biteId, studioUrl: finalStudioUrl };
269
+ }
270
+
152
271
  /** One look, no waiting: the staged take's current status as the server sees it. */
153
272
  export async function peekStaged({ base, apiKey, stagingId }) {
154
273
  const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
@@ -12,7 +12,7 @@
12
12
  // same law as upload.mjs: no studio link before the bite is completed.
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { waitForDecision, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
15
+ import { waitForDecision, waitForRetake, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
16
16
 
17
17
  const args = process.argv.slice(2);
18
18
  const noWait = args.includes("--no-wait");
@@ -63,12 +63,20 @@ if (all) {
63
63
  let stagingId = target;
64
64
  let pageUrl = null;
65
65
  let delivered = false;
66
+ // A re-take (staged with --retake-of) waits for its NEW version, not the bite,
67
+ // which is completed already; upload.mjs left the baseline in staged.json.
68
+ let retake = null;
66
69
  if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
67
70
  const staged = readStaged(target);
68
71
  if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
69
72
  stagingId = staged.stagingId;
70
73
  pageUrl = staged.dashboardUrl ?? null;
71
74
  delivered = staged.delivered === true;
75
+ if (Number.isInteger(staged.retakeOfBiteId) && staged.retakeOfBiteId > 0) {
76
+ let manifest = null;
77
+ try { manifest = JSON.parse(fs.readFileSync(path.join(target, "manifest.demobites.json"), "utf8")); } catch {}
78
+ retake = { biteId: staged.retakeOfBiteId, before: staged.retakeBefore ?? null, videoId: staged.videoId ?? null, studioUrl: staged.studioUrl ?? null, manifest };
79
+ }
72
80
  // A take whose delivery failed (network, 409) is delivered again here; the
73
81
  // server is idempotent. An older server (pending) and a kept take (waiting
74
82
  // for minutes, the server holds it) are left alone.
@@ -101,5 +109,7 @@ if (noWait) {
101
109
  process.exit(st.ok ? 0 : 1);
102
110
  }
103
111
 
104
- const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
112
+ const outcome = retake && delivered
113
+ ? await waitForRetake({ base, apiKey: cfg.api_key, biteId: retake.biteId, videoId: retake.videoId, manifest: retake.manifest, before: retake.before, pageUrl, studioUrl: retake.studioUrl })
114
+ : await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
105
115
  process.exit(outcome.exitCode);
@@ -214,6 +214,11 @@ if (attempt) {
214
214
  } catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
215
215
  }
216
216
  const previewSizeBytes = fs.statSync(cleanPath).size;
217
+ const { deliverStaged, waitForDecision, waitForRetake, readLiveRecipeFingerprint, gridUrl, waitingLine } = await import("./stage-wait.mjs");
218
+ // RE-TAKE receipt (2026-09-27): the bite stays `completed` while the new
219
+ // version is ingested, so the wait must watch the version, not the bite. The
220
+ // live recipe's fingerprint BEFORE staging is the baseline it must move from.
221
+ const retakeBefore = retakeOfBiteId ? await readLiveRecipeFingerprint({ base, apiKey: cfg.api_key, biteId: retakeOfBiteId }) : null;
217
222
  let stageRes;
218
223
  try {
219
224
  stageRes = await fetch(`${base}/api/recorder/stage`, {
@@ -270,7 +275,6 @@ await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
270
275
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
271
276
 
272
277
  // ── delivery: EVERY take becomes a bite by itself ─────────────────────────
273
- const { deliverStaged, waitForDecision, gridUrl, waitingLine } = await import("./stage-wait.mjs");
274
278
  const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
275
279
  const grid = gridUrl(base, delivery.dashboardUrl);
276
280
  // The staging id used to be printed only; a batch resumes from disk, so it is
@@ -286,6 +290,7 @@ try {
286
290
  biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
287
291
  queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
288
292
  api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
293
+ retakeOfBiteId: retakeOfBiteId ?? null, retakeBefore,
289
294
  }, null, 2) + "\n");
290
295
  } catch (e) { console.error(`staged.json not written: ${e.message}`); }
291
296
 
@@ -304,9 +309,12 @@ function openGrid() {
304
309
  if (delivery.delivered) {
305
310
  console.log(`Delivered. Watch it come in: ${grid}`);
306
311
  openGrid();
307
- if (stageOnly) { console.log(`Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
312
+ if (stageOnly) { console.log(`Wait for the ${retakeOfBiteId ? "new recording" : "bite"} to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
308
313
  // 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 });
314
+ // A re-take's bite is completed already: wait for the NEW version instead.
315
+ const outcome = retakeOfBiteId
316
+ ? await waitForRetake({ base, apiKey: cfg.api_key, biteId: retakeOfBiteId, videoId: delivery.videoId ?? null, manifest, before: retakeBefore, pageUrl: grid, studioUrl: delivery.studioUrl ?? null })
317
+ : await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid, delivered: true });
310
318
  process.exit(outcome.exitCode);
311
319
  }
312
320
  if (delivery.waiting) {