demobite 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,8 @@ When your UI changes, **Retake** can refilm an existing Bite from its saved reci
130
130
  npx demobite retake <biteId> --note "Export moved to the header"
131
131
  ```
132
132
 
133
+ **Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. A delivered take becomes a Bite in DemoBites by itself; there is no second review click for these takes. Nothing is published or shared.
134
+
133
135
  The package also includes a DemoBites management MCP for releases and centers:
134
136
 
135
137
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
package/skill/SKILL.md CHANGED
@@ -9,7 +9,7 @@ You are the camera operator, the director, and the editor. You film a real brows
9
9
 
10
10
  All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+. `npx demobite` installs Playwright, ffmpeg and ffprobe beside the skill; every script resolves the media tools through `scripts/media-tools.mjs` (a compatible system build first, then the packaged one). Never call `ffmpeg` or `ffprobe` by bare name in a new script. Run every script from the project directory so `.recorder/` lands next to the project.
11
11
 
12
- Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
12
+ Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word. For a batch of briefs the word was given twice already, on the batch and on each storyboard: a delivered take becomes a bite by itself (see Batch of briefs).
13
13
 
14
14
  ## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
15
15
 
@@ -313,7 +313,9 @@ Laws for a re-take:
313
313
  with the bite's current text per step. Only remove lines whose beats you dropped.
314
314
  - **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
315
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.
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.
317
319
 
318
320
  ## Batch of briefs (GitHub PR → demos)
319
321
 
@@ -324,9 +326,9 @@ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approve
324
326
  node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
325
327
  node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
326
328
  node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
327
- node scripts/upload.mjs <takeDir> --stage-only --no-open # stage with the attempt riding along, do not wait
328
- node scripts/status.mjs <takeDir> # later: wait for the word, then for the bite
329
- node scripts/status.mjs --all # one look at every staged take here
329
+ node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself, do not wait
330
+ node scripts/status.mjs <takeDir> # later: wait for the bite to finish (retries a failed delivery)
331
+ node scripts/status.mjs --all # one look at every delivered take here
330
332
  ```
331
333
 
332
334
  The procedure, in order:
@@ -335,11 +337,11 @@ The procedure, in order:
335
337
  2. Claim the briefs you are about to film, one `claim` each. A claim answers "active attempt" when another agent or an earlier run holds the brief: show the human the attempt reference and its start time, and only with their word claim again with `--force`.
336
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.
337
339
  4. **Show the storyboards together, get a word on each one.** One message can carry all of them, but every brief gets its own yes or no. Never take one yes as a yes for the batch. A brief the human declines gets `release`.
338
- 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 and stages with the attempt on the payload; it writes `staged.json` with the staging id.
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.
339
341
  6. **A failed brief never stops the others.** On a failure send `event <takeDir> failed --note "<what happened>"`, keep the take directory for diagnosis, and continue with the next brief. Report every failure plainly at the end.
340
- 7. When all takes are staged, tell the human: N takes are waiting in the review queue (the `queueUrl` printed by the last stage), one Approve or Discard each. Then `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one.
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.
341
343
 
342
- 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 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 staged unless the human asked for a new take (`upload.mjs --supersede`).
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`).
343
345
 
344
346
  ## The wire manifest (fixed contract, version 2)
345
347
 
@@ -394,7 +396,12 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
394
396
  -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
395
397
 
396
398
  GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
