retake-demos 0.4.1 → 0.12.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,82 @@ 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
+ - `retake intent` — what the product IS, in the builder's words: what it does,
230
+ who uses it, and what about it is deliberate. Every visual check reads it.
231
+ Without it they judge the app as a generic web page — measured on a real
232
+ demo: a faint internal note that a charter desk is MEANT to see quietly came
233
+ back as a contrast defect, and stopped once the judge knew what the screen
234
+ was for. One file per app (`--demo <name>`) or one for the workspace. Write
235
+ it the first time the person explains their product; it is context for
236
+ judging, never instructions.
237
+ - Every finding now carries how it was arrived at — `[reproduced]`,
238
+ `[seen-in-a-frame]`, `[read-from-the-run]`, `[source-only]`, `[unverified]`.
239
+ Say the tag when you repeat a finding. A thing you read in the code and a
240
+ thing you watched happen must not reach the person in the same voice.
241
+ - `retake sweep outputs/<name>` — the one that finds what nobody thought to
242
+ ask about. `verify` answers written-down questions, so it can only find what
243
+ was already suspected. `sweep` inspects EVERY scene's frame, one at a time,
244
+ never sampled, against a closed ten-item checklist: clipped text, overlap,
245
+ contrast, cut off, misalignment, doubled, unfinished, broken media, dev UI in
246
+ shot, empty regions. Advisory — it reports, it does not gate. Roughly one
247
+ judge call per frame, so about two minutes on a seventeen-scene demo.
248
+ - `retake sense outputs/<name>` — the verdict on the RUN, not on one frame.
249
+ Everything the demo typed, chose and clicked, put against the frames that
250
+ came out, through six lenses: quantity, continuity, state, units and labels,
251
+ order, dead ends. Needs nothing declared — the inputs are already in the
252
+ take. It ASKS rather than fails (exit stays 0), because whether a number adds
253
+ up is judgement. Found a real one first time out: two flight legs entered,
254
+ one unlabelled price shown.
255
+ - `retake destroy demos/<name>.yaml` — the flows nobody wrote down. Derives a
256
+ manifest for each of nine abuses (double-submit, reload-midway, back-button,
257
+ provider-down, provider-empty, empty-state, long-input, awkward-input,
258
+ impatient), writes them under `outputs/.destroy/<name>/`, and tries them.
259
+ `--run` performs them for real and keeps the frames. Three verdicts, not
260
+ two: **broke**, **worth a look** (only a person can call it), **held** — and
261
+ a failed second press counts as held, because the button being gone is the
262
+ app defending itself. Refuses a non-local URL, a demo that neither seeds nor
263
+ stubs, and an app somebody else is recording. Each refusal names its own
264
+ override; using one is the person's call.
265
+ - `retake heal` — writes back the demo files for recordings whose manifest went
266
+ missing. The window lists demos, not recordings, so a manifest deleted or
267
+ written outside `demos/` makes a finished video invisible. Every output folder
268
+ keeps `manifest.used.yaml`; this restores from that. Shows what it would do
269
+ unless you pass `--apply`. **Never record from a manifest that does not live
270
+ in `demos/` — this verb exists to clean up after that mistake, not to license
271
+ it.**
272
+ - Every check leaves a receipt in `outputs/<name>/checks.json` — which check
273
+ ran, against which take, and what it said. The window shows it as one line,
274
+ and `retake notes` names every demo nobody has inspected. Whether you checked
275
+ is not something anyone has to take your word for.
276
+ - **None of the checks are required for a recording to be complete.** `check`
277
+ is the core's own verdict on a take and consults none of them. verify, sweep,
278
+ sense, destroy and fixed are optional services run against a take that
279
+ already exists, and their results are advisory unless a person promotes one.
280
+ Recommend them for the claim that the APP is right; never imply a demo is
281
+ unfinished without them.
282
+ - `retake notes` — the watcher. Reads every take in `outputs/` back and says
283
+ the few things a person would say after looking through them: a stub that
284
+ answered nothing (the demo is showing live data), the same selector failing
285
+ in several demos (the app moved, not the demos), a fragment left where a
286
+ finished cut should be, a dead lock holding a folder, demos nobody has ever
287
+ verified. Silent when there is nothing. Run it when you arrive in a
288
+ workspace you did not set up.
289
+ - `retake verify outputs/<name>` — the verdict on the PICTURE. Every scene
290
+ with an `expect:` has its plain-language question put to a separate reader
291
+ that can see the still. Exit 3 on failure; an unanswerable question counts
292
+ as a failure, never a pass. This is the one check that catches DOM-correct,
293
+ visually-wrong: a frozen animation, a 404ing icon, a card out of its grid,
294
+ dark text on a dark pill. Use it before reporting that a screen works.
210
295
  - `retake check outputs/<name>` — resolution, fps, duration, files,
