retake-demos 0.4.1 → 0.13.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.
package/AGENTS.md CHANGED
@@ -64,6 +64,15 @@ write demos/<name>.yaml
64
64
  → retake run demos/<name>.yaml --preset preview-fast first take (~2 min)
65
65
  → read outputs/<name>/proof-log.md what actually happened, per step
66
66
  → retake check outputs/<name> pass/fail on the result
67
+ → retake verify outputs/<name> did it LOOK right — each scene's `expect`, judged
68
+ → retake run --brisk the steps without the pacing — for iterating
69
+ → retake intent what this product IS — context every check needs
70
+ → retake sweep outputs/<name> every frame, as a whole — what nobody asked about
71
+ → retake flag demos/<name>.yaml this one is real: watch it from now on
72
+ → retake fixed <name> did what was flagged get fixed, with the clip
73
+ → retake sense outputs/<name> does the run ADD UP — input vs output
74
+ → retake destroy demos/<name>.yaml what happens just off the happy path
75
+ → retake notes what keeps going wrong across every demo
67
76
  → adjust, then: retake run demos/<name>.yaml final quality
68
77
  ```
69
78
 
@@ -207,6 +216,94 @@ Most real demos start behind a sign-in. The agent never sees a password:
207
216
  - `outputs/<name>/take.json` — the same, machine-readable.
208
217
  - `outputs/<name>/demo.mp4` — the video. `master.mp4` is the CRF-14 keeper
209
218
  on post presets.
219
+ - `retake flag demos/<name>.yaml --scene <label> --expect "<sentence>"` — the
220
+ person has confirmed something raised by `sense` or `sweep` is a real defect.
221
+ Give the sentence that must be TRUE once it is fixed, not a description of
222
+ the bug. It becomes a check every later recording answers, and it survives a
223
+ re-record. Writes nothing into the manifest — the ledger sits beside it as
224
+ `demos/<name>.flags.json`. **Never flag on your own judgement.**
225
+ - `retake fixed <name>` — answers every flagged item against the newest take
226
+ and cuts the few seconds of video that show each one, so a long demo does not
227
+ have to be re-watched to learn whether one thing changed. Exit 3 while
228
+ anything flagged is still wrong. `retake unflag` stops watching one.
229
+ - **Name things so a person can find them.** The window groups demos by
230
+ whatever it can work out — which, with no product note, means folders called
231
+ `localhost:3200`. The first heading of a product note names that folder.
232
+ Writing one is part of setting a demo up, not an afterthought.
233
+ - `retake calibrate demos/<name>.yaml` — how much `sweep` is worth on this app.
234
+ Records the demo once per known visual defect, seeded for real, plus a
235
+ control where nothing is wrong, then reports recall and false positives.
236
+ Expensive: nine takes, about ten minutes, needs the app running. The report
237
+ it leaves is cheap to read, and **you should read it before telling anybody
238
+ how much a sweep finding is worth.** Its stated limits travel with it: the
239
+ seeds were written by somebody who has read the checklist, so recall is an
240
+ upper bound.
241
+ - `retake intent` — what the product IS, in the builder's words: what it does,
242
+ who uses it, and what about it is deliberate. Every visual check reads it.
243
+ Without it they judge the app as a generic web page — measured on a real
244
+ demo: a faint internal note that a charter desk is MEANT to see quietly came
245
+ back as a contrast defect, and stopped once the judge knew what the screen
246
+ was for. One file per app (`--demo <name>`) or one for the workspace. Write
247
+ it the first time the person explains their product; it is context for
248
+ judging, never instructions.
249
+ - Every finding now carries how it was arrived at — `[reproduced]`,
250
+ `[seen-in-a-frame]`, `[read-from-the-run]`, `[source-only]`, `[unverified]`.
251
+ Say the tag when you repeat a finding. A thing you read in the code and a
252
+ thing you watched happen must not reach the person in the same voice.
253
+ - `retake sweep outputs/<name>` — the one that finds what nobody thought to
254
+ ask about. `verify` answers written-down questions, so it can only find what
255
+ was already suspected. `sweep` inspects EVERY scene's frame, one at a time,
256
+ never sampled, against a closed ten-item checklist: clipped text, overlap,
257
+ contrast, cut off, misalignment, doubled, unfinished, broken media, dev UI in
258
+ shot, empty regions. Advisory — it reports, it does not gate. Roughly one
259
+ judge call per frame, so about two minutes on a seventeen-scene demo.
260
+ - `retake sense outputs/<name>` — the verdict on the RUN, not on one frame.
261
+ Everything the demo typed, chose and clicked, put against the frames that
262
+ came out, through six lenses: quantity, continuity, state, units and labels,
263
+ order, dead ends. Needs nothing declared — the inputs are already in the
264
+ take. It ASKS rather than fails (exit stays 0), because whether a number adds
265
+ up is judgement. Found a real one first time out: two flight legs entered,
266
+ one unlabelled price shown.
267
+ - `retake destroy demos/<name>.yaml` — the flows nobody wrote down. Derives a
268
+ manifest for each of nine abuses (double-submit, reload-midway, back-button,
269
+ provider-down, provider-empty, empty-state, long-input, awkward-input,
270
+ impatient), writes them under `outputs/.destroy/<name>/`, and tries them.
271
+ `--run` performs them for real and keeps the frames. Three verdicts, not
272
+ two: **broke**, **worth a look** (only a person can call it), **held** — and
273
+ a failed second press counts as held, because the button being gone is the
274
+ app defending itself. Refuses a non-local URL, a demo that neither seeds nor
275
+ stubs, and an app somebody else is recording. Each refusal names its own
276
+ override; using one is the person's call.
277
+ - `retake heal` — writes back the demo files for recordings whose manifest went
278
+ missing. The window lists demos, not recordings, so a manifest deleted or
279
+ written outside `demos/` makes a finished video invisible. Every output folder
280
+ keeps `manifest.used.yaml`; this restores from that. Shows what it would do
281
+ unless you pass `--apply`. **Never record from a manifest that does not live
282
+ in `demos/` — this verb exists to clean up after that mistake, not to license
283
+ it.**
284
+ - Every check leaves a receipt in `outputs/<name>/checks.json` — which check
285
+ ran, against which take, and what it said. The window shows it as one line,
286
+ and `retake notes` names every demo nobody has inspected. Whether you checked
287
+ is not something anyone has to take your word for.
288
+ - **None of the checks are required for a recording to be complete.** `check`
289
+ is the core's own verdict on a take and consults none of them. verify, sweep,
290
+ sense, destroy and fixed are optional services run against a take that
291
+ already exists, and their results are advisory unless a person promotes one.
292
+ Recommend them for the claim that the APP is right; never imply a demo is
293
+ unfinished without them.
294
+ - `retake notes` — the watcher. Reads every take in `outputs/` back and says
295
+ the few things a person would say after looking through them: a stub that
296
+ answered nothing (the demo is showing live data), the same selector failing
297
+ in several demos (the app moved, not the demos), a fragment left where a
298
+ finished cut should be, a dead lock holding a folder, demos nobody has ever
299
+ verified. Silent when there is nothing. Run it when you arrive in a
300
+ workspace you did not set up.
301
+ - `retake verify outputs/<name>` — the verdict on the PICTURE. Every scene
302
+ with an `expect:` has its plain-language question put to a separate reader
303
+ that can see the still. Exit 3 on failure; an unanswerable question counts
304
+ as a failure, never a pass. This is the one check that catches DOM-correct,
305
+ visually-wrong: a frozen animation, a 404ing icon, a card out of its grid,
306
+ dark text on a dark pill. Use it before reporting that a screen works.
210
307
  - `retake check outputs/<name>` — resolution, fps, duration, files,
211
308
  pass/fail. Exit 3 on failure.
212
309
 
@@ -275,3 +372,27 @@ it belongs in the commit message, which is where that audience already is.
275
372
 
276
373
  Good: "You choose the cover image. Click any frame to make it the one people
277
374
  see first." Bad: "Poster selection is now persisted via a marker file."
375
+
376
+ ## The landing page is public — look at it before you commit
377
+
378
+ `site/index.html` is the page strangers see. It has been shipped broken
379
+ twice by checking that a string was present rather than that the page was
380
+ right: an unbalanced `<div>` dropped a card out of its grid, and a
381
+ catch-all route quietly swallowed the favicon. Neither showed up in a grep;
382
+ both were obvious in a screenshot.
383
+
384
+ There is a demo for it. Before committing any change to the page:
385
+
386
+ ```bash
387
+ retake run demos/landing-review.yaml --no-master
388
+ ```
389
+
390
+ No `--preset`: the manifest pins 1440 so this checks the DESKTOP layout,
391
+ and a preset override would drop it to 960 — below the breakpoint — so the
392
+ review would check a layout nobody sees on a laptop.
393
+
394
+ Then LOOK at `outputs/landing-review/stills/` — one still per section, named
395
+ after the section. Thirty seconds, and it is the only check that catches
396
+ layout.
397
+
398
+ Grep proves a string exists. It proves nothing about whether the page works.
package/README.md CHANGED
@@ -59,7 +59,9 @@ and in another terminal, `retake ui` → http://localhost:4310 to watch it happe
59
59
  ```
