retake-demos 0.4.0 → 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 +109 -0
- package/README.md +13 -1
- package/demos/example.yaml +43 -0
- package/dist/cli.js +232 -6
- package/dist/describe.js +1 -1
- package/dist/dryrun.js +14 -2
- package/dist/ext/checked.js +37 -0
- package/dist/ext/clip.js +97 -0
- package/dist/ext/destroy.js +417 -0
- package/dist/ext/evidence.js +38 -0
- package/dist/ext/flags.js +184 -0
- package/dist/ext/heal.js +124 -0
- package/dist/ext/intent.js +108 -0
- package/dist/ext/judge.js +124 -0
- package/dist/ext/memo.js +0 -0
- package/dist/ext/notes.js +228 -0
- package/dist/ext/sense.js +189 -0
- package/dist/ext/sweep.js +203 -0
- package/dist/ext/verify.js +181 -0
- package/dist/manifest.js +28 -1
- package/dist/operator/tools.js +294 -51
- package/dist/progress.js +96 -0
- package/dist/record.js +174 -36
- package/dist/render.js +88 -7
- package/dist/tidy.js +20 -6
- package/dist/ui/chat.html +359 -6
- package/dist/ui/guard.js +126 -0
- package/dist/ui/server.js +129 -20
- package/dist/waiting.js +78 -0
- package/package.json +2 -2
- package/site/releases.json +154 -0
- package/skill/SKILL.md +395 -10
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|