211
296
  pass/fail. Exit 3 on failure.
212
297
 
@@ -275,3 +360,27 @@ it belongs in the commit message, which is where that audience already is.
275
360
 
276
361
  Good: "You choose the cover image. Click any frame to make it the one people
277
362
  see first." Bad: "Poster selection is now persisted via a marker file."
363
+
364
+ ## The landing page is public — look at it before you commit
365
+
366
+ `site/index.html` is the page strangers see. It has been shipped broken
367
+ twice by checking that a string was present rather than that the page was
368
+ right: an unbalanced `<div>` dropped a card out of its grid, and a
369
+ catch-all route quietly swallowed the favicon. Neither showed up in a grep;
370
+ both were obvious in a screenshot.
371
+
372
+ There is a demo for it. Before committing any change to the page:
373
+
374
+ ```bash
375
+ retake run demos/landing-review.yaml --no-master
376
+ ```
377
+
378
+ No `--preset`: the manifest pins 1440 so this checks the DESKTOP layout,
379
+ and a preset override would drop it to 960 — below the breakpoint — so the
380
+ review would check a layout nobody sees on a laptop.
381
+
382
+ Then LOOK at `outputs/landing-review/stills/` — one still per section, named
383
+ after the section. Thirty seconds, and it is the only check that catches
384
+ layout.
385
+
386
+ 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,15 @@
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 intent what this product IS — context every visual check needs
15
+ * retake sweep outputs/x look at every frame as a whole — what nobody thought to ask about
16
+ * retake sense outputs/x does the run ADD UP — what went in vs what came out
17
+ * retake destroy demos/x.yaml the flows nobody wrote down — abuse this demo nine ways
18
+ * retake flag demos/x.yaml --scene s --expect "…" this one is real: watch it from now on
19
+ * retake fixed x did what you flagged get fixed — with the clip that shows it
20
+ * retake heal demo files back for recordings whose manifest went missing
21
+ * retake notes read outputs/ back: what keeps going wrong, and what it costs
13
22
  * retake dry demos/x.yaml every selector and wait, no camera
14
23
  * retake validate demos/x.yaml schema check only
15
24
  * retake presets list quality presets
@@ -25,10 +34,19 @@ import { createRequire } from "node:module";
25
34
  import { spawnSync } from "node:child_process";
26
35
  import { Command } from "commander";
27
36
  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";
37
+ import { acquireLock, canReuse, EXPENSIVE_TAKE_SECONDS, keepPrevious, keptTakes, lastCaptureSeconds, unchangedUpTo, record, releaseLock, restoreKept, restorePrevious, stashPrevious } from "./record.js";
29
38
  import { check, render } from "./render.js";
39
+ import { endProgress, setPhase } from "./progress.js";
30
40
  import { presetNames } from "./presets.js";
31
41
  import { applyTidy, mb, planTidy } from "./tidy.js";