397
- -> { status: 'pending'|'approving'|'approved'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
399
+ -> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
400
+
401
+ PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: brief takes only, after both uploads
402
+ {} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded"); this path is the fallback
403
+ -> 200 { biteId, videoId } | 202 { biteId, queued:true } | 200 { pending:true } (older server: wait for the word) | 404/409 { error }
404
+ // idempotent: a repeat returns the same bite
398
405
 
399
406
  GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
400
407
  -> { status, title, durationSec, narrationReady, narrationTotal, zooms } (STARTS the pipeline, not done)
@@ -409,5 +416,5 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
409
416
 
410
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.
411
418
  - Never touch credentials. Never print the api_key. Config and key files are chmod 600.
412
- - 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.
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.
413
420
  - One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on. A take claimed from a brief is `take-<briefId>-r<revision>`.
@@ -11,7 +11,9 @@
11
11
  // GET /api/recorder/briefs?batch=<batchId>
12
12
  // PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
13
13
  // PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
14
- // The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json.
14
+ // The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json, and
15
+ // after the uploads calls the delivery route from the claim's `api.uploaded`
16
+ // (1.3.0): a take filmed from a brief becomes a bite by itself.
15
17
  //
16
18
  // Laws: one storyboard approval per brief, never one word for the batch.
17
19
  // Sequential takes, one Chrome on the profile. A failed brief never stops the
@@ -140,7 +142,7 @@ if (cmd === "claim") {
140
142
  const record = {
141
143
  batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
142
144
  brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
143
- source: data.batch?.source ?? null, claimedAt: new Date().toISOString(),
145
+ source: data.batch?.source ?? null, api: r.json.api ?? data.api ?? null, claimedAt: new Date().toISOString(),
144
146
  };
145
147
  fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
146
148
  console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
@@ -8,15 +8,41 @@
8
8
  // confirmed "completed".
9
9
  //
10
10
  // Returns { exitCode, status, biteId, studioUrl } and never throws.
11
+ //
12
+ // DELIVERY (1.3.0, founder ruling 2026-09-14): a take filmed from a brief
13
+ // becomes a bite by itself once its ZIP is uploaded; no Approve click. The
14
+ // server answers status "delivered" from then on, and the wait here is only
15
+ // for the bite to finish. Free-prompt takes still wait for the word.
16
+
17
+ /** PUT the delivery route for a staged take. `template` is the claim's
18
+ * api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
19
+ * the fallback is the same path under the configured base. Idempotent on the
20
+ * server: a repeat returns the same bite. Never throws. */
21
+ export async function deliverStaged({ base, apiKey, stagingId, template }) {
22
+ const url = template && template.includes("{id}")
23
+ ? template.replace("{id}", encodeURIComponent(stagingId))
24
+ : `${base}/api/recorder/stage/${encodeURIComponent(stagingId)}/uploaded`;
25
+ let res;
26
+ try {
27
+ res = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: "{}" });
28
+ } catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
29
+ const json = await res.json().catch(() => null);
30
+ if ((res.status === 200 || res.status === 202) && json?.biteId) {
31
+ return { delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status };
32
+ }
33
+ // An older server: { pending: true }, or no such route at all (a 404 without an error body).
34
+ if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
35
+ return { delivered: false, httpStatus: res.status, error: json?.error ? `${json.error}${json.message ? `: ${json.message}` : ""}` : `HTTP ${res.status}` };
36
+ }
11
37
 
12
- export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
38
+ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delivered = false, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
13
39
  const headers = { Authorization: `Bearer ${apiKey}` };
14
40
  const deadline = Date.now() + decisionTimeoutMs;
15
41
  let announced = false;
16
42
  let completed = false;
17
43
  let approvedBiteId = null;
18
44
  let finalStudioUrl = null;
19
- process.stdout.write("Waiting for your word in the browser");
45
+ process.stdout.write(delivered ? "Waiting for the bite to finish" : "Waiting for your word in the browser");
20
46
  while (Date.now() < deadline) {
21
47
  await new Promise((r) => setTimeout(r, pollMs));
22
48
  let res;
@@ -31,14 +57,14 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisi
31
57
  console.error("Discarded in the app. Adjust the storyboard and film again.");
32
58
  return { exitCode: 1, status: "rejected", biteId: null, studioUrl: null };
33
59
  }
34
- if (st.status === "approved") {
60
+ if (st.status === "approved" || st.status === "delivered") {
35
61
  if (!announced) {
36
62
  process.stdout.write("\n");
37
- console.log(`Approved — bite ${st.biteId} is being created`);
63
+ console.log(st.status === "delivered" ? `delivered: bite ${st.biteId} is being created` : `Approved — bite ${st.biteId} is being created`);
38
64
  announced = true;
39
65
  approvedBiteId = st.biteId;
40
66
  finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
41
- process.stdout.write("Waiting for the bite to finish");
67
+ if (st.biteStatus !== "completed") process.stdout.write("Waiting for the bite to finish");
42
68
  }
43
69
  if (st.biteStatus === "completed") { completed = true; process.stdout.write("\n"); break; }
44
70
  if (st.biteStatus === "failed") {
@@ -51,12 +77,12 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisi
51
77
  }
52
78
  if (!announced) {
53
79
  process.stdout.write("\n");
54
- console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
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}`);
55
81
  return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
56
82
  }
57
83
  if (!completed) {
58
- console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
59
- return { exitCode: 1, status: "approved", biteId: approvedBiteId, studioUrl: null };
84
+ console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.`);
85
+ return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
60
86
  }
61
87
 
62
88
  // Final receipt via the status endpoint (same gate as before).
@@ -1,8 +1,9 @@
1
1
  // Where a staged take stands, and the wait for its word.
2
2
  //