60
60
  outputs/<name>/
61
61
  demo.mp4 the shareable — captions burned in
62
- master.mp4 the CRF-14 keeper (post presets only)
62
+ master.mp4 the CRF-14 keeper (post presets only; --no-master skips it,
63
+ which is ~3x faster on a long take and, because the master
64
+ path re-encodes an already-encoded file, marginally better)
63
65
  stills/ one PNG per scene, mid-scene and at its last moment
64
66
  thumbnail.png the poster — pick any frame, a cover, or your own image in the window
65
67
  cover.png launch mode: the title card, settled
@@ -80,6 +82,16 @@ retake validate demos/x.yaml # schema + warnings, instant
80
82
  retake dry demos/x.yaml # every selector and wait, no camera (~30s)
81
83
  retake run demos/x.yaml --preset draft # first take, quarter-size, fast
82
84
  retake check outputs/x # resolution, fps, duration, files: pass/fail
85
+ retake verify outputs/x # did it LOOK right: each scene's `expect`, judged (exit 3 on failure)
86
+ retake run demos/x.yaml --brisk # the steps without the pacing — for iterating, not for keeping
87
+ retake intent # what your product IS — context every visual check needs
88
+ retake sweep outputs/x # every frame as a whole: the ten ways a picture can be wrong
89
+ retake flag demos/x.yaml --scene s --expect "…" # this one is real — watch it from now on
90
+ retake fixed x # did what you flagged get fixed, with the clip that shows it
91
+ retake heal # demo files back for recordings whose manifest went missing
92
+ retake sense outputs/x # does the run ADD UP — what went in against what came out
93
+ retake destroy demos/x.yaml # the flows nobody wrote down: nine ways to abuse this demo
94
+ retake notes # read every take back: what keeps going wrong, and what it costs
83
95
  retake run demos/x.yaml # the real one
