demobites 1.4.0 → 1.7.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 +14 -13
- package/launcher/index.mjs +72 -7
- package/package.json +1 -1
- package/scripts/calibrate.mjs +17 -3
- package/scripts/record.mjs +144 -15
- package/skill/SKILL.md +126 -33
- package/skill/scripts/batch.mjs +328 -0
- package/skill/scripts/briefs.mjs +163 -9
- package/skill/scripts/cleanup.mjs +2 -1
- package/skill/scripts/manifest.mjs +13 -1
- package/skill/scripts/retake.mjs +73 -15
- package/skill/scripts/stage-wait.mjs +172 -8
- package/skill/scripts/status.mjs +44 -18
- package/skill/scripts/upload.mjs +72 -67
- package/skill/scripts/vocab.mjs +2 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FILM IN THE BACKGROUND, IN PARALLEL (1.7.0, founder ruling 2026-09-26):
|
|
3
|
+
// after the ONE approval for the batch, the takes of an Update Radar batch
|
|
4
|
+
// are filmed together in a small pool, each in its OWN browser profile
|
|
5
|
+
// directory, and each is delivered as it finishes. A failed take never stops
|
|
6
|
+
// the others. "We will be judged by the outcome: the fewest edits before
|
|
7
|
+
// Export and Go live."
|
|
8
|
+
//
|
|
9
|
+
// node batch.mjs plan <slug> the pool size for this machine and which briefs have a storyboard (films nothing)
|
|
10
|
+
// node batch.mjs run <slug> [--concurrency N] film every brief with a storyboard, N at a time (1..4), deliver each as it lands
|
|
11
|
+
// [--only <briefId,...>] [--keep-profiles]
|
|
12
|
+
//
|
|
13
|
+
// Inputs, all under .recorder/radar/<slug>/ (written by `briefs.mjs list --slug`
|
|
14
|
+
// and by the agent's refactor pass):
|
|
15
|
+
// bundle.json the batch (refreshed from the server first; the disk copy is the fallback)
|
|
16
|
+
// storyboards/<briefId>.json one approved storyboard per brief (Phase 3 schema); a brief without one is skipped, and said
|
|
17
|
+
// Outputs:
|
|
18
|
+
// takes/<briefId>/<step>.log one log per step (claim, prep, record, cleanup, trim, calibrate, manifest, upload)
|
|
19
|
+
// takes/<briefId>/take.log every step in order
|
|
20
|
+
// takes/<briefId>/result.json what happened to the take
|
|
21
|
+
// takes/summary.json the batch's outcome
|
|
22
|
+
// take-<briefId>-r<revision>/ the take itself, in the project directory as for every claimed brief
|
|
23
|
+
//
|
|
24
|
+
// Per take, in order, as child processes of the existing scripts (nothing is
|
|
25
|
+
// reimplemented here): briefs claim → event recording → cleanup --prep (when
|
|
26
|
+
// declared) → record → cleanup (when declared) → trim → calibrate → manifest →
|
|
27
|
+
// upload --stage-only --no-open (the delivery door). On a failure: event
|
|
28
|
+
// failed --note, keep the take directory, go on with the next brief.
|
|
29
|
+
//
|
|
30
|
+
// THE POOL RULE: 2 concurrent takes on a machine with 8 CPU cores or fewer,
|
|
31
|
+
// or 16 GB of memory or less; 3 above that; --concurrency overrides; never
|
|
32
|
+
// more than 4. Each take films on its own profile directory seeded from
|
|
33
|
+
// .recorder/profile (the signed-in session rides along, the profile lock
|
|
34
|
+
// does not), passed to the children as RECORDER_PROFILE.
|
|
35
|
+
//
|
|
36
|
+
// Never prints the api_key (the children never do either).
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import os from "node:os";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const [, , cmd, slugArg, ...rest] = process.argv;
|
|
45
|
+
const flag = (name) => rest.includes(name);
|
|
46
|
+
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
|
|
47
|
+
|
|
48
|
+
function usage(code = 2) {
|
|
49
|
+
console.error(`Usage:
|
|
50
|
+
node batch.mjs plan <slug>
|
|
51
|
+
node batch.mjs run <slug> [--concurrency N] [--only <briefId,...>] [--keep-profiles]`);
|
|
52
|
+
process.exit(code);
|
|
53
|
+
}
|
|
54
|
+
if (!cmd || !["plan", "run"].includes(cmd)) usage();
|
|
55
|
+
const slug = String(slugArg ?? "").trim().toLowerCase();
|
|
56
|
+
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); }
|
|
57
|
+
|
|
58
|
+
// ── where the scripts are ──────────────────────────────────────────────────
|
|
59
|
+
// Installed, every script sits beside this file. In the package repository
|
|
60
|
+
// record/trim/calibrate live in ../../scripts; both layouts resolve.
|
|
61
|
+
function scriptPath(name) {
|
|
62
|
+
for (const dir of [here, path.resolve(here, "..", "..", "scripts")]) {
|
|
63
|
+
const p = path.join(dir, name);
|
|
64
|
+
if (fs.existsSync(p)) return p;
|
|
65
|
+
}
|
|
66
|
+
console.error(`${name} not found beside ${here}. Run \`npx demobite\` to refresh the skill.`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const SCRIPTS = {
|
|
70
|
+
briefs: scriptPath("briefs.mjs"), cleanup: scriptPath("cleanup.mjs"), record: scriptPath("record.mjs"),
|
|
71
|
+
trim: scriptPath("trim.mjs"), calibrate: scriptPath("calibrate.mjs"), manifest: scriptPath("manifest.mjs"), upload: scriptPath("upload.mjs"),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── the pool rule ──────────────────────────────────────────────────────────
|
|
75
|
+
const MAX_CONCURRENCY = 4;
|
|
76
|
+
function poolSize({ cpus = os.cpus().length, memGiB = os.totalmem() / 2 ** 30, override = null } = {}) {
|
|
77
|
+
if (override !== null && override !== undefined && override !== "") {
|
|
78
|
+
const n = Number(override);
|
|
79
|
+
if (!Number.isInteger(n) || n < 1) { console.error(`--concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}.`); process.exit(2); }
|
|
80
|
+
return { size: Math.min(n, MAX_CONCURRENCY), why: n > MAX_CONCURRENCY ? `--concurrency ${n}, capped at ${MAX_CONCURRENCY}` : `--concurrency ${n}` };
|
|
81
|
+
}
|
|
82
|
+
const small = cpus <= 8 || memGiB <= 16;
|
|
83
|
+
return { size: small ? 2 : 3, why: `${cpus} CPU cores, ${Math.round(memGiB)} GB memory` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── the batch on disk and on the server ────────────────────────────────────
|
|
87
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
88
|
+
let cfg = {};
|
|
89
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
90
|
+
if (!cfg.api_key || !cfg.base) { console.error(`Not connected to DemoBites. Run: ${process.env.DEMOBITE_CLI ? "npx demobite login" : "node scripts/login.mjs"}`); process.exit(1); }
|
|
91
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
92
|
+
|
|
93
|
+
const radarDir = path.resolve(".recorder", "radar", slug);
|
|
94
|
+
const bundlePath = path.join(radarDir, "bundle.json");
|
|
95
|
+
const storyboardsDir = path.join(radarDir, "storyboards");
|
|
96
|
+
const takesDir = path.join(radarDir, "takes");
|
|
97
|
+
const sharedProfile = path.resolve(".recorder", "profile");
|
|
98
|
+
|
|
99
|
+
const stamp = () => new Date().toTimeString().slice(0, 8);
|
|
100
|
+
const say = (line) => console.log(`${stamp()} ${line}`);
|
|
101
|
+
|
|
102
|
+
function loadBundle() {
|
|
103
|
+
// The server's view first (revisions move after a refine); the disk copy when it cannot be reached.
|
|
104
|
+
fs.mkdirSync(takesDir, { recursive: true });
|
|
105
|
+
const r = spawnSync("node", [SCRIPTS.briefs, "list", "--slug", slug], { cwd: process.cwd(), env: process.env, encoding: "utf8" });
|
|
106
|
+
fs.writeFileSync(path.join(takesDir, "list.log"), `${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
107
|
+
if (r.status !== 0) {
|
|
108
|
+
const first = (r.stderr || r.stdout || "").trim().split("\n")[0];
|
|
109
|
+
if (!fs.existsSync(bundlePath)) { console.error(first || "Could not read the batch."); process.exit(1); }
|
|
110
|
+
console.error(`Could not refresh the batch from DemoBites (${first}); using the bundle on disk.`);
|
|
111
|
+
}
|
|
112
|
+
let bundle;
|
|
113
|
+
try { bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8")); } catch (e) { console.error(`${bundlePath} unreadable: ${e.message}. Run: node scripts/briefs.mjs list --slug ${slug}`); process.exit(1); }
|
|
114
|
+
if (!bundle.batch?.id || !Array.isArray(bundle.briefs)) { console.error(`${bundlePath} has no batch. Run: node scripts/briefs.mjs list --slug ${slug}`); process.exit(1); }
|
|
115
|
+
return bundle;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const safeId = (id) => String(id).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
119
|
+
const storyboardFor = (b) => path.join(storyboardsDir, `${safeId(b.briefId)}.json`);
|
|
120
|
+
|
|
121
|
+
function planBatch(bundle) {
|
|
122
|
+
const order = [...bundle.briefs].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
123
|
+
const only = opt("--only") ? new Set(opt("--only").split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
124
|
+
return order.map((b, i) => {
|
|
125
|
+
const sb = storyboardFor(b);
|
|
126
|
+
let storyboard = null; let problem = null;
|
|
127
|
+
if (only && !only.has(String(b.briefId))) problem = "not in --only";
|
|
128
|
+
else if (!fs.existsSync(sb)) problem = "no storyboard";
|
|
129
|
+
else {
|
|
130
|
+
try { storyboard = JSON.parse(fs.readFileSync(sb, "utf8")); } catch (e) { problem = `storyboard unreadable: ${e.message}`; }
|
|
131
|
+
if (storyboard && (!Array.isArray(storyboard.steps) || storyboard.steps.length === 0)) problem = "storyboard has no steps";
|
|
132
|
+
}
|
|
133
|
+
const live = b.attempt && ["planning", "awaiting_storyboard_approval", "recording", "uploading"].includes(b.attempt.state);
|
|
134
|
+
if (!problem && b.status && !["approved", "refined"].includes(b.status) && !live) problem = `brief is ${b.status}`;
|
|
135
|
+
return { n: i + 1, brief: b, storyboardPath: sb, storyboard, problem, liveAttempt: live ? b.attempt : null };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── one step = one child process, logged ───────────────────────────────────
|
|
140
|
+
function runStep(take, step, script, args, extraEnv = {}) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const logPath = path.join(take.logDir, `${step}.log`);
|
|
143
|
+
const out = fs.createWriteStream(logPath);
|
|
144
|
+
const all = fs.createWriteStream(path.join(take.logDir, "take.log"), { flags: "a" });
|
|
145
|
+
all.write(`\n── ${step} ${new Date().toISOString()}\n node ${path.basename(script)} ${args.join(" ")}\n`);
|
|
146
|
+
let stdout = ""; let stderr = "";
|
|
147
|
+
const child = spawn("node", [script, ...args], { cwd: process.cwd(), env: { ...process.env, ...extraEnv }, stdio: ["ignore", "pipe", "pipe"] });
|
|
148
|
+
take.child = child;
|
|
149
|
+
child.stdout.on("data", (d) => { stdout += d; out.write(d); all.write(d); });
|
|
150
|
+
child.stderr.on("data", (d) => { stderr += d; out.write(d); all.write(d); });
|
|
151
|
+
child.on("close", (code, signal) => {
|
|
152
|
+
take.child = null;
|
|
153
|
+
out.end(); all.end();
|
|
154
|
+
resolve({ code: signal ? 1 : code, stdout, stderr, logPath, signal });
|
|
155
|
+
});
|
|
156
|
+
child.on("error", (e) => { take.child = null; stderr += e.message; out.end(); all.end(); resolve({ code: 1, stdout, stderr, logPath }); });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const firstLine = (r) => (r.stderr.trim() || r.stdout.trim()).split("\n").filter(Boolean).pop() ?? `exit ${r.code}`;
|
|
160
|
+
const rel = (p) => path.relative(process.cwd(), p) || p;
|
|
161
|
+
|
|
162
|
+
// ── one profile per take, seeded from the shared one ───────────────────────
|
|
163
|
+
// Chrome refuses a second instance on the same profile (SingletonLock); a
|
|
164
|
+
// COPY carries the signed-in session and nothing that locks. Caches stay
|
|
165
|
+
// behind: they are large and Chrome rebuilds them.
|
|
166
|
+
const SKIP_IN_PROFILE = /^(Singleton|lockfile$|.*Cache$|Crashpad$|BrowserMetrics|Safe Browsing|component_crx_cache|extensions_crx_cache)/i;
|
|
167
|
+
function seedProfile(take) {
|
|
168
|
+
const dir = path.join(take.logDir, "profile");
|
|
169
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
if (fs.existsSync(sharedProfile)) {
|
|
172
|
+
fs.cpSync(sharedProfile, dir, { recursive: true, force: true, filter: (src) => !SKIP_IN_PROFILE.test(path.basename(src)) });
|
|
173
|
+
}
|
|
174
|
+
return dir;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── one take, start to delivery ────────────────────────────────────────────
|
|
178
|
+
const takesInFlight = new Set();
|
|
179
|
+
async function runTake(item, bundle, results) {
|
|
180
|
+
const b = item.brief;
|
|
181
|
+
const label = `brief ${item.n}/${bundle.briefs.length} "${b.title}"`;
|
|
182
|
+
const take = { briefId: b.briefId, title: b.title, n: item.n, logDir: path.join(takesDir, safeId(b.briefId)), child: null };
|
|
183
|
+
takesInFlight.add(take);
|
|
184
|
+
try { await filmTake(item, bundle, results, take, label); } finally { takesInFlight.delete(take); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function filmTake(item, bundle, results, take, label) {
|
|
188
|
+
const b = item.brief;
|
|
189
|
+
fs.mkdirSync(take.logDir, { recursive: true });
|
|
190
|
+
fs.rmSync(path.join(take.logDir, "take.log"), { force: true });
|
|
191
|
+
const result = { briefId: b.briefId, title: b.title, n: item.n, startedAt: new Date().toISOString(), takeDir: null, attemptRef: null, outcome: null, step: null, error: null, biteId: null, logDir: rel(take.logDir) };
|
|
192
|
+
results.push(result);
|
|
193
|
+
const finish = (outcome, extra = {}) => { Object.assign(result, { outcome, endedAt: new Date().toISOString(), ...extra }); fs.writeFileSync(path.join(take.logDir, "result.json"), JSON.stringify(result, null, 2) + "\n"); };
|
|
194
|
+
const fail = async (step, r) => {
|
|
195
|
+
const why = firstLine(r);
|
|
196
|
+
result.step = step; result.error = why;
|
|
197
|
+
say(`${label}: failed at ${step} (${why}). Log: ${rel(r.logPath)}`);
|
|
198
|
+
if (result.attemptRef && result.takeDir) await runStep(take, "event-failed", SCRIPTS.briefs, ["event", result.takeDir, "failed", "--note", `${step}: ${why}`.slice(0, 600)]);
|
|
199
|
+
finish("failed");
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// 1. claim → take-<briefId>-r<revision>/brief.json
|
|
203
|
+
const claim = await runStep(take, "claim", SCRIPTS.briefs, ["claim", String(bundle.batch.id), String(b.briefId)]);
|
|
204
|
+
if (claim.code !== 0) { result.step = "claim"; result.error = firstLine(claim); say(`${label}: claim refused (${result.error}). Log: ${rel(claim.logPath)}`); finish("claim_refused"); return; }
|
|
205
|
+
const m = /→ (\S+)\/brief\.json \(attempt ([^,)]+)/.exec(claim.stdout);
|
|
206
|
+
result.takeDir = m?.[1] ?? `take-${safeId(b.briefId)}-r${b.revision}`;
|
|
207
|
+
result.attemptRef = m?.[2] ?? null;
|
|
208
|
+
if (!fs.existsSync(path.join(result.takeDir, "brief.json"))) { result.step = "claim"; result.error = `no brief.json in ${result.takeDir}`; say(`${label}: claim did not write ${result.takeDir}/brief.json`); finish("failed"); return; }
|
|
209
|
+
say(`${label}: claimed → ${result.takeDir} (attempt ${result.attemptRef ?? "?"})`);
|
|
210
|
+
|
|
211
|
+
// 2. the approved storyboard travels with the take (cleanup --prep reads it before record does)
|
|
212
|
+
fs.copyFileSync(item.storyboardPath, path.join(result.takeDir, "storyboard.json"));
|
|
213
|
+
const sb = item.storyboard;
|
|
214
|
+
const hasPrep = (sb.prep?.length ?? 0) > 0 || (sb.checks?.before?.length ?? 0) > 0;
|
|
215
|
+
const hasCleanup = (sb.cleanup?.length ?? 0) > 0 || (sb.checks?.after?.length ?? 0) > 0;
|
|
216
|
+
|
|
217
|
+
// 3. the attempt moves to recording; the take gets its own profile
|
|
218
|
+
const ev = await runStep(take, "event-recording", SCRIPTS.briefs, ["event", result.takeDir, "recording"]);
|
|
219
|
+
if (ev.code !== 0) { await fail("event recording", ev); return; }
|
|
220
|
+
let profile;
|
|
221
|
+
try { profile = seedProfile(take); } catch (e) { await fail("profile", { stdout: "", stderr: e.message, logPath: path.join(take.logDir, "take.log"), code: 1 }); return; }
|
|
222
|
+
const env = { RECORDER_PROFILE: profile };
|
|
223
|
+
say(`${label}: filming on its own profile (${rel(profile)})`);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
if (hasPrep) { const r = await runStep(take, "prep", SCRIPTS.cleanup, [result.takeDir, "--prep"], env); if (r.code !== 0) { await fail("prep", r); return; } say(`${label}: prep done (checks before passed)`); }
|
|
227
|
+
const rec = await runStep(take, "record", SCRIPTS.record, [result.takeDir, item.storyboardPath], env);
|
|
228
|
+
if (rec.code !== 0) { await fail("record", rec); return; }
|
|
229
|
+
say(`${label}: filmed`);
|
|
230
|
+
if (hasCleanup) { const r = await runStep(take, "cleanup", SCRIPTS.cleanup, [result.takeDir], env); if (r.code !== 0) { await fail("cleanup", r); return; } say(`${label}: workspace returned to its initial state (checks after passed)`); }
|
|
231
|
+
} finally {
|
|
232
|
+
if (!flag("--keep-profiles")) fs.rmSync(profile, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 4. finish the file, then the ONE delivery door
|
|
236
|
+
for (const [step, script] of [["trim", SCRIPTS.trim], ["calibrate", SCRIPTS.calibrate], ["manifest", SCRIPTS.manifest]]) {
|
|
237
|
+
const r = await runStep(take, step, script, [result.takeDir]);
|
|
238
|
+
if (r.code !== 0) { await fail(step, r); return; }
|
|
239
|
+
}
|
|
240
|
+
const up = await runStep(take, "upload", SCRIPTS.upload, [result.takeDir, "--stage-only", "--no-open"]);
|
|
241
|
+
let staged = null;
|
|
242
|
+
try { staged = JSON.parse(fs.readFileSync(path.join(result.takeDir, "staged.json"), "utf8")); } catch {}
|
|
243
|
+
if (staged?.delivered && staged.biteId) {
|
|
244
|
+
result.biteId = staged.biteId;
|
|
245
|
+
say(`${label}: delivered → bite ${staged.biteId}, becoming a demo by itself`);
|
|
246
|
+
finish("delivered", { stagingId: staged.stagingId, dashboardUrl: staged.dashboardUrl });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (staged?.waiting) {
|
|
250
|
+
const kept = up.stdout.split("\n").find((l) => l.startsWith("Kept.")) ?? "Kept. The take is waiting for recording minutes.";
|
|
251
|
+
say(`${label}: ${kept}`);
|
|
252
|
+
finish("waiting", { stagingId: staged.stagingId, resetsAt: staged.resetsAt, dashboardUrl: staged.dashboardUrl });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (up.code !== 0 || !staged) { await fail("upload", up); return; }
|
|
256
|
+
result.step = "upload"; result.error = staged.deliveryError ?? "staged, not delivered";
|
|
257
|
+
say(`${label}: staged but not delivered (${result.error}). Try again later: node scripts/status.mjs ${result.takeDir}`);
|
|
258
|
+
finish("staged", { stagingId: staged.stagingId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── the pool ───────────────────────────────────────────────────────────────
|
|
262
|
+
async function runPool(items, size, worker) {
|
|
263
|
+
const queue = [...items];
|
|
264
|
+
const active = new Set();
|
|
265
|
+
const next = async () => {
|
|
266
|
+
const item = queue.shift();
|
|
267
|
+
if (!item) return;
|
|
268
|
+
const p = worker(item).catch((e) => { say(`brief ${item.n} "${item.brief.title}": ${e.message}`); }).finally(() => active.delete(p));
|
|
269
|
+
active.add(p);
|
|
270
|
+
await p;
|
|
271
|
+
await next();
|
|
272
|
+
};
|
|
273
|
+
await Promise.all(Array.from({ length: Math.min(size, queue.length) }, () => next()));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── main ───────────────────────────────────────────────────────────────────
|
|
277
|
+
const bundle = loadBundle();
|
|
278
|
+
const plan = planBatch(bundle);
|
|
279
|
+
const pool = poolSize({ override: opt("--concurrency") });
|
|
280
|
+
const ready = plan.filter((p) => !p.problem);
|
|
281
|
+
const radarName = bundle.radar?.name ?? slug;
|
|
282
|
+
|
|
283
|
+
console.log(`Update Radar: ${radarName} batch ${bundle.batch.id}`);
|
|
284
|
+
console.log(`Pool: ${pool.size} take${pool.size === 1 ? "" : "s"} at a time (${pool.why}), each on its own profile directory; the rest queue.`);
|
|
285
|
+
for (const p of plan) {
|
|
286
|
+
const tag = p.problem ? `skip, ${p.problem}` : `ready${p.liveAttempt ? `, live attempt ${p.liveAttempt.ref} will be superseded by the claim` : ""}`;
|
|
287
|
+
console.log(` ${String(p.n).padStart(2, " ")}. ${p.brief.title} brief ${p.brief.briefId} r${p.brief.revision} [${tag}]`);
|
|
288
|
+
}
|
|
289
|
+
if (cmd === "plan") {
|
|
290
|
+
console.log(`\nStoryboards live in ${rel(storyboardsDir)}/<briefId>.json. Films nothing; run: node scripts/batch.mjs run ${slug}`);
|
|
291
|
+
process.exit(ready.length ? 0 : 1);
|
|
292
|
+
}
|
|
293
|
+
if (!ready.length) { console.error(`\nNothing to film: no brief has a storyboard in ${rel(storyboardsDir)}/.`); process.exit(1); }
|
|
294
|
+
console.log(`\nFilming ${ready.length} of ${plan.length} in the background. Logs: ${rel(takesDir)}/<briefId>/\n`);
|
|
295
|
+
|
|
296
|
+
const results = [];
|
|
297
|
+
let interrupted = false;
|
|
298
|
+
process.on("SIGINT", () => {
|
|
299
|
+
if (interrupted) process.exit(130);
|
|
300
|
+
interrupted = true;
|
|
301
|
+
say("interrupted: stopping the takes in flight; claimed briefs are marked failed so they can be filmed again");
|
|
302
|
+
for (const t of takesInFlight) t.child?.kill("SIGTERM");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await runPool(ready, pool.size, async (item) => {
|
|
306
|
+
if (interrupted) return;
|
|
307
|
+
await runTake(item, bundle, results);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── the summary ────────────────────────────────────────────────────────────
|
|
311
|
+
const count = (o) => results.filter((r) => r.outcome === o).length;
|
|
312
|
+
const skipped = plan.filter((p) => p.problem);
|
|
313
|
+
console.log(`\nBatch ${bundle.batch.id}: ${count("delivered")} delivered, ${count("waiting")} kept waiting for recording minutes, ${count("failed") + count("claim_refused") + count("staged")} not delivered, ${skipped.length} skipped.`);
|
|
314
|
+
for (const r of results.sort((a, b) => a.n - b.n)) {
|
|
315
|
+
const line = r.outcome === "delivered" ? `delivered, bite ${r.biteId}`
|
|
316
|
+
: r.outcome === "waiting" ? "kept, waiting for recording minutes (it waits on the Demos grid)"
|
|
317
|
+
: r.outcome === "staged" ? `staged, not delivered (${r.error}); node scripts/status.mjs ${r.takeDir}`
|
|
318
|
+
: r.outcome === "claim_refused" ? `claim refused: ${r.error}`
|
|
319
|
+
: `failed at ${r.step}: ${r.error} (${r.logDir}/${r.step === "event recording" ? "event-recording" : r.step}.log)`;
|
|
320
|
+
console.log(` ${String(r.n).padStart(2, " ")}. ${r.title}: ${line}`);
|
|
321
|
+
}
|
|
322
|
+
for (const p of skipped) console.log(` ${String(p.n).padStart(2, " ")}. ${p.brief.title}: skipped, ${p.problem}`);
|
|
323
|
+
const summary = { slug, batchId: bundle.batch.id, radar: bundle.radar ?? null, pool, startedAt: results[0]?.startedAt ?? null, endedAt: new Date().toISOString(), results, skipped: skipped.map((p) => ({ briefId: p.brief.briefId, title: p.brief.title, why: p.problem })) };
|
|
324
|
+
fs.writeFileSync(path.join(takesDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
|
|
325
|
+
const grid = `${base}/demos`;
|
|
326
|
+
console.log(`\nDemos grid: ${grid}`);
|
|
327
|
+
if (bundle.radar?.workflowUrl) console.log(`Workflow page: ${bundle.radar.workflowUrl}`);
|
|
328
|
+
process.exit(results.every((r) => r.outcome === "delivered" || r.outcome === "waiting") && !interrupted ? 0 : 1);
|
package/skill/scripts/briefs.mjs
CHANGED
|
@@ -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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|