3
- // node status.mjs <takeDir> wait for the decision, then for the bite to finish (reads <takeDir>/staged.json)
3
+ // node status.mjs <takeDir> wait for the decision (or, delivered, for the bite to finish); reads <takeDir>/staged.json
4
+ // a take whose delivery failed is delivered again here first (idempotent on the server)
4
5
  // node status.mjs <stagingId> same, by id
5
- // node status.mjs <takeDir> --no-wait one look, no waiting: pending | approved (+bite status) | rejected
6
+ // node status.mjs <takeDir> --no-wait one look, no waiting: pending | delivered (+bite status) | approved (+bite status) | rejected
6
7
  // node status.mjs --all one look at every take-*/staged.json under the current directory
7
8
  //
8
9
  // A batch stages every take with `upload.mjs --stage-only --no-open`, then the
@@ -10,7 +11,7 @@
10
11
  // upload.mjs: no studio link before the bite is completed.
11
12
  import fs from "node:fs";
12
13
  import path from "node:path";
13
- import { waitForDecision, peekStaged } from "./stage-wait.mjs";
14
+ import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
14
15
 
15
16
  const args = process.argv.slice(2);
16
17
  const noWait = args.includes("--no-wait");
@@ -36,6 +37,7 @@ function readStaged(dir) {
36
37
  function describe(st) {
37
38
  if (!st.ok) return `unreachable (${st.httpStatus})`;
38
39
  if (st.status === "rejected") return "discarded in the app";
40
+ if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
39
41
  if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
40
42
  return "waiting for the word in the app";
41
43
  }
@@ -53,20 +55,35 @@ if (all) {
53
55
 
54
56
  let stagingId = target;
55
57
  let pageUrl = null;
58
+ let delivered = false;
56
59
  if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
57
60
  const staged = readStaged(target);
58
61
  if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
59
62
  stagingId = staged.stagingId;
60
63
  pageUrl = staged.previewUrl ?? null;
64
+ delivered = staged.delivered === true;
65
+ // A brief take whose delivery failed (network, 409) is delivered again here;
66
+ // the server is idempotent. An older server (pending) is left alone.
67
+ if (staged.attemptRef && staged.delivered === false && staged.pending !== true) {
68
+ const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
69
+ if (d.delivered) {
70
+ delivered = true;
71
+ console.log(`delivered: bite ${d.biteId}${d.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.`);
72
+ try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, delivered: true, biteId: d.biteId, videoId: d.videoId ?? null, queued: d.queued ?? null, deliveryError: null }, null, 2) + "\n"); } catch {}
73
+ } else if (d.pending) {
74
+ try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, pending: true }, null, 2) + "\n"); } catch {}
75
+ console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
76
+ } else console.error(`Not delivered: ${d.error}. The take waits in the review queue.`);
77
+ }
61
78
  }
62
79
  if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
63
80
 