42
+ import { verify } from "./ext/verify.js";
43
+ import { notes } from "./ext/notes.js";
44
+ import { sense } from "./ext/sense.js";
45
+ import { sweep } from "./ext/sweep.js";
46
+ import { TEMPLATE, readIntent, writeIntent } from "./ext/intent.js";
47
+ import { checkFlags, flag, unflag } from "./ext/flags.js";
48
+ import { describeOrphans, findOrphans, healOrphans } from "./ext/heal.js";
49
+ import { SHAPES, describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "./ext/destroy.js";
32
50
  import { PKG_ROOT, VERSION, entry } from "./paths.js";
33
51
  import { SECRET_NAME, writeEnvFile } from "./env.js";
34
52
  // The manifest may reference ${VARS}; load .env before any command reads one.
@@ -42,6 +60,11 @@ try {
42
60
  catch { /* .env is optional */ }
43
61
  const program = new Command();
44
62
  program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
63
+ // `retake notes | head` closes the pipe before the writer is done, and node
64
+ // turns that into an unhandled EPIPE and a stack trace. A command-line tool
65
+ // should stop quietly when whoever was reading walks away.
66
+ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
67
+ process.exit(0); throw e; });
45
68
  const say = (l) => process.stdout.write(l + "\n");
46
69
  /** Said once, before the camera rolls, when a take is long enough that the
47
70
  cheaper ways of getting the same answer are worth knowing about.
@@ -83,7 +106,9 @@ program
83
106
  .option("--preset <name>", `override the manifest's preset (${presetNames().join(", ")})`)
84
107
  .option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
85
108
  .option("--gif", "also produce a GIF (overrides the manifest)", false)
109
+ .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
110
  .option("--until <scene>", "record up to the end of this scene, then stop (iterate on one beat without paying for the whole take)")
111
+ .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
112
  .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
113
  .option("--name <name>", "write to outputs/<name> instead of the manifest's name — revise a cut without overwriting the published one")
89
114
  .action(async (file, opts) => {
@@ -115,7 +140,7 @@ program
115
140
  if (opts.reuse && fs.existsSync(takePath)) {
116
141
  try {
117
142
  const prev = JSON.parse(fs.readFileSync(takePath, "utf8"));
118
- if (prev.captureHash && prev.captureHash === captureHash(manifest) && prev.video && fs.existsSync(prev.video) && !prev.partial)
143
+ if (canReuse(prev, manifest))
119
144
  take = prev;
120
145
  }
121
146
  catch { /* record fresh */ }
@@ -125,6 +150,16 @@ program
125
150
  if (!take)
126
151
  for (const l of costHints(manifest, outDir, opts))
127
152
  say(l);
153
+ // Say it while it can still be acted on, not in the summary afterwards.
154
+ if (!take && !opts.until && !opts.from) {
155
+ const u = unchangedUpTo(manifest, outDir);
156
+ const last = lastCaptureSeconds(outDir);
157
+ if (u && u.steps > 3) {
158
+ const share = Math.round((u.steps / manifest.steps.length) * 100);
159
+ say(`ℹ the first ${u.steps} of ${manifest.steps.length} steps are identical to your last take of this demo (${share}% of it).`);
160
+ 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` : ""}`);
161
+ }
162
+ }
128
163
  say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
129
164
  say(`[stage] capture ${take ? "skip" : "start"}`);
130
165
  const t0 = Date.now();
@@ -133,11 +168,12 @@ program
133
168
  // the person still has the one that worked. See stashPrevious.
134
169
  stashPrevious(outDir);
135
170
  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 });
171
+ 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
172
  }
138
173
  catch (e) {
139
174
  if (restorePrevious(outDir))
140
175
  say("↩ recording failed — your previous take has been put back");
176
+ endProgress(outDir);
141
177
  releaseLock(outDir);
142
178
  throw e;
143
179
  }
@@ -147,8 +183,9 @@ program
147
183
  try {
148
184
  if (opts.render) {
149
185
  say(`[stage] render start`);
186
+ setPhase(outDir, { phase: "rendering", label: "encoding the video" });
150
187
  const tr = Date.now();
151
- const a = await render(manifest, take, outDir, { log: (l) => { if (!l.startsWith("$"))
188
+ const a = await render(manifest, take, outDir, { noMaster: opts.master === false, log: (l) => { if (!l.startsWith("$"))
152
189
  say(l); } });
153
190
  if (!opts.keepRaw)
154
191
  cleanRaw(outDir, take, a.mp4);
@@ -160,9 +197,11 @@ program
160
197
  const c = check(outDir, manifest);
161
198
  say(c.ok ? "check: pass" : "check: FAIL\n " + c.lines.filter((l) => l.startsWith("FAIL")).join("\n "));
162
199
  }
200
+ setPhase(outDir, { phase: "checking", label: "checking the result" });
163
201
  say(`[stage] done`);
164
202
  }
165
203
  finally {
204
+ endProgress(outDir);
166
205
  releaseLock(outDir);
167
206
  }
168
207
  if (take.partial)
@@ -213,6 +252,7 @@ program
213
252
  say(`✓ ${path.relative(process.cwd(), f)}`);
214
253
  }
215
254
  finally {
255
+ endProgress(outDir);
216
256
  releaseLock(outDir);
217
257
  }
218
258
  });