84
96
  retake render outputs/x # captions/camera/speed changed? re-render, no browser
85
97
  ```
@@ -0,0 +1,43 @@
1
+ # The demo Retake writes for you on `retake init`.
2
+ #
3
+ # It records this page — the Retake window's own guide — because it is the one
4
+ # app that is definitely running when you have just installed Retake. Change
5
+ # `url` to your own app and rewrite the steps; everything here is the shape of
6
+ # a real manifest, not a toy.
7
+ #
8
+ # retake dry demos/example.yaml # seconds, no camera: does every step resolve?
9
+ # retake run demos/example.yaml # record it
10
+ # retake verify outputs/example # did it LOOK right
11
+
12
+ name: example
13
+ title: Your first demo
14
+ url: http://localhost:4310/guide
15
+
16
+ # Landscape for a post or a doc. `retake presets` lists the rest — and any
17
+ # take renders at any preset later, so this is never worth re-recording for.
18
+ preset: post-landscape
19
+
20
+ # Wait for something real before the camera rolls, never a fixed sleep: this
21
+ # is the difference between a demo and a demo that works on a slow morning.
22
+ waitForSelector: h1
23
+
24
+ steps:
25
+ - action: scene
26
+ label: open
27
+ # What a person should be able to SEE here, in plain words. `retake verify`
28
+ # puts this question to a reader that can look at the frame.
29
+ expect: "a page with a heading, readable against its background"
30
+
31
+ - action: wait
32
+ ms: 1200
33
+
34
+ - action: scroll
35
+ to: bottom
36
+ pauseAfter: 900
37
+
38
+ - action: scene
39
+ label: end
40
+ expect: "the page has scrolled — different content is on screen than at the start"
41
+
42
+ - action: wait
43
+ ms: 1200
package/dist/cli.js CHANGED
@@ -10,6 +10,16 @@
10
10
  * retake run demos/x.yaml --headed --no-render watch the browser, keep only the raw take
11
11
  * retake render outputs/x re-render from the existing take (--preset to switch)
12
12
  * retake check outputs/x pass/fail on resolution, fps, duration, files
13
+ * retake verify outputs/x did it LOOK right — each scene's `expect`, judged
14
+ * retake calibrate demos/x.yaml how much to trust `sweep` — seed defects, count what it finds
15
+ * retake intent what this product IS — context every visual check needs
16
+ * retake sweep outputs/x look at every frame as a whole — what nobody thought to ask about
17
+ * retake sense outputs/x does the run ADD UP — what went in vs what came out
18
+ * retake destroy demos/x.yaml the flows nobody wrote down — abuse this demo nine ways
19
+ * retake flag demos/x.yaml --scene s --expect "…" this one is real: watch it from now on
20
+ * retake fixed x did what you flagged get fixed — with the clip that shows it
21
+ * retake heal demo files back for recordings whose manifest went missing
22
+ * retake notes read outputs/ back: what keeps going wrong, and what it costs
13
23
  * retake dry demos/x.yaml every selector and wait, no camera
14
24
  * retake validate demos/x.yaml schema check only
15
25
  * retake presets list quality presets
@@ -25,10 +35,20 @@ import { createRequire } from "node:module";
25
35
  import { spawnSync } from "node:child_process";
26
36
  import { Command } from "commander";
27
37
  import { loadManifest, resolve, warnings } from "./manifest.js";
28
- import { acquireLock, captureHash, EXPENSIVE_TAKE_SECONDS, keepPrevious, keptTakes, lastCaptureSeconds, record, releaseLock, restoreKept, restorePrevious, stashPrevious } from "./record.js";
38
+ import { acquireLock, canReuse, EXPENSIVE_TAKE_SECONDS, keepPrevious, keptTakes, lastCaptureSeconds, unchangedUpTo, record, releaseLock, restoreKept, restorePrevious, stashPrevious } from "./record.js";
29
39
  import { check, render } from "./render.js";
40
+ import { endProgress, setPhase } from "./progress.js";
30
41
  import { presetNames } from "./presets.js";
31
42
  import { applyTidy, mb, planTidy } from "./tidy.js";
43
+ import { verify } from "./ext/verify.js";
44
+ import { notes } from "./ext/notes.js";
45
+ import { sense } from "./ext/sense.js";
46
+ import { sweep } from "./ext/sweep.js";
47
+ import { TEMPLATE, readIntent, writeIntent } from "./ext/intent.js";
48
+ import { planCalibration, report } from "./ext/calibrate.js";
49
+ import { checkFlags, flag, unflag } from "./ext/flags.js";
50
+ import { describeOrphans, findOrphans, healOrphans } from "./ext/heal.js";
51
+ import { SHAPES, describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "./ext/destroy.js";
32
52
  import { PKG_ROOT, VERSION, entry } from "./paths.js";
33
53
  import { SECRET_NAME, writeEnvFile } from "./env.js";
34
54
  // The manifest may reference ${VARS}; load .env before any command reads one.
@@ -42,6 +62,11 @@ try {
42
62
  catch { /* .env is optional */ }
43
63
  const program = new Command();
44
64
  program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
65
+ // `retake notes | head` closes the pipe before the writer is done, and node
66
+ // turns that into an unhandled EPIPE and a stack trace. A command-line tool
67
+ // should stop quietly when whoever was reading walks away.
68
+ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
69
+ process.exit(0); throw e; });
45
70
  const say = (l) => process.stdout.write(l + "\n");
46
71
  /** Said once, before the camera rolls, when a take is long enough that the
47
72
  cheaper ways of getting the same answer are worth knowing about.
@@ -83,7 +108,9 @@ program
83
108
  .option("--preset <name>", `override the manifest's preset (${presetNames().join(", ")})`)