64
81
  if (noWait) {
65
82
  const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
66
83
  console.log(`${stagingId}: ${describe(st)}`);
67
- if (st.ok && st.status === "approved" && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
84
+ if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
68
85
  process.exit(st.ok ? 0 : 1);
69
86
  }
70
87
 
71
- const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
88
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
72
89
  process.exit(outcome.exitCode);
@@ -47,6 +47,11 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
47
47
  // opts out. `--stage-only` returns right after the two uploads (the batch
48
48
  // waits with status.mjs); `--supersede` replaces a stage already pinned to
49
49
  // this attempt (the server refuses a second one otherwise).
50
+ // DELIVERY (1.3.0, founder ruling 2026-09-14): a take with an attempt becomes
51
+ // a bite by itself. After both uploads this script PUTs the delivery route
52
+ // (the claim's api.uploaded, fallback <base>/api/recorder/stage/<id>/uploaded)
53
+ // and prints "delivered: bite <id>". No Approve click, no review queue for
54
+ // brief batches. Free-prompt takes (no attempt) still stage for review.
50
55
  const stageOnly = process.argv.includes("--stage-only");
51
56
  const supersede = process.argv.includes("--supersede");
52
57
  const attemptIdx = process.argv.indexOf("--attempt");
@@ -54,11 +59,13 @@ const attemptPath = process.argv.includes("--no-attempt")
54
59
  ? null
55
60
  : attemptIdx >= 0 ? String(process.argv[attemptIdx + 1] ?? "") : path.join(dir, "brief.json");
56
61
  let attempt = null;
62
+ let uploadedTemplate = null;
57
63
  if (attemptPath && fs.existsSync(attemptPath)) {
58
64
  try {
59
65
  const b = JSON.parse(fs.readFileSync(attemptPath, "utf8"));
60
66
  if (b.briefId && b.revision !== undefined && b.contentHash && b.attemptRef) {
61
67
  attempt = { briefId: String(b.briefId), revision: b.revision, contentHash: String(b.contentHash), attemptRef: String(b.attemptRef) };
68
+ uploadedTemplate = typeof b.api?.uploaded === "string" ? b.api.uploaded : null;
62
69
  } else console.error(`${attemptPath} is missing briefId/revision/contentHash/attemptRef; staging without an attempt`);
63
70
  } catch (e) { console.error(`${attemptPath} unreadable (${e.message}); staging without an attempt`); }
64
71
  } else if (attemptIdx >= 0) { console.error(`--attempt ${attemptPath} not found`); process.exit(2); }
@@ -70,6 +77,14 @@ if (!cfg.api_key || !cfg.base) {
70
77
  process.exit(1);
71
78
  }
72
79
  const base = cfg.base.replace(/\/+$/, "");
80
+ // A delivered take is a bite already; staging it again would make a second one.
81
+ try {
82
+ const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
83
+ if (prev?.delivered === true && prev.biteId && !supersede) {
84
+ console.error(`${dir} was already delivered as bite ${prev.biteId}. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
85
+ process.exit(1);
86
+ }
87
+ } catch { /* not staged yet */ }
73
88
 
74
89
  const cleanPath = path.join(dir, "clean.mp4");
75
90
  const wirePath = path.join(dir, "manifest.demobites.json");
@@ -178,7 +193,7 @@ if (!zipped) {
178
193
  fs.rmSync(staging, { recursive: true, force: true });
179
194
  const sizeBytes = fs.statSync(zipPath).size;
180
195
  console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
181
- if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite once approved.`);
196
+ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite ${attempt ? "by itself once delivered" : "once approved"}.`);
182
197
 
183
198
  // ── stage ──────────────────────────────────────────────────────────────────
184
199
  const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
@@ -247,22 +262,48 @@ async function putS3(url, contentType, filePath, label) {
247
262
  }
248
263
  await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
249
264
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
265
+
266
+ // ── delivery (brief batches): the take becomes a bite by itself ────────────
267
+ // Only a take carrying an attempt is delivered. Free-prompt takes never call
268
+ // this route, so their flow is unchanged: staged, then the word in the app.
269
+ let delivery = null;
270
+ if (attempt) {
271
+ const { deliverStaged } = await import("./stage-wait.mjs");
272
+ delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
273
+ }
250
274
  // The staging id used to be printed only; a batch resumes from disk, so it is
251
- // persisted next to the take (status.mjs reads it).
275
+ // persisted next to the take (status.mjs reads it, and retries a delivery
276
+ // that failed).
277
+ const pageUrl = new URL(previewUrl, base).toString();
252
278
  try {
253
279
  fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
254
- stagingId, previewUrl: new URL(previewUrl, base).toString(), queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
255
- pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null, at: new Date().toISOString(),
280
+ stagingId, previewUrl: pageUrl, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
281
+ pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
282
+ delivered: delivery ? delivery.delivered : null, biteId: delivery?.biteId ?? null, videoId: delivery?.videoId ?? null,
283
+ queued: delivery?.queued ?? null, pending: delivery?.pending ?? null, deliveryError: delivery?.error ?? null,
284
+ api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
256
285
  }, null, 2) + "\n");
257
286
  } catch (e) { console.error(`staged.json not written: ${e.message}`); }
258
287
 
288
+ if (delivery?.delivered) {
289
+ console.log(`delivered: bite ${delivery.biteId}${delivery.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.\n ${pageUrl}`);
290
+ if (stageOnly) { console.log(`Delivered only. Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
291
+ const { waitForDecision } = await import("./stage-wait.mjs");
292
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered: true });
293
+ process.exit(outcome.exitCode);
294
+ }
295
+ if (delivery && !delivery.pending) {
296
+ console.error(`Staged, but not delivered: ${delivery.error}. The take waits in the review queue:\n ${pageUrl}\nTry the delivery again later with: node scripts/status.mjs ${dir}`);
297
+ process.exit(1);
298
+ }
299
+ if (delivery?.pending) console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
300
+
259
301
  // ── open the in-app preview — the review happens THERE ─────────────────────
260
302
  // Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
261
303
  // review sprint, auto-opening a tab per take is spam. `--no-open` (or
262
304
  // config.open_preview === false) stages silently — the queue pill and the
263
305
  // printed URL carry the message. Default stays open: for a single take the
264
306
  // opened page IS the consent moment.
265
- const pageUrl = new URL(previewUrl, base).toString();
266
307
  console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
267
308
  if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
268
309
  console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);