@@ -223,9 +263,10 @@ program
223
263
  .option("--apply", "actually remove them", false)
224
264
  .option("--deep", "also remove demo.mp4 (still re-renderable, but it is the deliverable)", false)
225
265
  .option("--orphans", "also remove whole folders whose manifest is gone", false)
266
+ .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
267
  .action((opts) => {
227
268
  const root = path.resolve(opts.out);
228
- const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, demosDir: path.resolve("demos") });
269
+ const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, failed: opts.failed, demosDir: path.resolve("demos") });
229
270
  if (!plan.bytes) {
230
271
  say(`nothing to reclaim in ${path.relative(process.cwd(), root)} — ${mb(plan.keptBytes)} of recordings and deliverables, all of it worth keeping.`);
231
272
  return;
@@ -247,6 +288,190 @@ program
247
288
  const freed = applyTidy(plan);
248
289
  say(`\nfreed ${mb(freed)}. Every demo can still be rebuilt with \`retake render outputs/<name>\`.`);
249
290
  });
291
+ program
292
+ .command("flag")
293
+ .description("mark something as really wrong — it becomes an `expect:` on that scene, and every later recording answers it")
294
+ .argument("<manifest>", "the demo it is wrong in")
295
+ .requiredOption("--scene <label>", "which scene shows it")
296
+ .requiredOption("--expect <sentence>", "what has to be true once it is fixed, in plain words")
297
+ .option("--question <sentence>", "what was originally raised, if it came from `sense`")
298
+ .action((file, opts) => {
299
+ const r = flag(path.resolve(file), { scene: opts.scene, expect: opts.expect, question: opts.question ?? opts.expect, source: opts.question ? "sense" : "you" });
300
+ if ("error" in r) {
301
+ say(`✗ ${r.error}`);
302
+ process.exitCode = 2;
303
+ return;
304
+ }
305
+ say(`flagged on ${opts.scene}: "${r.flag.expect}"`);
306
+ say(`Every recording from here on answers it — \`verify\` and \`fixed\` both.`);
307
+ say(`\`retake fixed ${path.basename(file).replace(/\.[^.]+$/, "")}\` after the next take.`);
308
+ });
309
+ program
310
+ .command("unflag")
311
+ .description("stop watching something you flagged")
312
+ .argument("<manifest>", "the demo it was flagged on")
313
+ .argument("<what>", "the flag's id, or the expectation itself")
314
+ .action((file, what) => {
315
+ const r = unflag(path.resolve(file), what);
316
+ if ("error" in r) {
317
+ say(`✗ ${r.error}`);
318
+ process.exitCode = 2;
319
+ return;
320
+ }
321
+ say(`no longer watching: "${r.removed.expect}"`);
322
+ });
323
+ program
324
+ .command("fixed")
325
+ .description("did the things you flagged get fixed — answers each one against the newest take, with the few seconds that show it")
326
+ .argument("<demo>", "demo name, or a path to its manifest")
327
+ .option("-o, --out <dir>", "output root", "outputs")
328
+ .option("--no-clips", "skip cutting the clips")
329
+ .action(async (demo, opts) => {
330
+ const file = fs.existsSync(demo) ? path.resolve(demo) : path.resolve("demos", `${demo}.yaml`);
331
+ if (!fs.existsSync(file))
332
+ throw new Error(`no ${path.relative(process.cwd(), file)}`);
333
+ const { manifest } = loadManifest(file);
334
+ const outDir = path.resolve(opts.out, manifest.name);
335
+ const r = await checkFlags(file, manifest, outDir, { clips: opts.clips, log: say });
336
+ // Something flagged and still wrong is a failure; nothing flagged is not.
337
+ if (r.checked.some((c) => c.ok !== true))
338
+ process.exitCode = 3;
339
+ });
340
+ program
341
+ .command("destroy")
342
+ .description("the flows nobody wrote down — writes a manifest for each way this demo could be abused, and tries them")
343
+ .argument("<manifest>", "the demo to derive candidates from")
344
+ .option("-o, --out <dir>", "output root", "outputs")
345
+ .option("--only <shapes>", "just these, comma-separated (see --shapes)")
346
+ .option("--shapes", "list the ways it knows how to break something, and stop", false)
347
+ .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)
348
+ .option("--no-try", "write the candidates and stop")
349
+ .action(async (file, opts) => {
350
+ if (opts.shapes) {
351
+ for (const s of SHAPES) {
352
+ say(`${s.name}`);
353
+ say(` ${s.asks}`);
354
+ say(` ${s.because}`);
355
+ say("");
356
+ }
357
+ return;
358
+ }
359
+ const loaded = loadManifest(file);
360
+ const plan = planDestroy(loaded.manifest, path.resolve(opts.out), { only: opts.only?.split(",").map((x) => x.trim()).filter(Boolean) });
361
+ for (const l of describePlan(plan, loaded.manifest))
362
+ say(l);
363
+ if (plan.refused) {
364
+ process.exitCode = 2;
365
+ return;
366
+ }
367
+ if (!opts.try || !plan.candidates.length)
368
+ return;
369
+ // Generating is safe — it makes files. Running clicks things, and that is
370
+ // where the two refusals bite.
371
+ const no = refuseToRun(loaded.manifest, process.env, path.resolve(opts.out));
372
+ if (no) {
373
+ say("");
374
+ say(`✗ not running these: ${no}`);
375
+ process.exitCode = 2;
376
+ return;
377
+ }
378
+ say("");
379
+ const trials = await tryCandidates(plan, { mode: opts.run ? "run" : "dry", manifestDir: loaded.dir, outRoot: path.resolve(opts.out), log: say });
380
+ for (const l of describeTrials(trials, opts.run ? "run" : "dry"))
381
+ say(l);
382
+ if (trials.some((t) => t.verdict === "broke"))
383
+ process.exitCode = 3;
384
+ });
385
+ program
386
+ .command("intent")
387
+ .description("what this product IS — the context every visual check is missing without it")
388
+ .argument("[text]", "the note to save; omit to read what is there")
389
+ .option("--demo <name>", "a note for one demo's app, rather than the whole workspace")
390
+ .option("--starter", "write a starter file to fill in", false)
391
+ .action((text, opts) => {
392
+ const demos = path.resolve("demos");
393
+ if (opts.starter || (!text && !readIntent(demos, opts.demo))) {
394
+ if (readIntent(demos, opts.demo) && !text) {
395
+ say(readIntent(demos, opts.demo));
396
+ return;
397
+ }
398
+ say(`wrote ${path.relative(process.cwd(), writeIntent(demos, TEMPLATE, opts.demo))} — fill it in.`);
399
+ say("Without it, every check judges your app as a generic web page: anything deliberate about it can come back as a defect.");
400
+ return;
401
+ }
402
+ if (!text) {
403
+ say(readIntent(demos, opts.demo) ?? "");
404
+ return;
405
+ }
406
+ say(`saved to ${path.relative(process.cwd(), writeIntent(demos, text, opts.demo))}`);
407
+ });
408
+ program
409
+ .command("sweep")
410
+ .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")
411
+ .argument("<dir>", "an outputs/<name> dir")
412
+ .argument("[manifest]", "manifest (default demos/<name>.yaml)")
413
+ .option("--all", "the middle of each scene too, not just its last moment", false)
414
+ .option("--concurrency <n>", "how many frames to look at at once", "4")
415
+ .option("--fresh", "ask about every frame again, even the ones that have not changed", false)
416
+ .action(async (dir, manifestFile, opts) => {
417
+ const outDir = path.resolve(dir);
418
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
419
+ if (!fs.existsSync(file))
420
+ throw new Error(`no ${file} — pass the manifest path`);
421
+ // No exit code, deliberately. Some of these are judgement; `verify` gates.
422
+ await sweep(loadManifest(file).manifest, outDir, say, { all: opts.all, concurrency: Number(opts.concurrency) || 4, fresh: opts.fresh });
423
+ });
424
+ program
425
+ .command("sense")
426
+ .description("does the run ADD UP — what went in against what came out, across the whole demo")
427
+ .argument("<dir>", "an outputs/<name> dir")
428
+ .argument("[manifest]", "manifest (default demos/<name>.yaml)")
429
+ .action((dir, manifestFile) => {
430
+ const outDir = path.resolve(dir);
431
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
432
+ if (!fs.existsSync(file))
433
+ throw new Error(`no ${file} — pass the manifest path`);
434
+ // Deliberately no exit code. sense asks questions; `verify` is the gate.
435
+ sense(loadManifest(file).manifest, outDir, say);
436
+ });
437
+ program
438
+ .command("heal")
439
+ .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")
440
+ .option("-o, --out <dir>", "output root", "outputs")
441
+ .option("--apply", "actually write them (without this it only says what it would do)", false)
442
+ .action((opts) => {
443
+ const orphans = findOrphans(path.resolve(opts.out), path.resolve("demos"));
444
+ const written = opts.apply ? healOrphans(orphans, path.resolve("demos")) : null;
445
+ for (const l of describeOrphans(orphans, written))
446
+ say(l);
447
+ });
448
+ program
449
+ .command("notes")
450
+ .description("what everyone has actually been doing — reads outputs/ back and says the few things worth saying")
451
+ .option("-o, --out <dir>", "output root", "outputs")
452
+ .option("--all", "every note, not just the five that matter", false)
453
+ .option("--days <n>", "how far back to look", "14")
454
+ .action((opts) => {
455
+ const { lines } = notes(path.resolve(opts.out), { all: opts.all, days: Number(opts.days) || 14 });
456
+ for (const l of lines)
457
+ say(l);
458
+ });
459
+ program
460
+ .command("verify")
461
+ .description("did it LOOK right — puts each scene's `expect` question to a vision model against that scene's still")
462
+ .argument("<dir>", "an outputs/<name> dir containing take.json and stills/")
463
+ .argument("[manifest]", "manifest to read the expectations from (default demos/<name>.yaml)")
464
+ .action(async (dir, manifestFile) => {
465
+ const outDir = path.resolve(dir);
466
+ const file = manifestFile ?? path.join("demos", `${path.basename(outDir)}.yaml`);
467
+ if (!fs.existsSync(file))
468
+ throw new Error(`no ${file} — pass the manifest path`);
469
+ const { manifest } = loadManifest(file);
470
+ const v = await verify(manifest, outDir, say, file);
471
+ // 3, the same code dry and check already use for "this did not pass".
472
+ if (!v.ok)
473
+ process.exitCode = 3;
474
+ });
250
475
  program