84
109
  .option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
85
110
  .option("--gif", "also produce a GIF (overrides the manifest)", false)
111
+ .option("--no-master", "skip the archival CRF-14 master — roughly halves render on a long take, and the deliverable is marginally better for it (one encode instead of an encode of an encode)")
86
112
  .option("--until <scene>", "record up to the end of this scene, then stop (iterate on one beat without paying for the whole take)")
113
+ .option("--brisk", "record the whole thing without its pacing — every pause skipped, every wait capped. All the steps, none of the holding still: on a long demo that is minutes instead of many. For iterating; record the real one without it.", false)
87
114
  .option("--from <scene>", "start the take at this scene — earlier steps still run, at full speed and off camera, so a demo whose ENDING changed stops costing its beginning")
88
115
  .option("--name <name>", "write to outputs/<name> instead of the manifest's name — revise a cut without overwriting the published one")
89
116
  .action(async (file, opts) => {
@@ -115,7 +142,7 @@ program
115
142
  if (opts.reuse && fs.existsSync(takePath)) {
116
143
  try {
117
144
  const prev = JSON.parse(fs.readFileSync(takePath, "utf8"));
118
- if (prev.captureHash && prev.captureHash === captureHash(manifest) && prev.video && fs.existsSync(prev.video) && !prev.partial)
145
+ if (canReuse(prev, manifest))
119
146
  take = prev;
120
147
  }
121
148
  catch { /* record fresh */ }
@@ -125,6 +152,16 @@ program
125
152
  if (!take)
126
153
  for (const l of costHints(manifest, outDir, opts))
127
154
  say(l);
155
+ // Say it while it can still be acted on, not in the summary afterwards.
156
+ if (!take && !opts.until && !opts.from) {
157
+ const u = unchangedUpTo(manifest, outDir);
158
+ const last = lastCaptureSeconds(outDir);
159
+ if (u && u.steps > 3) {
160
+ const share = Math.round((u.steps / manifest.steps.length) * 100);
161
+ say(`ℹ the first ${u.steps} of ${manifest.steps.length} steps are identical to your last take of this demo (${share}% of it).`);
162
+ say(` · --from ${u.scene} records only what changed${last ? `, so roughly ${Math.max(5, Math.round((last * (100 - share)) / 100))}s instead of ${Math.round(last)}s` : ""}`);
163
+ }
164
+ }
128
165
  say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
129
166
  say(`[stage] capture ${take ? "skip" : "start"}`);
130
167
  const t0 = Date.now();
@@ -133,11 +170,12 @@ program
133
170
  // the person still has the one that worked. See stashPrevious.
134
171
  stashPrevious(outDir);
135
172
  try {
136
- take = await record(manifest, { until: opts.until, from: opts.from, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
173
+ take = await record(manifest, { until: opts.until, from: opts.from, brisk: opts.brisk, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
137
174
  }
138
175
  catch (e) {
139
176
  if (restorePrevious(outDir))
140
177
  say("↩ recording failed — your previous take has been put back");
178
+ endProgress(outDir);
141
179
  releaseLock(outDir);
142
180
  throw e;
143
181
  }
@@ -147,8 +185,9 @@ program
147
185
  try {
148
186
  if (opts.render) {
149
187
  say(`[stage] render start`);
188
+ setPhase(outDir, { phase: "rendering", label: "encoding the video" });
150
189
  const tr = Date.now();
151
- const a = await render(manifest, take, outDir, { log: (l) => { if (!l.startsWith("$"))
190
+ const a = await render(manifest, take, outDir, { noMaster: opts.master === false, log: (l) => { if (!l.startsWith("$"))
152
191
  say(l); } });
153
192
  if (!opts.keepRaw)
154
193
  cleanRaw(outDir, take, a.mp4);
@@ -160,9 +199,11 @@ program
160
199
  const c = check(outDir, manifest);
161
200
  say(c.ok ? "check: pass" : "check: FAIL\n " + c.lines.filter((l) => l.startsWith("FAIL")).join("\n "));
162
201
  }
202
+ setPhase(outDir, { phase: "checking", label: "checking the result" });
163
203
  say(`[stage] done`);
164
204
  }
165
205
  finally {
206
+ endProgress(outDir);
166
207
  releaseLock(outDir);
167
208
  }
168
209
  if (take.partial)
@@ -213,6 +254,7 @@ program
213
254
  say(`✓ ${path.relative(process.cwd(), f)}`);
214
255
  }
215
256
  finally {
257
+ endProgress(outDir);
216
258
  releaseLock(outDir);
217
259
  }
218
260
  });
@@ -223,9 +265,10 @@ program
223
265
  .option("--apply", "actually remove them", false)
224
266
  .option("--deep", "also remove demo.mp4 (still re-renderable, but it is the deliverable)", false)
225
267
  .option("--orphans", "also remove whole folders whose manifest is gone", false)
268
+ .option("--failed", "also remove the whole folder of any take whose steps failed — raw recording included, and there is no getting it back", false)
226
269
  .action((opts) => {
227
270
  const root = path.resolve(opts.out);
228
- const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, demosDir: path.resolve("demos") });
271
+ const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, failed: opts.failed, demosDir: path.resolve("demos") });
229
272
  if (!plan.bytes) {
230
273
  say(`nothing to reclaim in ${path.relative(process.cwd(), root)} — ${mb(plan.keptBytes)} of recordings and deliverables, all of it worth keeping.`);
231
274
  return;
@@ -247,6 +290,227 @@ program
247
290
  const freed = applyTidy(plan);
248
291
  say(`\nfreed ${mb(freed)}. Every demo can still be rebuilt with \`retake render outputs/<name>\`.`);
249
292
  });
293
+ program
294
+ .command("flag")
295
+ .description("mark something as really wrong — it becomes an `expect:` on that scene, and every later recording answers it")
296
+ .argument("<manifest>", "the demo it is wrong in")
297
+ .requiredOption("--scene <label>", "which scene shows it")
298
+ .requiredOption("--expect <sentence>", "what has to be true once it is fixed, in plain words")
299
+ .option("--question <sentence>", "what was originally raised, if it came from `sense`")
300
+ .action((file, opts) => {
301
+ const r = flag(path.resolve(file), { scene: opts.scene, expect: opts.expect, question: opts.question ?? opts.expect, source: opts.question ? "sense" : "you" });
302
+ if ("error" in r) {
303
+ say(`✗ ${r.error}`);
304
+ process.exitCode = 2;
305
+ return;
306
+ }
307
+ say(`flagged on ${opts.scene}: "${r.flag.expect}"`);
308
+ say(`Every recording from here on answers it — \`verify\` and \`fixed\` both.`);
309
+ say(`\`retake fixed ${path.basename(file).replace(/\.[^.]+$/, "")}\` after the next take.`);
310
+ });
311
+ program
312
+ .command("unflag")
313
+ .description("stop watching something you flagged")
314
+ .argument("<manifest>", "the demo it was flagged on")
315
+ .argument("<what>", "the flag's id, or the expectation itself")
316
+ .action((file, what) => {
317
+ const r = unflag(path.resolve(file), what);
318
+ if ("error" in r) {
319
+ say(`✗ ${r.error}`);
320
+ process.exitCode = 2;
321
+ return;
322
+ }
323
+ say(`no longer watching: "${r.removed.expect}"`);
324
+ });
325
+ program
326
+ .command("fixed")
327
+ .description("did the things you flagged get fixed — answers each one against the newest take, with the few seconds that show it")
328
+ .argument("<demo>", "demo name, or a path to its manifest")
329
+ .option("-o, --out <dir>", "output root", "outputs")
330
+ .option("--no-clips", "skip cutting the clips")
331
+ .action(async (demo, opts) => {
332
+ const file = fs.existsSync(demo) ? path.resolve(demo) : path.resolve("demos", `${demo}.yaml`);
333
+ if (!fs.existsSync(file))
334
+ throw new Error(`no ${path.relative(process.cwd(), file)}`);
335
+ const { manifest } = loadManifest(file);
336
+ const outDir = path.resolve(opts.out, manifest.name);
337
+ const r = await checkFlags(file, manifest, outDir, { clips: opts.clips, log: say });
338
+ // Something flagged and still wrong is a failure; nothing flagged is not.
339
+ if (r.checked.some((c) => c.ok !== true))
340
+ process.exitCode = 3;
341
+ });
342
+ program
343
+ .command("destroy")
344
+ .description("the flows nobody wrote down — writes a manifest for each way this demo could be abused, and tries them")
345
+ .argument("<manifest>", "the demo to derive candidates from")
346
+ .option("-o, --out <dir>", "output root", "outputs")
347
+ .option("--only <shapes>", "just these, comma-separated (see --shapes)")
348
+ .option("--shapes", "list the ways it knows how to break something, and stop", false)
349
+ .option("--run", "actually perform the steps instead of only resolving them — slower, and the only way to catch what happens when an app is really used", false)
350
+ .option("--no-try", "write the candidates and stop")
351
+ .action(async (file, opts) => {
352
+ if (opts.shapes) {
353
+ for (const s of SHAPES) {
354
+ say(`${s.name}`);
355
+ say(` ${s.asks}`);
356
+ say(` ${s.because}`);
357
+ say("");
358
+ }
359
+ return;
360
+ }
361
+ const loaded = loadManifest(file);
362
+ const plan = planDestroy(loaded.manifest, path.resolve(opts.out), { only: opts.only?.split(",").map((x) => x.trim()).filter(Boolean) });
363
+ for (const l of describePlan(plan, loaded.manifest))
364
+ say(l);
365
+ if (plan.refused) {
366
+ process.exitCode = 2;
367
+ return;
368
+ }
369
+ if (!opts.try || !plan.candidates.length)
370
+ return;
371
+ // Generating is safe — it makes files. Running clicks things, and that is
372
+ // where the two refusals bite.
373
+ const no = refuseToRun(loaded.manifest, process.env, path.resolve(opts.out));
374
+ if (no) {
375
+ say("");
376
+ say(`✗ not running these: ${no}`);
377
+ process.exitCode = 2;
378
+ return;
379
+ }
380
+ say("");
381
+ const trials = await tryCandidates(plan, { mode: opts.run ? "run" : "dry", manifestDir: loaded.dir, outRoot: path.resolve(opts.out), log: say });
382
+ for (const l of describeTrials(trials, opts.run ? "run" : "dry"))
383
+ say(l);
384
+ if (trials.some((t) => t.verdict === "broke"))
385
+ process.exitCode = 3;
386
+ });
387
+ program
388
+ .command("calibrate")
389
+ .description("how much to trust `sweep` — seeds a known defect into a working demo, records each one, and counts what it finds")
390
+ .argument("<manifest>", "a demo whose app is running")
391
+ .option("-o, --out <dir>", "output root", "outputs")
392
+ .option("--only <names>", "just these defects, comma-separated")
393
+ .option("--plan", "write the variants and stop", false)
394
+ .action(async (file, opts) => {
395
+ const loaded = loadManifest(file);
396
+ const outRoot = path.resolve(opts.out);
397
+ const plan = planCalibration(loaded.manifest, outRoot, { only: opts.only?.split(",").map((x) => x.trim()).filter(Boolean) });
398
+ say(`${plan.variants.length} variants in ${path.relative(process.cwd(), plan.root)}/ — one control, ${plan.variants.length - 1} seeded`);
399
+ if (opts.plan)
400
+ return;
401
+ const results = [];
402
+ for (const v of plan.variants) {
403
+ const dir = path.join(plan.root, v.name);
404
+ say(`\n${v.name}${v.defect ? ` — ${v.defect.looks}` : ""}`);
405
+ try {
406
+ const take = await record(v.manifest, { outDir: dir, manifestDir: loaded.dir, headed: false, log: () => { } });
407
+ await render(v.manifest, take, dir, { log: () => { }, noMaster: true });
408
+ const s = await sweep(v.manifest, dir, () => { }, { demosDir: path.resolve("demos") });
409
+ const took = true; // the seed proved itself in the page, or the take threw
410
+ const kinds = s.frames.flatMap((f) => f.issues.map((i) => i.kind));
411
+ const found = !v.defect ? false : kinds.some((k) => k.toUpperCase().startsWith(v.defect.expect.split(" ")[0]));
412
+ results.push({ variant: v.name, defect: v.defect, kinds, found: found && took, seeded: took, other: v.defect ? kinds.filter((k) => !k.toUpperCase().startsWith(v.defect.expect.split(" ")[0])).length : 0 });
413
+ say(` → ${kinds.join(", ") || "nothing"}${v.defect && !took ? " (the seed did not land — nothing was asked of the check)" : ""}`);
414
+ }
415
+ catch (e) {
416
+ results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: !/seed did not take/.test(String(e.message)), other: 0, error: String(e.message).split("\n")[0].slice(0, 90) });
417
+ say(` → could not run: ${String(e.message).split("\n")[0].slice(0, 90)}`);
418
+ }
419
+ }
420
+ for (const l of report(results))
421
+ say(l);
422
+ fs.writeFileSync(path.join(plan.root, "report.json"), JSON.stringify({ at: new Date().toISOString(), demo: loaded.manifest.name, results }, null, 2) + "\n");
423
+ });
424
+ program
425
+ .command("intent")
426
+ .description("what this product IS — the context every visual check is missing without it")
427
+ .argument("[text]", "the note to save; omit to read what is there")
428
+ .option("--demo <name>", "a note for one demo's app, rather than the whole workspace")
429
+ .option("--starter", "write a starter file to fill in", false)
430
+ .action((text, opts) => {
431
+ const demos = path.resolve("demos");
432
+ if (opts.starter || (!text && !readIntent(demos, opts.demo))) {
433
+ if (readIntent(demos, opts.demo) && !text) {
434
+ say(readIntent(demos, opts.demo));
435
+ return;
436
+ }
437
+ say(`wrote ${path.relative(process.cwd(), writeIntent(demos, TEMPLATE, opts.demo))} — fill it in.`);
438
+ say("Without it, every check judges your app as a generic web page: anything deliberate about it can come back as a defect.");
439
+ return;
440
+ }
441
+ if (!text) {
442
+ say(readIntent(demos, opts.demo) ?? "");
443
+ return;
444
+ }
445
+ say(`saved to ${path.relative(process.cwd(), writeIntent(demos, text, opts.demo))}`);
446
+ });
447
+ program
448
+ .command("sweep")
449
+ .description("look at every frame as a whole — the ten ways a picture can be wrong, checked on each one, whether or not anybody asked")
450
+ .argument("<dir>", "an outputs/<name> dir")
451
+ .argument("[manifest]", "manifest (default demos/<name>.yaml)")
452
+ .option("--all", "the middle of each scene too, not just its last moment", false)
453
+ .option("--concurrency <n>", "how many frames to look at at once", "4")
454
+ .option("--fresh", "ask about every frame again, even the ones that have not changed", false)
455
+ .action(async (dir, manifestFile, opts) => {
456
+ const outDir = path.resolve(dir);
457
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
458
+ if (!fs.existsSync(file))
459
+ throw new Error(`no ${file} — pass the manifest path`);
460
+ // No exit code, deliberately. Some of these are judgement; `verify` gates.
461
+ await sweep(loadManifest(file).manifest, outDir, say, { all: opts.all, concurrency: Number(opts.concurrency) || 4, fresh: opts.fresh });
462
+ });
463
+ program
464
+ .command("sense")
465
+ .description("does the run ADD UP — what went in against what came out, across the whole demo")
466
+ .argument("<dir>", "an outputs/<name> dir")
467
+ .argument("[manifest]", "manifest (default demos/<name>.yaml)")
468
+ .action((dir, manifestFile) => {
469
+ const outDir = path.resolve(dir);
470
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
471
+ if (!fs.existsSync(file))
472
+ throw new Error(`no ${file} — pass the manifest path`);
473
+ // Deliberately no exit code. sense asks questions; `verify` is the gate.
474
+ sense(loadManifest(file).manifest, outDir, say);
475
+ });
476
+ program
477
+ .command("heal")
478
+ .description("write back the demo files for recordings whose manifest went missing — the window only lists demos, so those recordings are invisible until it does")
479
+ .option("-o, --out <dir>", "output root", "outputs")
480
+ .option("--apply", "actually write them (without this it only says what it would do)", false)
481
+ .action((opts) => {
482
+ const orphans = findOrphans(path.resolve(opts.out), path.resolve("demos"));
483
+ const written = opts.apply ? healOrphans(orphans, path.resolve("demos")) : null;
484
+ for (const l of describeOrphans(orphans, written))
485
+ say(l);
486
+ });
487
+ program
488
+ .command("notes")
489
+ .description("what everyone has actually been doing — reads outputs/ back and says the few things worth saying")
490
+ .option("-o, --out <dir>", "output root", "outputs")
491
+ .option("--all", "every note, not just the five that matter", false)
492
+ .option("--days <n>", "how far back to look", "14")
493
+ .action((opts) => {
494
+ const { lines } = notes(path.resolve(opts.out), { all: opts.all, days: Number(opts.days) || 14 });
495
+ for (const l of lines)
496
+ say(l);
497
+ });
498
+ program
499
+ .command("verify")
500
+ .description("did it LOOK right — puts each scene's `expect` question to a vision model against that scene's still")
501
+ .argument("<dir>", "an outputs/<name> dir containing take.json and stills/")
502
+ .argument("[manifest]", "manifest to read the expectations from (default demos/<name>.yaml)")
503
+ .action(async (dir, manifestFile) => {
504
+ const outDir = path.resolve(dir);
505
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
506
+ if (!fs.existsSync(file))
507
+ throw new Error(`no ${file} — pass the manifest path`);
508
+ const { manifest } = loadManifest(file);
509
+ const v = await verify(manifest, outDir, say, file);
510
+ // 3, the same code dry and check already use for "this did not pass".
511
+ if (!v.ok)
512
+ process.exitCode = 3;
513
+ });
250
514
  program
251
515
  .command("render")
252
516
  .argument("<dir>", "an outputs/<name> dir containing take.json")
@@ -255,6 +519,7 @@ program
255
519
  .option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
256
520
  .option("--gif", "also produce a GIF", false)
257
521
  .option("--force", "ignore the render cache", false)
522
+ .option("--no-master", "skip the archival CRF-14 master — halves render on a long take")
258
523
  .option("--out <dir>", "write the render into a NEW directory, leaving the original untouched")
259
524
  .action(async (dir, manifestFile, opts) => {
260
525
  const takePath = path.join(dir, "take.json");
@@ -296,7 +561,7 @@ program
296
561
  manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
297
562
  acquireLock(path.resolve(dir));
298
563
  try {
299
- const a = await render(manifest, take, path.resolve(dir), { log: say, force: opts.force, scene: opts.scene });
564
+ const a = await render(manifest, take, path.resolve(dir), { log: say, force: opts.force, scene: opts.scene, noMaster: opts.master === false });
300
565
  for (const f of [a.master, a.mp4, a.gif, a.thumbnail])
301
566
  if (f)
302
567
  say(`✓ ${path.relative(process.cwd(), f)}`);
package/dist/describe.js CHANGED
@@ -313,7 +313,7 @@ const SCHEMA_DOC = `
313
313
  Manifest YAML fields:
314
314
  name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
315
315
  camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
316
- NEVER set viewport. The preset decides the size and every take at a preset comes out identical; setting viewport makes this demo a different shape from every other one and players letterbox it. Same for cursor, chrome, background, fps, crf and captions — the preset handles all of it.
316
+ NEVER set captions: true unless the person asked for captions — they are off by default because a caption makes a viewer read instead of watch. Write a caption on each scene anyway: it names the beat in the proof log and the stills. NEVER set viewport. The preset decides the size and every take at a preset comes out identical; setting viewport makes this demo a different shape from every other one and players letterbox it. Same for cursor, chrome, background, fps, crf and captions — the preset handles all of it.
317
317
  Always set mode: demo — this is a demo, so nothing goes in the frame but the product. Never emit intro or outro cards, music, callouts or voiceover: those belong to mode: launch, which only a person can ask for.
318
318
  Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
319
319
  {action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.