251
476
  .command("render")
252
477
  .argument("<dir>", "an outputs/<name> dir containing take.json")
@@ -255,6 +480,7 @@ program
255
480
  .option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
256
481
  .option("--gif", "also produce a GIF", false)
257
482
  .option("--force", "ignore the render cache", false)
483
+ .option("--no-master", "skip the archival CRF-14 master — halves render on a long take")
258
484
  .option("--out <dir>", "write the render into a NEW directory, leaving the original untouched")
259
485
  .action(async (dir, manifestFile, opts) => {
260
486
  const takePath = path.join(dir, "take.json");
@@ -296,7 +522,7 @@ program
296
522
  manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
297
523
  acquireLock(path.resolve(dir));
298
524
  try {
299
- const a = await render(manifest, take, path.resolve(dir), { log: say, force: opts.force, scene: opts.scene });
525
+ const a = await render(manifest, take, path.resolve(dir), { log: say, force: opts.force, scene: opts.scene, noMaster: opts.master === false });
300
526
  for (const f of [a.master, a.mp4, a.gif, a.thumbnail])
301
527
  if (f)
302
528
  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.
package/dist/dryrun.js CHANGED
@@ -10,7 +10,11 @@ import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { applyPageSetup, authState, dragPoints, gateOnApp, resolvePoint, runEvaluateSeed, runFileOrCommandSeed } from "./record.js";
12
12
  import { chromium } from "playwright";
13
+ import { waitForStep } from "./waiting.js";
13
14
  import { expandEnv, resolve } from "./manifest.js";
15
+ /** The longest a dry run will wait for anything. Long enough for a real app
16
+ to answer, short enough that checking a demo stays a seconds-long job. */
17
+ const DRY_WAIT_CAP = 15_000;
14
18
  async function elInfo(page, selector, timeout) {
15
19
  try {
16
20
  const h = await page.locator(selector).first().elementHandle({ timeout });
@@ -143,7 +147,11 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
143
147
  // failures that are really a race.
144
148
  await gateOnApp(page, m, shotDir);
145
149
  layoutWidth = await page.evaluate(() => document.documentElement.clientWidth).catch(() => null);
146
- for (const s of [...(m.auth?.setup ?? []), ...m.setup])
150
+ // Exactly what the recorder does: sign-in steps run only when there is no
151
+ // fresh session to reuse. Running them unconditionally made `dry` sign in
152
+ // again every time — slower than it needed to be, and a different sequence
153
+ // from the one it is supposed to be predicting.
154
+ for (const s of [...(auth.fresh ? [] : (m.auth?.setup ?? [])), ...m.setup])
147
155
  await run(s, true);
148
156
  for (const [i, s] of m.steps.entries())
149
157
  await run(s, false, i);
@@ -213,8 +221,12 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
213
221
  await page.goto(expandEnv(step.url), { waitUntil: "domcontentloaded", timeout: 60000 });
214
222
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
215
223
  break;
224
+ // The same wait the recorder performs, capped so a dry run stays
225
+ // cheap. It used to check only that the selector appeared, ignoring
226
+ // `minChars` and `stableMs` entirely — so a manifest waiting for a
227
+ // streamed result passed here and timed out on camera.
216
228
  case "waitFor":
217
- await page.waitForSelector(step.selector, { state: step.gone ? "hidden" : "visible", timeout: Math.min(step.timeout ?? 15000, 15000) });
229
+ await waitForStep(page, step, { cap: DRY_WAIT_CAP });
218
230
  break;
219
231
  // noWaitAfter: a click that submits a form starts a navigation, and
220
232
  // waiting for the element to settle afterwards reports a false failure
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What has actually been checked, and against which recording.
3
+ *
4
+ * "Did the agent verify this, or did it just say so?" had no answer. The
5
+ * checks printed their verdicts into a terminal somebody else was looking at,
6
+ * and nothing on disk remembered that they had run at all — so the window
7
+ * could not tell a demo that had been inspected from one nobody had looked at.
8
+ *
9
+ * Each check drops one line in here when it finishes. It is a record, not a
10
+ * cache: nothing reads it to skip work, and a stale entry is shown as stale
11
+ * rather than believed. Written best-effort — a check must never fail because
12
+ * it could not write its own receipt.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ const file = (outDir) => path.join(outDir, "checks.json");
17
+ export function readChecks(outDir) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(file(outDir), "utf8"));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ /** Record that a check ran. Never throws: a receipt is not worth a failure. */
26
+ export function noteCheck(outDir, name, r) {
27
+ try {
28
+ if (!fs.existsSync(outDir))
29
+ return;
30
+ const all = readChecks(outDir);
31
+ all[name] = { at: new Date().toISOString(), ...r };
32
+ fs.writeFileSync(file(outDir), JSON.stringify(all, null, 2) + "\n");
33
+ }
34
+ catch { /* the verdict was still printed */ }
35
+ }
36
+ /** Is this record about the recording that is there now? */
37
+ export const isCurrent = (r, takeFinishedAt) => !!r && !!takeFinishedAt && r.takeFinishedAt === takeFinishedAt;