retake-demos 0.12.1 → 0.13.2
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 +17 -0
- package/dist/cli.js +67 -0
- package/dist/dryrun.js +15 -1
- package/dist/ext/calibrate.js +341 -0
- package/dist/ext/flags.js +8 -1
- package/dist/ext/intent.js +53 -5
- package/dist/ext/notes.js +15 -4
- package/dist/operator/tools.js +19 -1
- package/dist/ui/chat.html +75 -14
- package/dist/ui/guide.html +67 -21
- package/dist/ui/server.js +28 -7
- package/package.json +1 -1
- package/site/releases.json +77 -0
- package/skill/SKILL.md +28 -5
package/AGENTS.md
CHANGED
|
@@ -226,6 +226,23 @@ Most real demos start behind a sign-in. The agent never sees a password:
|
|
|
226
226
|
and cuts the few seconds of video that show each one, so a long demo does not
|
|
227
227
|
have to be re-watched to learn whether one thing changed. Exit 3 while
|
|
228
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, and
|
|
239
|
+
all four belong in anything you say about the numbers: the seeds were written
|
|
240
|
+
by somebody who has read the checklist, so recall is an upper bound; injected
|
|
241
|
+
faults are cleaner than real ones; the run names the checklist items nothing
|
|
242
|
+
seeds, and says nothing whatever about those; and a run whose control did not
|
|
243
|
+
record cleanly measured nothing at all and exits 3 rather than printing a
|
|
244
|
+
reassuring zero. From MCP you can only READ the last measurement — making one
|
|
245
|
+
is a terminal command, because it records for ten minutes.
|
|
229
246
|
- `retake intent` — what the product IS, in the builder's words: what it does,
|
|
230
247
|
who uses it, and what about it is deliberate. Every visual check reads it.
|
|
231
248
|
Without it they judge the app as a generic web page — measured on a real
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
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
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
|
|
14
15
|
* retake intent what this product IS — context every visual check needs
|
|
15
16
|
* retake sweep outputs/x look at every frame as a whole — what nobody thought to ask about
|
|
16
17
|
* retake sense outputs/x does the run ADD UP — what went in vs what came out
|
|
@@ -44,6 +45,7 @@ import { notes } from "./ext/notes.js";
|
|
|
44
45
|
import { sense } from "./ext/sense.js";
|
|
45
46
|
import { sweep } from "./ext/sweep.js";
|
|
46
47
|
import { TEMPLATE, readIntent, writeIntent } from "./ext/intent.js";
|
|
48
|
+
import { planCalibration, report, usable } from "./ext/calibrate.js";
|
|
47
49
|
import { checkFlags, flag, unflag } from "./ext/flags.js";
|
|
48
50
|
import { describeOrphans, findOrphans, healOrphans } from "./ext/heal.js";
|
|
49
51
|
import { SHAPES, describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "./ext/destroy.js";
|
|
@@ -382,6 +384,71 @@ program
|
|
|
382
384
|
if (trials.some((t) => t.verdict === "broke"))
|
|
383
385
|
process.exitCode = 3;
|
|
384
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
|
+
/** Sweep once, and give back what it said. */
|
|
403
|
+
const look = async (m, dir, fresh) => (await sweep(m, dir, () => { }, { demosDir: path.resolve("demos"), fresh })).frames.flatMap((f) => f.issues.map((i) => i.kind));
|
|
404
|
+
let stability = null;
|
|
405
|
+
for (const v of plan.variants) {
|
|
406
|
+
const dir = path.join(plan.root, v.name);
|
|
407
|
+
say(`\n${v.name}${v.defect ? ` — ${v.defect.looks}` : ""}`);
|
|
408
|
+
try {
|
|
409
|
+
const take = await record(v.manifest, { outDir: dir, manifestDir: loaded.dir, headed: false, log: () => { } });
|
|
410
|
+
await render(v.manifest, take, dir, { log: () => { }, noMaster: true });
|
|
411
|
+
// A recording that fell over, recorded nothing, or produced no frames
|
|
412
|
+
// measures nothing — whichever end of it broke. Asking sweep about it
|
|
413
|
+
// and reporting the empty answer is how a run against an app that was
|
|
414
|
+
// not even running came back reading "none on the control".
|
|
415
|
+
const fit = usable(dir, take);
|
|
416
|
+
if (!fit.ok) {
|
|
417
|
+
results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: false, why: fit.why, other: 0 });
|
|
418
|
+
say(` → not measured: ${fit.why}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const kinds = await look(v.manifest, dir, false);
|
|
422
|
+
const found = !v.defect ? false : kinds.some((k) => k.toUpperCase().startsWith(v.defect.expect.split(" ")[0]));
|
|
423
|
+
results.push({ variant: v.name, defect: v.defect, kinds, found, seeded: true, other: v.defect ? kinds.filter((k) => !k.toUpperCase().startsWith(v.defect.expect.split(" ")[0])).length : 0 });
|
|
424
|
+
say(` → ${kinds.join(", ") || "nothing"}`);
|
|
425
|
+
// The control, asked the same question about the same frames a second
|
|
426
|
+
// time. Judges are not deterministic, and a harness that never checks
|
|
427
|
+
// this cannot tell a real finding from a coin landing heads.
|
|
428
|
+
if (!v.defect) {
|
|
429
|
+
say(" asking the control again, to see whether the answer holds");
|
|
430
|
+
stability = { first: kinds, again: await look(v.manifest, dir, true) };
|
|
431
|
+
say(` → ${stability.again.join(", ") || "nothing"}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
const msg = String(e.message).split("\n")[0].slice(0, 90);
|
|
436
|
+
results.push({ variant: v.name, defect: v.defect, kinds: [], found: false, seeded: false, why: `the run threw — ${msg}`, other: 0, error: msg });
|
|
437
|
+
say(` → could not run: ${msg}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const l of report(results, stability))
|
|
441
|
+
say(l);
|
|
442
|
+
fs.writeFileSync(path.join(plan.root, "report.json"), JSON.stringify({ at: new Date().toISOString(), demo: loaded.manifest.name, results, stability }, null, 2) + "\n");
|
|
443
|
+
// The false-positive rate is the number people act on. If the control did
|
|
444
|
+
// not record, this run produced no number at all, and exiting 0 says the
|
|
445
|
+
// opposite.
|
|
446
|
+
const control = results.find((r) => !r.defect);
|
|
447
|
+
if (!control?.seeded) {
|
|
448
|
+
process.exitCode = 3;
|
|
449
|
+
say("\nnothing was measured — fix the run before quoting any of this.");
|
|
450
|
+
}
|
|
451
|
+
});
|
|
385
452
|
program
|
|
386
453
|
.command("intent")
|
|
387
454
|
.description("what this product IS — the context every visual check is missing without it")
|
package/dist/dryrun.js
CHANGED
|
@@ -260,9 +260,23 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
260
260
|
}
|
|
261
261
|
break;
|
|
262
262
|
}
|
|
263
|
+
// `to: top` and `to: bottom` are the PAGE's ends, not selectors — the
|
|
264
|
+
// recorder reads scrollY and scrollHeight for them. Dry was looking for
|
|
265
|
+
// an element called "bottom", timing out, and failing a step that
|
|
266
|
+
// records perfectly. The starter demo shipped with `retake init` hits
|
|
267
|
+
// it, so a new person's first dry run failed on a demo that works.
|
|
263
268
|
case "scroll":
|
|
264
|
-
|
|
269
|
+
// And actually scroll. Skipping it left the page at the top while
|
|
270
|
+
// every later step ran, so a dry run rehearsed a different page from
|
|
271
|
+
// the one the recording would see.
|
|
272
|
+
if (step.to === "top")
|
|
273
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
274
|
+
else if (step.to === "bottom")
|
|
275
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
276
|
+
else if (step.to) {
|
|
265
277
|
await page.locator(step.to).first().boundingBox({ timeout: short });
|
|
278
|
+
await page.locator(step.to).first().scrollIntoViewIfNeeded({ timeout: short });
|
|
279
|
+
}
|
|
266
280
|
break;
|
|
267
281
|
case "upload":
|
|
268
282
|
await page.locator(step.selector).first().waitFor({ timeout: short });
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much should anybody trust what `sweep` says?
|
|
3
|
+
*
|
|
4
|
+
* Every other check in Retake is tested: the seam, the guard, the parity, the
|
|
5
|
+
* refusals. The one thing nothing measures is whether the model-based checks
|
|
6
|
+
* are RIGHT — and that is the part the product is now sold on. Today `sweep`
|
|
7
|
+
* found three real bugs, produced at least one clear false positive, and
|
|
8
|
+
* missed a genuine overlap three passes running. Three anecdotes are not a
|
|
9
|
+
* number.
|
|
10
|
+
*
|
|
11
|
+
* Retake can measure this with what it already has. `evaluate` injects
|
|
12
|
+
* anything into the page before the camera rolls, so a defect can be seeded
|
|
13
|
+
* deterministically: one variant of a working demo per known fault, recorded
|
|
14
|
+
* for real, plus a clean control. Then:
|
|
15
|
+
*
|
|
16
|
+
* recall how many seeded defects were found
|
|
17
|
+
* false positives what was reported on the control, where nothing is wrong
|
|
18
|
+
* stability whether the same frame gives the same answer twice
|
|
19
|
+
*
|
|
20
|
+
* WHERE THIS WORKS, AND WHERE IT DOES NOT.
|
|
21
|
+
*
|
|
22
|
+
* On a page that is served and then left alone — a document, a landing page,
|
|
23
|
+
* Retake's own guide — injection is reliable and the numbers mean something:
|
|
24
|
+
* eight of eight seeded faults found, nothing reported on the control.
|
|
25
|
+
*
|
|
26
|
+
* On a live single-page app it is not. Against Capture, seeds provably present
|
|
27
|
+
* in the DOM after hydration were provably absent from every recorded frame,
|
|
28
|
+
* four different ways of injecting them, for reasons not isolated. A harness
|
|
29
|
+
* built on injection therefore measures the injection as much as the check,
|
|
30
|
+
* and a low recall on such an app is not evidence about the check at all.
|
|
31
|
+
*
|
|
32
|
+
* So: use this to calibrate against static pages, and take the control result
|
|
33
|
+
* — findings where nothing is wrong — from anywhere, because that half needs
|
|
34
|
+
* no injection and is the half that matters most for trust. To calibrate a
|
|
35
|
+
* real app, label real recordings and have a person adjudicate them. That is
|
|
36
|
+
* slower, and it is the only thing that answers the question honestly.
|
|
37
|
+
*
|
|
38
|
+
* Two honest limits, stated rather than buried. The person writing the seeds
|
|
39
|
+
* is not blind to the checklist, so recall here is an upper bound — a defect
|
|
40
|
+
* nobody thought to seed is still not measured. And CSS-injected faults are
|
|
41
|
+
* cleaner than the ones real apps produce; a laboratory number, not a field
|
|
42
|
+
* one. Both are worth having, neither is the whole answer.
|
|
43
|
+
*/
|
|
44
|
+
import fs from "node:fs";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import YAML from "yaml";
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
import { LOOK_FOR } from "./sweep.js";
|
|
49
|
+
/**
|
|
50
|
+
* Deliberately mechanical. Every one of these is visible to a person in one
|
|
51
|
+
* glance, which is the bar: if a human cannot see it in the frame, a miss is
|
|
52
|
+
* not evidence of anything.
|
|
53
|
+
*
|
|
54
|
+
* And every one targets ORDINARY TEXT — p, li, headings — rather than a
|
|
55
|
+
* specific element. Three seeds failed here before that rule existed: they
|
|
56
|
+
* changed parts of the page the demo's frames never showed, so the run
|
|
57
|
+
* reported a miss when nothing had been asked of the check. A calibration
|
|
58
|
+
* harness that can silently measure nothing is worse than none, because its
|
|
59
|
+
* number looks the same either way.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFECTS = [
|
|
62
|
+
{
|
|
63
|
+
name: "clipped-text",
|
|
64
|
+
proof: "[...document.querySelectorAll('p,li,h1,h2,h3')].some(e => e.scrollHeight > e.clientHeight + 4)",
|
|
65
|
+
expect: "CLIPPED",
|
|
66
|
+
looks: "headings cut off mid-letter by a box too short for them",
|
|
67
|
+
css: "h1,h2,h3,li,p{max-height:14px!important;overflow:hidden!important;display:block!important}",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "overlapping-text",
|
|
71
|
+
proof: "[...document.querySelectorAll('p,li')].some(e => parseFloat(getComputedStyle(e).marginBottom) < -10)",
|
|
72
|
+
expect: "OVERLAP",
|
|
73
|
+
looks: "paragraphs sitting on top of each other",
|
|
74
|
+
css: "p,li{margin-bottom:-26px!important;position:relative!important;z-index:2!important}",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "unreadable-contrast",
|
|
78
|
+
// Was `.replace(/\\s/g,'')`, which after string escaping built a regex for
|
|
79
|
+
// a literal backslash followed by "s" — so the spaces in "rgb(233, 235,
|
|
80
|
+
// 228)" were never stripped, the proof was never true, and this seed
|
|
81
|
+
// reported "did not land" on every app it was ever run against. It was
|
|
82
|
+
// counted as one of eight for two releases. No regex now.
|
|
83
|
+
proof: "getComputedStyle(document.body).color.split(' ').join('') === 'rgb(233,235,228)'",
|
|
84
|
+
expect: "CONTRAST",
|
|
85
|
+
looks: "body text almost the same colour as the page behind it",
|
|
86
|
+
css: "body,p,li,span{color:#e9ebe4!important}",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "runs-off-the-edge",
|
|
90
|
+
proof: 'document.documentElement.scrollWidth > document.documentElement.clientWidth + 40',
|
|
91
|
+
expect: "CUT OFF",
|
|
92
|
+
looks: "content pushed off the right-hand side of the frame",
|
|
93
|
+
css: "main,body>*{margin-left:62%!important;white-space:nowrap!important}",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "doubled-heading",
|
|
97
|
+
proof: "!!document.querySelector('[data-retake-dup]')",
|
|
98
|
+
expect: "DOUBLED",
|
|
99
|
+
looks: "every heading rendered twice, one under the other",
|
|
100
|
+
// The first version targeted h2 and used attr(data-x), which is empty —
|
|
101
|
+
// so nothing appeared and the run looked like a MISS. A seed that fails
|
|
102
|
+
// silently is indistinguishable from a check that failed, which is why
|
|
103
|
+
// `landed` below exists.
|
|
104
|
+
// Twice wrong before this: CSS pseudo-content on headings, which produced
|
|
105
|
+
// nothing the first time and nothing IN FRAME the second. A seed that is
|
|
106
|
+
// not plainly visible in the frames being judged measures nothing, and the
|
|
107
|
+
// temptation to keep tuning it until the number improves is exactly how a
|
|
108
|
+
// calibration harness starts lying. So it clones the real node instead.
|
|
109
|
+
css: "",
|
|
110
|
+
js: "for (const h of [...document.querySelectorAll('h1,h2,h3,p,li')]) { if (h.textContent && h.textContent.trim().length > 8) { const c = h.cloneNode(true); c.setAttribute('data-retake-dup',''); h.after(c); } }",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "stuck-spinner",
|
|
114
|
+
proof: "!!document.getElementById('retake-seed-spinner')",
|
|
115
|
+
expect: "UNFINISHED",
|
|
116
|
+
looks: "a loading spinner sitting on the page that never resolves",
|
|
117
|
+
css: "",
|
|
118
|
+
js: "const d=document.createElement('div');d.id='retake-seed-spinner';d.textContent='Loading…';d.style.cssText='position:fixed;top:14px;left:50%;transform:translateX(-50%);background:#fff;border:1px solid #ccc;border-radius:999px;padding:6px 16px;font:14px sans-serif;z-index:2147483647';document.body.appendChild(d);",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "broken-image",
|
|
122
|
+
proof: "!!document.getElementById('retake-seed-img')",
|
|
123
|
+
expect: "BROKEN MEDIA",
|
|
124
|
+
looks: "an image that failed to load, showing its alt text instead",
|
|
125
|
+
css: "",
|
|
126
|
+
js: "const i=document.createElement('img');i.id='retake-seed-img';i.src='/definitely-not-here-'+'x'.repeat(4)+'.png';i.alt='product photograph';i.style.cssText='position:fixed;top:90px;left:24px;width:150px;height:100px;border:1px solid #ccc;background:#fff;z-index:2147483647';document.body.appendChild(i);",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "debug-badge",
|
|
130
|
+
proof: "!!document.getElementById('retake-seed-badge')",
|
|
131
|
+
expect: "NOT FOR THE CAMERA",
|
|
132
|
+
looks: "a development badge left in the corner of the screen",
|
|
133
|
+
css: "",
|
|
134
|
+
js: "const b=document.createElement('div');b.id='retake-seed-badge';b.textContent='DEV BUILD · staging';b.style.cssText='position:fixed;bottom:12px;left:12px;background:#111;color:#0f0;font:12px monospace;padding:5px 10px;border-radius:5px;z-index:2147483647';document.body.appendChild(b);",
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
/** Where calibration writes. Never near a real take. */
|
|
138
|
+
export const lab = (outRoot, demo) => path.join(outRoot, ".calibrate", demo);
|
|
139
|
+
/**
|
|
140
|
+
* A control and one variant per defect, as ordinary manifests.
|
|
141
|
+
*
|
|
142
|
+
* The control matters more than any single variant: without a run where
|
|
143
|
+
* nothing is wrong, a low false-positive rate cannot be claimed at all.
|
|
144
|
+
*/
|
|
145
|
+
export function planCalibration(m, outRoot, opts = {}) {
|
|
146
|
+
const root = lab(outRoot, m.name);
|
|
147
|
+
fs.mkdirSync(root, { recursive: true });
|
|
148
|
+
// `--only typo` used to match nothing and run a control alone, then print a
|
|
149
|
+
// recall line reading "0 of 0" — which is not a failure, it is a measurement
|
|
150
|
+
// of nothing wearing the clothes of one.
|
|
151
|
+
const unknown = (opts.only ?? []).filter((n) => !DEFECTS.some((d) => d.name === n));
|
|
152
|
+
if (unknown.length)
|
|
153
|
+
throw new Error(`no such defect: ${unknown.join(", ")} — try ${DEFECTS.map((d) => d.name).join(", ")}`);
|
|
154
|
+
const want = opts.only?.length ? DEFECTS.filter((d) => opts.only.includes(d.name)) : DEFECTS;
|
|
155
|
+
const variants = [];
|
|
156
|
+
const write = (name, defect) => {
|
|
157
|
+
const clone = JSON.parse(JSON.stringify(m));
|
|
158
|
+
clone.name = `${m.name}-${name}`.slice(0, 60);
|
|
159
|
+
if (defect) {
|
|
160
|
+
// Before anything is recorded, so every frame carries the fault.
|
|
161
|
+
clone.setup = [
|
|
162
|
+
...(clone.setup ?? []),
|
|
163
|
+
// A stylesheet in <head> survives re-render; the elements below may not.
|
|
164
|
+
...(defect.css ? [{ action: "evaluate", script: `{const s=document.createElement('style');s.id='retake-seed-css';s.textContent=${JSON.stringify(defect.css)};document.head.appendChild(s);setInterval(()=>{if(!document.getElementById('retake-seed-css'))document.head.appendChild(s);},250);}` }] : []),
|
|
165
|
+
// Re-applied on a timer, because a React app hydrates and re-renders
|
|
166
|
+
// after the seed runs and takes anything appended to the body with it.
|
|
167
|
+
// Capture did exactly that: the element passed its own proof at seed
|
|
168
|
+
// time and was gone from every recorded frame, which looked identical
|
|
169
|
+
// to the check missing it.
|
|
170
|
+
...(defect.js ? [{ action: "evaluate", script: `{ const seed = () => { try { ${defect.js} } catch {} }; seed(); setInterval(() => { if (!(${defect.proof})) seed(); }, 250); }` }] : []),
|
|
171
|
+
// The seed proves itself, in the page, before anything is recorded.
|
|
172
|
+
// A seed that did not render must fail the take rather than produce
|
|
173
|
+
// frames that look exactly like a check missing something.
|
|
174
|
+
{ action: "wait", ms: 1200 },
|
|
175
|
+
// Checked after the app has had time to hydrate and re-render, not at
|
|
176
|
+
// the instant of injection — that was the difference between a seed
|
|
177
|
+
// that exists and a seed that is in the picture.
|
|
178
|
+
{ action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed did not take on this app"); }` },
|
|
179
|
+
{ action: "wait", ms: 300 },
|
|
180
|
+
];
|
|
181
|
+
// And again at the END. The setup proof says the seed was on the page
|
|
182
|
+
// before the camera rolled; it says nothing about whether it was still
|
|
183
|
+
// there for the frames that were actually judged. A single-page app that
|
|
184
|
+
// navigates or re-renders can drop it halfway through, and every frame
|
|
185
|
+
// after that point asks the check to find something that is not there.
|
|
186
|
+
// Failing the step is right: a run that lost its seed is not a miss, it
|
|
187
|
+
// is not a measurement.
|
|
188
|
+
clone.steps = [
|
|
189
|
+
...clone.steps,
|
|
190
|
+
{ action: "evaluate", script: `{ if (!(${defect.proof})) throw new Error("the ${defect.name} seed was gone by the end of the run"); }` },
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
const file = path.join(root, `${name}.yaml`);
|
|
194
|
+
fs.writeFileSync(file, [
|
|
195
|
+
defect ? `# ${defect.name} — ${defect.looks}.` : "# control — nothing is wrong with this one.",
|
|
196
|
+
`# Generated by \`retake calibrate ${m.name}\`. Recording it seeds the fault for real.`,
|
|
197
|
+
"",
|
|
198
|
+
YAML.stringify(clone),
|
|
199
|
+
].join("\n"));
|
|
200
|
+
return { name, defect, file, manifest: clone };
|
|
201
|
+
};
|
|
202
|
+
variants.push(write("control", null));
|
|
203
|
+
for (const d of want)
|
|
204
|
+
variants.push(write(d.name, d));
|
|
205
|
+
return { root, variants };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Did the seed actually take?
|
|
209
|
+
*
|
|
210
|
+
* A variant whose frames are byte-identical to the control's is a variant
|
|
211
|
+
* where nothing was injected — the CSS missed, the selector was wrong, the
|
|
212
|
+
* page changed. Counting that as a MISS blames the check for the harness's
|
|
213
|
+
* mistake, and it happened on the very first run: a doubled-heading seed that
|
|
214
|
+
* targeted the wrong element reported 7 of 8 when the honest answer was 7 of
|
|
215
|
+
* 7 and one seed that never landed.
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* @deprecated Seeds prove themselves in the page now — see `Defect.proof`.
|
|
219
|
+
*
|
|
220
|
+
* Kept only as a record of why: this compares a variant's frames to the
|
|
221
|
+
* control's, which cannot work on an app whose content changes between runs.
|
|
222
|
+
* Capture's board carries timestamps and counts, so every variant differed
|
|
223
|
+
* from its control whether or not anything had been injected, and this
|
|
224
|
+
* reported eight seeds as landed when at least one plainly had not.
|
|
225
|
+
*/
|
|
226
|
+
export function landed(controlDir, variantDir) {
|
|
227
|
+
// NOTE what this does and does not prove: that the frames differ from the
|
|
228
|
+
// control's, so SOMETHING was injected. It cannot prove the fault is
|
|
229
|
+
// plainly visible in them. A seed that changes the page off-screen passes
|
|
230
|
+
// here and still measures nothing — which happened, twice, before the
|
|
231
|
+
// doubled-heading seed was rewritten to clone a real node.
|
|
232
|
+
const shots = (d) => {
|
|
233
|
+
try {
|
|
234
|
+
return fs.readdirSync(path.join(d, "stills")).sort()
|
|
235
|
+
.map((f) => createHash("sha1").update(fs.readFileSync(path.join(d, "stills", f))).digest("hex"));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const a = shots(controlDir), b = shots(variantDir);
|
|
242
|
+
if (!a.length || !b.length)
|
|
243
|
+
return true; // cannot tell; do not accuse the harness
|
|
244
|
+
return a.join() !== b.join();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Is this recording fit to measure anything at all?
|
|
248
|
+
*
|
|
249
|
+
* It was not asked. A control recorded against an app that was not running
|
|
250
|
+
* came back with an empty timeline, `ok: false` and no frames — and the report
|
|
251
|
+
* said "FALSE POSITIVES none on the control", which is the single number this
|
|
252
|
+
* harness exists to produce. Nothing was wrong with the control because
|
|
253
|
+
* nothing had happened in it. A blank measurement that reads as a clean one is
|
|
254
|
+
* worse than no measurement, and it exited 0.
|
|
255
|
+
*/
|
|
256
|
+
export function usable(dir, take) {
|
|
257
|
+
if (!take)
|
|
258
|
+
return { ok: false, why: "nothing was recorded" };
|
|
259
|
+
if (!take.timeline?.length)
|
|
260
|
+
return { ok: false, why: "the run recorded no steps at all" };
|
|
261
|
+
if (take.ok === false)
|
|
262
|
+
return { ok: false, why: "the run did not finish cleanly" };
|
|
263
|
+
let stills = 0;
|
|
264
|
+
try {
|
|
265
|
+
stills = fs.readdirSync(path.join(dir, "stills")).filter((f) => /\.(png|jpe?g)$/i.test(f)).length;
|
|
266
|
+
}
|
|
267
|
+
catch { /* none */ }
|
|
268
|
+
if (!stills)
|
|
269
|
+
return { ok: false, why: "the run produced no frames to look at" };
|
|
270
|
+
return { ok: true, why: "" };
|
|
271
|
+
}
|
|
272
|
+
/** The `sweep` items nothing here seeds, named rather than left to arithmetic. */
|
|
273
|
+
export function uncovered() {
|
|
274
|
+
const seeded = new Set(DEFECTS.map((d) => d.expect.toUpperCase()));
|
|
275
|
+
return LOOK_FOR.map((l) => l.split(" — ")[0].trim())
|
|
276
|
+
.filter((kind) => !seeded.has(kind.toUpperCase()));
|
|
277
|
+
}
|
|
278
|
+
/** Recall, false positives, and the sentence that says what they mean. */
|
|
279
|
+
export function report(rs, stability = null) {
|
|
280
|
+
const all = rs.filter((r) => r.defect);
|
|
281
|
+
const seeded = all.filter((r) => r.seeded);
|
|
282
|
+
const unseeded = all.filter((r) => !r.seeded);
|
|
283
|
+
const control = rs.find((r) => !r.defect);
|
|
284
|
+
const found = seeded.filter((r) => r.found).length;
|
|
285
|
+
const out = [];
|
|
286
|
+
out.push("");
|
|
287
|
+
out.push(`RECALL ${found} of ${seeded.length} defects that actually appeared were found`);
|
|
288
|
+
for (const r of seeded) {
|
|
289
|
+
out.push(` ${r.found ? "\u2713" : "\u2717"} ${r.variant.padEnd(20)} wanted ${String(r.defect.expect).padEnd(19)} got ${r.kinds.join(", ") || (r.error ? `\u2014 ${r.error}` : "nothing")}`);
|
|
290
|
+
}
|
|
291
|
+
if (unseeded.length) {
|
|
292
|
+
out.push("");
|
|
293
|
+
out.push(`NOT MEASURED ${unseeded.length} of ${all.length} seeded runs asked the check nothing:`);
|
|
294
|
+
for (const r of unseeded)
|
|
295
|
+
out.push(` \u2014 ${r.variant.padEnd(20)} ${r.why ?? r.error ?? "the injection did not land"}. Fix the harness, not the checklist.`);
|
|
296
|
+
}
|
|
297
|
+
out.push("");
|
|
298
|
+
// The control is the half that needs no injection, so it is the half that
|
|
299
|
+
// travels — and the only one that can speak to false positives. If it did
|
|
300
|
+
// not record, nothing here may be claimed, in either direction.
|
|
301
|
+
if (!control) {
|
|
302
|
+
out.push("FALSE POSITIVES not measured \u2014 no control was run, so no rate can be claimed");
|
|
303
|
+
}
|
|
304
|
+
else if (!control.seeded) {
|
|
305
|
+
out.push(`FALSE POSITIVES NOT MEASURED \u2014 on the control, ${control.why ?? control.error ?? "the run did not record"}. No rate can be claimed from this run, and neither can the recall above: every variant recorded under the same conditions.`);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
out.push(control.kinds.length
|
|
309
|
+
? `FALSE POSITIVES ${control.kinds.length} finding(s) on the control, where nothing is wrong: ${control.kinds.join(", ")}`
|
|
310
|
+
: "FALSE POSITIVES none on the control");
|
|
311
|
+
}
|
|
312
|
+
const noise = seeded.reduce((n, r) => n + r.other, 0);
|
|
313
|
+
if (noise)
|
|
314
|
+
out.push(` and ${noise} extra finding(s) across the variants, beyond the fault seeded in each`);
|
|
315
|
+
out.push("");
|
|
316
|
+
// Promised in this file's own header for two releases and never produced.
|
|
317
|
+
// A number nobody measures is a claim, and this harness exists because
|
|
318
|
+
// claims about the checks were not good enough.
|
|
319
|
+
if (!stability) {
|
|
320
|
+
out.push("STABILITY not measured this run \u2014 the control was asked once, so nothing here says whether the same frames give the same answer twice.");
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const a = [...stability.first].sort().join(", ") || "nothing";
|
|
324
|
+
const b = [...stability.again].sort().join(", ") || "nothing";
|
|
325
|
+
out.push(a === b
|
|
326
|
+
? `STABILITY the control gave the same answer twice: ${a}`
|
|
327
|
+
: `STABILITY the control gave DIFFERENT answers to the same frames \u2014 first ${a}, then ${b}. Treat single sweep results on this app as one opinion, not a reading.`);
|
|
328
|
+
}
|
|
329
|
+
out.push("");
|
|
330
|
+
const gaps = uncovered();
|
|
331
|
+
if (gaps.length) {
|
|
332
|
+
// The count is of what this run attempted, not of the catalogue — `--only`
|
|
333
|
+
// makes those different numbers, and printing the catalogue's would
|
|
334
|
+
// overstate a narrow run.
|
|
335
|
+
const tried = all.length || DEFECTS.length;
|
|
336
|
+
out.push(`NOT COVERED ${tried} seed${tried === 1 ? "" : "s"} against a ${LOOK_FOR.length}-item checklist. Nothing here measures ${gaps.join(" or ")} at all, so the recall above says nothing about ${gaps.length === 1 ? "it" : "them"}.`);
|
|
337
|
+
out.push("");
|
|
338
|
+
}
|
|
339
|
+
out.push("What this is and is not: the seeds were written by someone who has read the checklist, so recall here is an upper bound \u2014 a fault nobody thought to seed is still unmeasured. Injected faults are also cleaner than the ones real apps produce. Treat it as a floor for trust, not a score.");
|
|
340
|
+
return out;
|
|
341
|
+
}
|
package/dist/ext/flags.js
CHANGED
|
@@ -113,6 +113,10 @@ function stillFor(outDir, take, label) {
|
|
|
113
113
|
* have to be re-watched.
|
|
114
114
|
*/
|
|
115
115
|
export async function checkFlags(manifestFile, _m, outDir, opts = {}) {
|
|
116
|
+
// Both derived from the manifest's own path, so `fixed` reads the note that
|
|
117
|
+
// belongs to this demo's app.
|
|
118
|
+
const demosDir = path.dirname(manifestFile);
|
|
119
|
+
const demoName = path.basename(manifestFile).replace(/\.(ya?ml|json)$/i, "");
|
|
116
120
|
const lines = [];
|
|
117
121
|
const say = (l) => { lines.push(l); opts.log?.(l); };
|
|
118
122
|
const flags = readFlags(manifestFile);
|
|
@@ -139,8 +143,11 @@ export async function checkFlags(manifestFile, _m, outDir, opts = {}) {
|
|
|
139
143
|
why = `no still for scene "${f.scene}" in the newest take — was that scene recorded?`;
|
|
140
144
|
else if (!provider)
|
|
141
145
|
why = noJudge ?? "nothing available to look at the frame";
|
|
146
|
+
// The same product context `verify` gets. Without it `fixed` judged the
|
|
147
|
+
// same frames by a generic standard while `verify` judged them knowing
|
|
148
|
+
// what the app is — two answers to one question, by design accident.
|
|
142
149
|
else
|
|
143
|
-
({ ok, why } = await judgeWith(provider, still, f.expect));
|
|
150
|
+
({ ok, why } = await judgeWith(provider, still, f.expect, demosDir, demoName));
|
|
144
151
|
let clip;
|
|
145
152
|
let clipError;
|
|
146
153
|
if (opts.clips !== false) {
|
package/dist/ext/intent.js
CHANGED
|
@@ -20,6 +20,30 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
/**
|
|
24
|
+
* A demo name that cannot leave the folder it is meant to be in.
|
|
25
|
+
*
|
|
26
|
+
* `intentPath` and `writeIntent` put this straight into `path.join`, so
|
|
27
|
+
* `--demo ../../escaped` wrote its file outside `demos/` entirely. Measured,
|
|
28
|
+
* not theorised, and in a released package. Kebab-case is the rule every other
|
|
29
|
+
* name in Retake already follows; the containment check afterwards is the belt
|
|
30
|
+
* for anything that rule ever stops catching.
|
|
31
|
+
*/
|
|
32
|
+
export function safeDemo(demo) {
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(demo)) {
|
|
34
|
+
throw new Error(`"${demo}" is not a demo name — lowercase letters, numbers and hyphens only`);
|
|
35
|
+
}
|
|
36
|
+
return demo;
|
|
37
|
+
}
|
|
38
|
+
/** Belt for the braces: whatever we built, it has to be inside `dir`. */
|
|
39
|
+
function within(dir, file) {
|
|
40
|
+
const root = path.resolve(dir);
|
|
41
|
+
const full = path.resolve(file);
|
|
42
|
+
if (full !== root && !full.startsWith(root + path.sep)) {
|
|
43
|
+
throw new Error(`refusing to write outside ${dir}`);
|
|
44
|
+
}
|
|
45
|
+
return full;
|
|
46
|
+
}
|
|
23
47
|
/**
|
|
24
48
|
* Where a demo's product note lives.
|
|
25
49
|
*
|
|
@@ -30,13 +54,15 @@ import path from "node:path";
|
|
|
30
54
|
*/
|
|
31
55
|
export function intentPath(demosDir, demo) {
|
|
32
56
|
if (demo) {
|
|
33
|
-
const own = path.join(demosDir, `${demo}.product.md`);
|
|
57
|
+
const own = within(demosDir, path.join(demosDir, `${safeDemo(demo)}.product.md`));
|
|
34
58
|
if (fs.existsSync(own))
|
|
35
59
|
return own;
|
|
36
60
|
}
|
|
37
|
-
return path.join(demosDir, "product.md");
|
|
61
|
+
return within(demosDir, path.join(demosDir, "product.md"));
|
|
38
62
|
}
|
|
39
63
|
export function readIntent(demosDir, demo) {
|
|
64
|
+
// A bad name is not an error when reading — it is simply no note. Only
|
|
65
|
+
// writing refuses, because writing is where the damage would be.
|
|
40
66
|
try {
|
|
41
67
|
const t = fs.readFileSync(intentPath(demosDir, demo), "utf8").trim();
|
|
42
68
|
return filled(t) ? t : null;
|
|
@@ -57,15 +83,37 @@ export function filled(text) {
|
|
|
57
83
|
return words.length > 0;
|
|
58
84
|
}
|
|
59
85
|
export function writeIntent(demosDir, text, demo) {
|
|
86
|
+
const f = within(demosDir, path.join(demosDir, demo ? `${safeDemo(demo)}.product.md` : "product.md"));
|
|
60
87
|
fs.mkdirSync(demosDir, { recursive: true });
|
|
61
|
-
const f = demo ? path.join(demosDir, `${demo}.product.md`) : path.join(demosDir, "product.md");
|
|
62
88
|
fs.writeFileSync(f, text.trim() + "\n");
|
|
63
89
|
return f;
|
|
64
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The product's name, from the first heading of its note.
|
|
93
|
+
*
|
|
94
|
+
* The window groups demos by URL, so a workspace of real products showed
|
|
95
|
+
* folders called "localhost:3200" and "localhost:4990" — accurate and
|
|
96
|
+
* meaningless. Naming the product once, for the checks, names its folder too.
|
|
97
|
+
* Returns null for the starter's own placeholder, so an unfilled note never
|
|
98
|
+
* becomes a folder called "What this product is".
|
|
99
|
+
*/
|
|
100
|
+
export function productName(demosDir, demo) {
|
|
101
|
+
const t = readIntent(demosDir, demo);
|
|
102
|
+
if (!t)
|
|
103
|
+
return null;
|
|
104
|
+
const h = /^#\s+(.+?)\s*$/m.exec(t);
|
|
105
|
+
const name = h?.[1]?.trim();
|
|
106
|
+
if (!name || /^what this product is$/i.test(name))
|
|
107
|
+
return null;
|
|
108
|
+
return name.slice(0, 32);
|
|
109
|
+
}
|
|
65
110
|
/** The starter, so nobody has to guess what belongs in it. */
|
|
66
|
-
export const TEMPLATE = `#
|
|
111
|
+
export const TEMPLATE = `# Name this product
|
|
67
112
|
|
|
68
|
-
<!--
|
|
113
|
+
<!-- Replace the heading above with what this product is CALLED — that name
|
|
114
|
+
becomes its folder in the Retake window, which otherwise says something
|
|
115
|
+
like "localhost:3200". Then, below: one or two sentences on what it does
|
|
116
|
+
and for whom. -->
|
|
69
117
|
|
|
70
118
|
## Who uses it
|
|
71
119
|
|
package/dist/ext/notes.js
CHANGED
|
@@ -56,10 +56,19 @@ function readRun(dir) {
|
|
|
56
56
|
return null; // an empty folder is not a run
|
|
57
57
|
return { name, dir, take, used, priors, when, checks: readChecks(dir) };
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* How many scenes carry an `expect:`, and how many do not.
|
|
61
|
+
*
|
|
62
|
+
* The note below used to fire on any demo with two bare scenes and say
|
|
63
|
+
* "nothing has ever checked how it looks". It said that about a demo carrying
|
|
64
|
+
* seventeen expectations that `verify` had just answered thirty-nine questions
|
|
65
|
+
* from. A watcher that says something plainly untrue about the workspace it is
|
|
66
|
+
* watching is worse than one that says nothing.
|
|
67
|
+
*/
|
|
68
|
+
function expectCounts(used) {
|
|
61
69
|
const steps = used?.steps ?? [];
|
|
62
|
-
|
|
70
|
+
const scenes = steps.filter((s) => s.action === "scene");
|
|
71
|
+
return { with: scenes.filter((s) => s.expect).length, without: scenes.filter((s) => !s.expect).length };
|
|
63
72
|
}
|
|
64
73
|
/** The selector a failed step was reaching for, when it named one. */
|
|
65
74
|
function failedSelector(summary, error) {
|
|
@@ -185,7 +194,9 @@ export function collect(root, sinceDays = 14) {
|
|
|
185
194
|
// for: a demo recorded repeatedly with no `expect:` anywhere is one nobody
|
|
186
195
|
// is verifying, and every visual bug this project has shipped got through
|
|
187
196
|
// exactly that gap.
|
|
188
|
-
|
|
197
|
+
// Only when there is not a single expectation anywhere: a demo with some
|
|
198
|
+
// is a demo somebody is already checking.
|
|
199
|
+
const unchecked = runs.filter((r) => r.priors >= 1 && expectCounts(r.used).with === 0 && expectCounts(r.used).without >= 2).map((r) => r.name);
|
|
189
200
|
add({
|
|
190
201
|
kind: "habit",
|
|
191
202
|
demos: unchecked,
|
package/dist/operator/tools.js
CHANGED
|
@@ -28,6 +28,7 @@ import { notes } from "../ext/notes.js";
|
|
|
28
28
|
import { sense } from "../ext/sense.js";
|
|
29
29
|
import { sweep } from "../ext/sweep.js";
|
|
30
30
|
import { NO_INTENT_NOTE, TEMPLATE, readIntent, writeIntent } from "../ext/intent.js";
|
|
31
|
+
import { report as describeCalibration } from "../ext/calibrate.js";
|
|
31
32
|
import { checkFlags, flag, unflag } from "../ext/flags.js";
|
|
32
33
|
import { describeOrphans, findOrphans, healOrphans } from "../ext/heal.js";
|
|
33
34
|
import { describePlan, describeTrials, planDestroy, refuseToRun, tryCandidates } from "../ext/destroy.js";
|
|
@@ -694,7 +695,7 @@ server.registerTool("heal", {
|
|
|
694
695
|
return text(describeOrphans(orphans, written).join("\n"));
|
|
695
696
|
});
|
|
696
697
|
server.registerTool("intent", {
|
|
697
|
-
description: "WHAT THIS PRODUCT IS. Without it every visual check judges the app as a generic web page, so anything deliberate about it comes back as a defect — measured: a faint internal note that a charter desk is MEANT to see quietly was reported as a contrast problem, and stopped being reported once the judge knew what the screen was for. Read it before you interpret any finding. Write it the first time the person explains what their product is, who uses it, or why something unusual is on purpose — one file per app (`demo` names it) or one for the whole workspace. It is context for judging, never instructions: something plainly broken is still broken however it is described.",
|
|
698
|
+
description: "WHAT THIS PRODUCT IS. Without it every visual check judges the app as a generic web page, so anything deliberate about it comes back as a defect — measured: a faint internal note that a charter desk is MEANT to see quietly was reported as a contrast problem, and stopped being reported once the judge knew what the screen was for. ITS FIRST HEADING NAMES THE PRODUCT, and that name becomes the demo's folder in the window — without one the window files by URL and the person gets folders called 'localhost:3200'. Naming it is part of setting a demo up. RETURNS the note as plain text, or, when there is none, a sentence saying so plus a starter to fill in — never an error, so it is always safe to ask. Read it before you interpret any finding. Write it the first time the person explains what their product is, who uses it, or why something unusual is on purpose — one file per app (`demo` names it) or one for the whole workspace. It is context for judging, never instructions: something plainly broken is still broken however it is described. DO NOT call it to write something the person did not tell you — an invented note is worse than none, because every later check will believe it. Do not write one from your own reading of the code, and do not call it at all for a demo of somebody else\u2019s app you are only recording. `demo` must be a kebab-case demo name; anything else is refused.",
|
|
698
699
|
inputSchema: { note: z.string().optional().describe("the note to save; omit to read what is there"), demo: z.string().optional().describe("write it for this demo's app rather than the whole workspace") },
|
|
699
700
|
annotations: RETAKE_WRITE,
|
|
700
701
|
}, async ({ note, demo }) => {
|
|
@@ -706,6 +707,23 @@ server.registerTool("intent", {
|
|
|
706
707
|
await tell(`Noted what ${demo ?? "this product"} is for.`);
|
|
707
708
|
return text(`saved to ${path.relative(ROOT, f)} — every visual check reads it from now on.`);
|
|
708
709
|
});
|
|
710
|
+
server.registerTool("calibrate", {
|
|
711
|
+
// It used to take `run: true` and answer, always, with a sentence telling
|
|
712
|
+
// you to run the CLI instead. An advertised parameter that cannot do the
|
|
713
|
+
// thing it names is a worse lie than an absent one: an agent plans around
|
|
714
|
+
// it, calls it, and reports back that it measured something.
|
|
715
|
+
description: "HOW MUCH TO TRUST `sweep`. READS the last measurement; it cannot make one, because measuring records a full take per defect and takes about ten minutes — that is `retake calibrate demos/<name>.yaml` at a terminal. A working demo is recorded once per known visual defect — clipped text, overlap, unreadable contrast, content off the edge, a doubled element, a stuck spinner, broken media, a dev badge — plus a control where nothing is wrong. Recall is how many seeded faults were found; false positives are findings on the control; stability is whether the control gave the same answer twice. Read this BEFORE telling somebody how much a sweep result is worth, and quote the numbers rather than your impression. Its limits are printed with it and should be repeated: the seeds were written by somebody who has read the checklist, so recall is an upper bound; injected faults are cleaner than real ones; and the run names outright which checklist items nothing seeds.",
|
|
716
|
+
inputSchema: { name: z.string().describe("the demo whose calibration to read") },
|
|
717
|
+
annotations: READ_ONLY,
|
|
718
|
+
}, async ({ name }) => {
|
|
719
|
+
if (!safe(name))
|
|
720
|
+
return text(`no demo "${name}"`);
|
|
721
|
+
const file = path.join(OUT, ".calibrate", name, "report.json");
|
|
722
|
+
if (!fs.existsSync(file))
|
|
723
|
+
return text(`${name} has never been calibrated, so there is no measured answer to how much its sweep results are worth. \`retake calibrate demos/${name}.yaml\` produces one — at a terminal, not from here.`);
|
|
724
|
+
const r = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
725
|
+
return text([...describeCalibration(r.results, r.stability ?? null), "", `measured ${r.at}`].join("\n"));
|
|
726
|
+
});
|
|
709
727
|
server.registerTool("done", { description: "Call when the demo is recorded and acceptable (or when you are stopping). One sentence for the person.", inputSchema: { summary: z.string(), demo: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ summary, demo }) => {
|
|
710
728
|
await tell(`Done: ${summary}`);
|
|
711
729
|
if (UI && !SESSION)
|
package/dist/ui/chat.html
CHANGED
|
@@ -11,18 +11,27 @@
|
|
|
11
11
|
/* The review surface. Retake is driven from an agent (or the workshop);
|
|
12
12
|
this page is where a person watches, judges, and collects the output.
|
|
13
13
|
It accepts no prose on purpose. */
|
|
14
|
-
:root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#
|
|
14
|
+
:root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#656c63; --rule:#dbdfd3; --rule2:#c9cec0; --accent:#1a1c1e; --amber:#e8b04b; --ok:#2f5d3f; --bad:#a23b2c; }
|
|
15
15
|
* { box-sizing:border-box; }
|
|
16
16
|
html,body { margin:0; height:100%; background:var(--paper); color:var(--ink); font:15px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
|
|
17
17
|
button, input { font:inherit; color:inherit; }
|
|
18
18
|
.app { min-height:100%; display:flex; flex-direction:column; }
|
|
19
19
|
.top { display:flex; align-items:center; gap:10px; padding:0 24px; height:56px; border-bottom:1px solid var(--rule); background:var(--paper); }
|
|
20
|
+
/* A phone has room for the pills or for the padding, not both. */
|
|
21
|
+
@media (max-width: 700px) {
|
|
22
|
+
.top { padding:0 12px; gap:6px; overflow-x:auto; scrollbar-width:none; }
|
|
23
|
+
.top::-webkit-scrollbar { display:none; }
|
|
24
|
+
.chip { padding:0 11px; font-size:12.5px; }
|
|
25
|
+
}
|
|
20
26
|
.mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; flex:none; }
|
|
21
27
|
.mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
|
|
22
28
|
.mark i:nth-child(1){top:4px;width:4px;opacity:.55} .mark i:nth-child(2){top:8px;width:7px;opacity:.8} .mark i:nth-child(3){top:12px;width:11px;background:var(--amber)} .mark i:nth-child(4){top:16px;width:7px;opacity:.8}
|
|
23
29
|
.top h1 { font-size:15px; font-weight:600; margin:0; }
|
|
24
30
|
.controls { position:relative; display:flex; align-items:center; gap:8px; margin-left:8px; }
|
|
25
|
-
|
|
31
|
+
/* `white-space: nowrap` because these are pills: without it "How it works"
|
|
32
|
+
and "What's new" wrapped INSIDE their own borders on a phone, which reads
|
|
33
|
+
as a broken control rather than a narrow screen. */
|
|
34
|
+
.chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; height:32px; padding:0 14px; display:inline-flex; align-items:center; white-space:nowrap; flex:none; font-size:13px; font-weight:500; color:var(--ink); cursor:pointer; transition:border-color .12s; }
|
|
26
35
|
.chip:hover { border-color:var(--ink); }
|
|
27
36
|
.pop { position:absolute; left:0; top:38px; z-index:40; background:#fff; border:1px solid var(--rule2); border-radius:14px; box-shadow:0 12px 34px rgba(24,28,24,.13); padding:8px; width:290px; display:none; }
|
|
28
37
|
.pop.show { display:block; }
|
|
@@ -64,6 +73,13 @@
|
|
|
64
73
|
.under { display:flex; align-items:baseline; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
|
65
74
|
.vtitle { font-size:15px; font-weight:600; color:var(--ink); }
|
|
66
75
|
.vmeta { font-size:13px; color:var(--muted); }
|
|
76
|
+
/* The recording's own state, in its own colour. A finished take was quiet
|
|
77
|
+
grey metadata while a failed OPTIONAL check was a red mark — so a demo
|
|
78
|
+
that recorded perfectly read as broken. */
|
|
79
|
+
.vstate { font-size:12px; font-weight:500; border-radius:999px; padding:2px 9px; white-space:nowrap; }
|
|
80
|
+
.vstate.done { color:var(--ok); background:rgba(47,93,63,.10); }
|
|
81
|
+
.vstate.part { color:var(--amber); background:rgba(232,176,75,.16); }
|
|
82
|
+
.vstate.bad { color:var(--bad); background:rgba(162,59,44,.10); }
|
|
67
83
|
.under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 14px; cursor:pointer; font-size:13px; font-weight:500; display:inline-flex; align-items:center; transition:border-color .12s; }
|
|
68
84
|
.under a:hover, .under button:hover { border-color:var(--ink); }
|
|
69
85
|
/* A native select with its arrow removed leaves lopsided padding; make it
|
|
@@ -72,16 +88,36 @@
|
|
|
72
88
|
.under a, .under button { height:32px; box-sizing:border-box; align-self:center; }
|
|
73
89
|
.under select:hover { border-color:var(--ink); }
|
|
74
90
|
.frames { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
|
|
91
|
+
/* On a phone the heading, its hint and "Use current frame" fought for one
|
|
92
|
+
line and the button wrapped to three. Stack them instead. */
|
|
93
|
+
@media (max-width: 700px) {
|
|
94
|
+
.frames .fhead { flex-wrap:wrap; row-gap:8px; }
|
|
95
|
+
.frames .fhead .sp { display:none; }
|
|
96
|
+
.frames .fhead .chip { order:3; width:100%; justify-content:center; }
|
|
97
|
+
.frames .fhead .hint { order:2; flex-basis:100%; }
|
|
98
|
+
}
|
|
75
99
|
/* What you flagged, and whether it is still wrong. Shown only when there is
|
|
76
100
|
something flagged — an empty one would be clutter with a heading on it. */
|
|
77
101
|
/* One line: what has actually been looked at, and whether the answer is
|
|
78
102
|
about the recording that is there now. Never a panel — the question is
|
|
79
103
|
"did anyone check this", and that is one line's worth of answer. */
|
|
80
|
-
|
|
104
|
+
/* Its own area, named. Loose grey dashes under the video read as parts of
|
|
105
|
+
the recording that were missing; they are optional services you can run. */
|
|
106
|
+
.review { margin-top:14px; border:1px solid var(--rule); border-radius:12px; padding:10px 14px 12px; background:var(--card); }
|
|
107
|
+
.rhead { font-size:11.5px; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; }
|
|
108
|
+
/* No opacity. It took a colour already at the AA line and pushed it under.
|
|
109
|
+
Case and letter-spacing separate this from the label beside it already. */
|
|
110
|
+
.rhint { text-transform:none; letter-spacing:0; font-size:12px; }
|
|
111
|
+
.checked { margin-top:8px; font-size:12.5px; color:var(--muted); display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; }
|
|
112
|
+
/* One per row on a phone: four of these side by side break into fragments
|
|
113
|
+
that read as unrelated words. */
|
|
114
|
+
@media (max-width: 700px) {
|
|
115
|
+
.checked { display:grid; grid-template-columns:1fr; gap:6px; }
|
|
116
|
+
.checked .c { justify-content:flex-start; }
|
|
117
|
+
}
|
|
81
118
|
.checked .c { display:inline-flex; gap:5px; align-items:baseline; }
|
|
82
119
|
.checked .c b { font-weight:500; color:var(--ink); }
|
|
83
120
|
.checked .c.none b { color:var(--muted); }
|
|
84
|
-
.checked .c.old { opacity:.6; }
|
|
85
121
|
.checked .c.bad b { color:var(--bad); }
|
|
86
122
|
.checked .why { font-size:12px; }
|
|
87
123
|
.flagged { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
|
|
@@ -238,6 +274,7 @@
|
|
|
238
274
|
<video id="vid" controls playsinline></video>
|
|
239
275
|
<div class="under">
|
|
240
276
|
<span class="vtitle" id="vidName"></span>
|
|
277
|
+
<span class="vstate" id="vidState"></span>
|
|
241
278
|
<span class="vmeta" id="vidMeta"></span>
|
|
242
279
|
<span class="sp"></span>
|
|
243
280
|
<select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
|
|
@@ -256,7 +293,10 @@
|
|
|
256
293
|
</div>
|
|
257
294
|
<div id="flagRows"></div>
|
|
258
295
|
</div>
|
|
259
|
-
<div class="
|
|
296
|
+
<div class="review" id="review" style="display:none">
|
|
297
|
+
<div class="rhead">Optional review<span class="rhint">checks you can run against this recording — it is complete without them</span></div>
|
|
298
|
+
<div class="checked" id="checked"></div>
|
|
299
|
+
</div>
|
|
260
300
|
<div class="frames">
|
|
261
301
|
<div class="fhead">
|
|
262
302
|
<span class="lab">Poster & frames</span>
|
|
@@ -335,20 +375,31 @@ $('newsBtn').onclick = async () => {
|
|
|
335
375
|
const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
|
|
336
376
|
const veil = document.createElement('div');
|
|
337
377
|
veil.className = 'veil';
|
|
338
|
-
|
|
339
|
-
|
|
378
|
+
// The newest release, and a way to the rest. Every release at once made the
|
|
379
|
+
// one thing that changed today compete with everything that ever changed.
|
|
380
|
+
let showAll = false;
|
|
381
|
+
const body = () => (showAll ? rs : rs.slice(0, 1)).map((r, i) =>
|
|
340
382
|
'<div class="rel' + (i ? '' : ' latest') + '">' +
|
|
341
383
|
'<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
|
|
342
384
|
'<ul>' + (r.notes || []).map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul>' +
|
|
343
|
-
'</div>').join('')
|
|
344
|
-
|
|
385
|
+
'</div>').join('');
|
|
386
|
+
const render = () => {
|
|
387
|
+
veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3><div class="scroll">' + body() + '</div>' +
|
|
388
|
+
'<div class="row" style="margin-top:18px">' +
|
|
389
|
+
(showAll || rs.length < 2 ? '' : '<button class="btn" id="newsAll">All ' + rs.length + ' releases</button>') +
|
|
390
|
+
'<span class="sp"></span><button class="btn" id="newsClose">Close</button></div></div>';
|
|
391
|
+
const all = veil.querySelector('#newsAll');
|
|
392
|
+
if (all) all.onclick = () => { showAll = true; render(); };
|
|
393
|
+
veil.querySelector('#newsClose').onclick = () => veil.remove();
|
|
394
|
+
};
|
|
395
|
+
render();
|
|
345
396
|
document.body.appendChild(veil);
|
|
346
397
|
localStorage.setItem(SEEN_KEY, rs[0].version);
|
|
347
398
|
$('newsBtn').querySelector('.ndot')?.remove();
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') {
|
|
399
|
+
veil.onclick = (e) => { if (e.target === veil) veil.remove(); };
|
|
400
|
+
// `shut()` does not exist here — Escape threw a ReferenceError and left the
|
|
401
|
+
// modal sitting there. Copied from a dialog that had one.
|
|
402
|
+
document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { veil.remove(); document.removeEventListener('keydown', esc2); } });
|
|
352
403
|
};
|
|
353
404
|
|
|
354
405
|
document.getElementById('gremlinBtn').onclick = async () => {
|
|
@@ -430,6 +481,15 @@ async function pin(name) {
|
|
|
430
481
|
const t = d.take;
|
|
431
482
|
const failed = (t.timeline || []).find((e) => e.ok === false);
|
|
432
483
|
$('vidName').textContent = name;
|
|
484
|
+
// What the RECORDING did — separate from anything optional that looked at it.
|
|
485
|
+
const stateEl = $('vidState');
|
|
486
|
+
// Failure first, always. `brisk` was tested before `ok`, so a brisk take
|
|
487
|
+
// whose steps fell over displayed the neutral "Brisk take" and nothing said
|
|
488
|
+
// it had failed — on the one path people use while iterating fast.
|
|
489
|
+
if (t.ok === false) { stateEl.className = 'vstate bad'; stateEl.textContent = t.brisk ? 'Brisk take — steps failed' : 'Steps failed'; stateEl.title = 'Some steps did not pass — see the proof log.'; }
|
|
490
|
+
else if (t.brisk) { stateEl.className = 'vstate part'; stateEl.textContent = 'Brisk take'; stateEl.title = 'All the steps, none of the pacing. For iterating, not for keeping.'; }
|
|
491
|
+
else if (t.partial) { stateEl.className = 'vstate part'; stateEl.textContent = 'Partial'; stateEl.title = t.partial; }
|
|
492
|
+
else { stateEl.className = 'vstate done'; stateEl.textContent = 'Recording complete'; stateEl.title = 'The take is sound: every step passed and the artifacts check out.'; }
|
|
433
493
|
$('vidMeta').textContent = `${(t.duration - t.trimBefore).toFixed(0)}s · ${t.ok ? 'all steps passed' : failed ? `stopped at step ${failed.index}: ${failed.summary}` : 'some steps failed'}${d.facts ? ` · ${d.facts.width}×${d.facts.height}` : ''}`;
|
|
434
494
|
$('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
|
|
435
495
|
$('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
|
|
@@ -484,7 +544,8 @@ function drawChecked(checks, take) {
|
|
|
484
544
|
const now = take && take.finishedAt;
|
|
485
545
|
// Labelled, because four bare dashes read as four things missing. They are
|
|
486
546
|
// optional checks against a finished recording, not parts of it.
|
|
487
|
-
|
|
547
|
+
$('review').style.display = '';
|
|
548
|
+
box.innerHTML = CHECKS.map(([key, label, title]) => {
|
|
488
549
|
const r = checks && checks[key];
|
|
489
550
|
if (!r) return `<span class="c none" title="${esc(title)} — not run on this take. Optional; the recording is complete without it.">${label} <b>—</b></span>`;
|
|
490
551
|
const stale = !now || r.takeFinishedAt !== now;
|
package/dist/ui/guide.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>How Retake works</title>
|
|
7
7
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
8
8
|
<style>
|
|
9
|
-
:root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#
|
|
9
|
+
:root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#656c63; --rule:#dbdfd3; --amber:#e8b04b; --accent:#1a1c1e; }
|
|
10
10
|
* { box-sizing:border-box; }
|
|
11
11
|
body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.7 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
|
|
12
12
|
.top { display:flex; align-items:center; gap:10px; padding:14px 22px; --rule2:#c9cec0; }
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
.top a { display:flex; align-items:center; gap:10px; text-decoration:none; color:inherit; }
|
|
17
17
|
.top h1 { font-size:15px; font-weight:600; margin:0; }
|
|
18
18
|
main { max-width:640px; margin:0 auto; padding:28px 22px 80px; }
|
|
19
|
+
/* The flow needs three columns of room; the prose below stays narrow. */
|
|
20
|
+
@media (min-width: 900px) { main { max-width:960px; } main > section, main > .lead, main > h2, main > .loop { max-width:640px; } }
|
|
19
21
|
h2 { font-size:22px; letter-spacing:-.01em; margin:0 0 6px; }
|
|
20
22
|
.lead { color:var(--muted); margin:0 0 34px; }
|
|
21
23
|
section { margin-bottom:34px; }
|
|
@@ -28,6 +30,24 @@
|
|
|
28
30
|
.quiet { color:var(--muted); font-size:14px; }
|
|
29
31
|
code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:1px 7px; font:14px ui-monospace, Menlo, monospace; }
|
|
30
32
|
hr { border:0; border-top:1px solid var(--rule); margin:34px 0; }
|
|
33
|
+
/* The first three steps are one loop, not three articles. Prose can say
|
|
34
|
+
that; only a shape shows it. */
|
|
35
|
+
.flow { display:grid; grid-template-columns:1fr auto 1fr auto 1fr; gap:14px; align-items:start; margin:10px 0 18px; }
|
|
36
|
+
.beat { background:var(--card); border:1px solid var(--rule); border-radius:14px; padding:16px; }
|
|
37
|
+
.beat .n { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:999px; background:var(--accent); color:#eceee9; font-size:12px; font-weight:600; }
|
|
38
|
+
.beat h3 { margin:10px 0 8px; font-size:15px; }
|
|
39
|
+
.beat p { font-size:13.5px; line-height:1.6; color:var(--muted); margin:10px 0 0; }
|
|
40
|
+
.beat .say { font-size:13.5px; margin:0; padding:10px 13px; }
|
|
41
|
+
.thing { display:flex; flex-direction:column; gap:5px; background:var(--paper); border:1px solid var(--rule); border-radius:10px; padding:11px 13px; font-size:14px; }
|
|
42
|
+
.thing span { color:var(--muted); font-size:13px; }
|
|
43
|
+
.arrow { color:#c9cec0; font-size:20px; padding-top:56px; }
|
|
44
|
+
.loop { display:flex; gap:10px; align-items:baseline; font-size:14px; color:var(--muted); background:var(--card); border:1px solid var(--rule); border-radius:12px; padding:12px 16px; }
|
|
45
|
+
.loop span { color:var(--amber); font-size:16px; }
|
|
46
|
+
/* Stacked on a phone, with the arrows turned to point down the page. */
|
|
47
|
+
@media (max-width: 760px) {
|
|
48
|
+
.flow { grid-template-columns:1fr; gap:10px; }
|
|
49
|
+
.arrow { padding:0; text-align:center; transform:rotate(90deg); }
|
|
50
|
+
}
|
|
31
51
|
</style>
|
|
32
52
|
</head>
|
|
33
53
|
<body>
|
|
@@ -40,33 +60,59 @@
|
|
|
40
60
|
<h2>How Retake works</h2>
|
|
41
61
|
<p class="lead">Describe a demo of your app in a sentence. Retake drives a real browser through it and hands you the video. When the app changes, you re-run — nobody re-records anything by hand.</p>
|
|
42
62
|
|
|
43
|
-
<
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
<div class="flow">
|
|
64
|
+
<div class="beat">
|
|
65
|
+
<span class="n">1</span>
|
|
66
|
+
<h3>You say it</h3>
|
|
67
|
+
<div class="say">Record a demo of my app at localhost:3000 showing the sign-up flow.</div>
|
|
68
|
+
<p>To the agent you already use. It reads your app, writes the steps, checks every one against the live page, and only then records.</p>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="arrow" aria-hidden="true">→</div>
|
|
71
|
+
<div class="beat">
|
|
72
|
+
<span class="n">2</span>
|
|
73
|
+
<h3>You watch it</h3>
|
|
74
|
+
<div class="thing"><b>demo.mp4</b><span>a still for every scene</span><span>a written record of what happened</span></div>
|
|
75
|
+
<p>It lands in this window, newest on top. <b>Download</b> for the file, <b>Show in folder</b> for the rest.</p>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="arrow" aria-hidden="true">→</div>
|
|
78
|
+
<div class="beat">
|
|
79
|
+
<span class="n">3</span>
|
|
80
|
+
<h3>You change your mind</h3>
|
|
81
|
+
<div class="say">No zooms. Hide the cursor. Square for a post.</div>
|
|
82
|
+
<p>In plain words, and most of it comes back in seconds — the recording is already made. Say it once and Retake keeps it for the project.</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<p class="loop"><span>↺</span> And when your app changes, you run the same file again. That is the whole idea: the demo outlives the video.</p>
|
|
50
86
|
|
|
51
|
-
<
|
|
52
|
-
<h3>2 · Judge the take</h3>
|
|
53
|
-
<p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Show in folder</b> for everything else — the stills, the proof log, the manifest.</p>
|
|
54
|
-
</section>
|
|
87
|
+
<hr>
|
|
55
88
|
|
|
56
89
|
<section>
|
|
57
|
-
<h3>
|
|
58
|
-
<p>
|
|
90
|
+
<h3>When you want to know it is right</h3>
|
|
91
|
+
<p>A recording is finished when it records. Everything below is optional, run
|
|
92
|
+
afterwards on a take you already have, and none of it changes the video.</p>
|
|
93
|
+
<p>Ask your agent in plain words. It has all of these.</p>
|
|
59
94
|
<ul>
|
|
60
|
-
<li
|
|
61
|
-
|
|
62
|
-
<li
|
|
63
|
-
|
|
95
|
+
<li><b>Does it look right?</b> Write what should be visible on a scene —
|
|
96
|
+
<i>“the board shows two items”</i> — and every later recording answers it.</li>
|
|
97
|
+
<li><b>What did nobody think to check?</b> Every frame gets read for the ten
|
|
98
|
+
ways a screen goes wrong: text cut off, things overlapping, writing you
|
|
99
|
+
cannot read, a spinner still spinning, a dev badge left in shot.</li>
|
|
100
|
+
<li><b>Does it add up?</b> What the demo typed against what came back. It found
|
|
101
|
+
a quote showing one price for two flights that way.</li>
|
|
102
|
+
<li><b>What happens off the happy path?</b> The button pressed twice, the page
|
|
103
|
+
reloaded mid-flight, the app with nothing in it yet.</li>
|
|
104
|
+
<li><b>Did that get fixed?</b> Say something is wrong once and every later take
|
|
105
|
+
answers it, with the six seconds of video that show it.</li>
|
|
64
106
|
</ul>
|
|
65
|
-
<p class="quiet">
|
|
107
|
+
<p class="quiet">Tell it what your product is for and these get much better —
|
|
108
|
+
otherwise they judge your app as a generic web page and anything you did on
|
|
109
|
+
purpose can come back as a mistake. Say it once; it is kept.</p>
|
|
110
|
+
<p class="quiet">They report, they do not block. On Retake's own help page the
|
|
111
|
+
frame check found eight of eight faults deliberately planted in it and
|
|
112
|
+
complained about nothing on a clean recording, and that number is a floor
|
|
113
|
+
rather than a promise.</p>
|
|
66
114
|
</section>
|
|
67
115
|
|
|
68
|
-
<hr>
|
|
69
|
-
|
|
70
116
|
<section>
|
|
71
117
|
<h3>Where things live</h3>
|
|
72
118
|
<p>Each demo is a small YAML file in <code>demos/</code> — that file <i>is</i> the demo. Videos land in <code>outputs/<name>/</code>: <code>demo.mp4</code>, <code>stills/</code>, and <code>proof-log.md</code> saying exactly what happened, step by step.</p>
|
package/dist/ui/server.js
CHANGED
|
@@ -40,6 +40,7 @@ import { digest } from "../digest.js";
|
|
|
40
40
|
import { readFlags } from "../ext/flags.js";
|
|
41
41
|
import { findOrphans } from "../ext/heal.js";
|
|
42
42
|
import { readChecks } from "../ext/checked.js";
|
|
43
|
+
import { productName } from "../ext/intent.js";
|
|
43
44
|
import { running } from "../progress.js";
|
|
44
45
|
import { MAX_BODY, checkRequest, clearToken, newToken, readBodyLimited, writeToken } from "./guard.js";
|
|
45
46
|
import { startOffer, startApp, listeningPorts } from "../appserver.js";
|
|
@@ -201,14 +202,31 @@ function listDemos(project) {
|
|
|
201
202
|
return [];
|
|
202
203
|
const wanted = project?.trim() ? projectKey(project) : null;
|
|
203
204
|
const assignments = demoProjects();
|
|
205
|
+
// The precedence for what a demo's folder is called, in one place, because
|
|
206
|
+
// it was three places and the release note described a fourth:
|
|
207
|
+
//
|
|
208
|
+
// 1. a folder you assigned by hand
|
|
209
|
+
// 2. a folder assigned by hand to another demo of the same app
|
|
210
|
+
// 3. the name in this demo's own product note
|
|
211
|
+
// 4. the name in a product note written for another demo of the same app
|
|
212
|
+
// 5. the app's address, which is where this started and helps nobody
|
|
213
|
+
//
|
|
214
|
+
// Four is new. Writing `retake intent --demo checkout "# Acme"` named the
|
|
215
|
+
// checkout folder and left the other six Acme demos in a fold called
|
|
216
|
+
// "localhost:3200", which is the exact complaint the naming was added to
|
|
217
|
+
// answer.
|
|
204
218
|
const byUrl = new Map();
|
|
219
|
+
const namedByUrl = new Map();
|
|
205
220
|
for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
|
|
206
221
|
const mm = cachedManifest(path.join(DEMOS, f));
|
|
207
|
-
if (mm)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
222
|
+
if (!mm)
|
|
223
|
+
continue;
|
|
224
|
+
const asg = assignments[mm.name];
|
|
225
|
+
if (asg && mm.url && !byUrl.has(mm.url))
|
|
226
|
+
byUrl.set(mm.url, asg);
|
|
227
|
+
const named = productName(DEMOS, mm.name);
|
|
228
|
+
if (named && mm.url && !namedByUrl.has(mm.url))
|
|
229
|
+
namedByUrl.set(mm.url, named);
|
|
212
230
|
}
|
|
213
231
|
const listed = fs
|
|
214
232
|
.readdirSync(DEMOS)
|
|
@@ -239,7 +257,10 @@ function listDemos(project) {
|
|
|
239
257
|
// that points at the same app — otherwise an agent's new draft for
|
|
240
258
|
// Capture lands in a second "localhost:3100" fold next to "capture".
|
|
241
259
|
const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
|
|
242
|
-
|
|
260
|
+
// A product that has said what it is called is filed under that name.
|
|
261
|
+
// Grouping by URL gave folders like "localhost:3200" — true, and no help
|
|
262
|
+
// to anybody looking for their charter demos.
|
|
263
|
+
const group = assigned ? path.basename(assigned) : (productName(DEMOS, name) ?? (url ? namedByUrl.get(url) : undefined) ?? shortGroup(url));
|
|
243
264
|
// Heal a folder whose run was killed mid-recording: the previous take is
|
|
244
265
|
// in its stash and nothing has put it back yet. The window must never
|
|
245
266
|
// show someone an empty demo when their recording is safe on disk.
|
|
@@ -276,7 +297,7 @@ function orphanedRecordings(known) {
|
|
|
276
297
|
// rows that look like failed demos and are not demos at all.
|
|
277
298
|
.filter((o) => o.rendered && !o.fragment && !known.includes(o.name))
|
|
278
299
|
.map((o) => ({
|
|
279
|
-
name: o.name, file: "", title: o.title, url: o.url, group: shortGroup(o.url),
|
|
300
|
+
name: o.name, file: "", title: o.title, url: o.url, group: productName(DEMOS, o.name) ?? shortGroup(o.url),
|
|
280
301
|
valid: true, settings: {},
|
|
281
302
|
lastTake: o.finishedAt ? { finishedAt: o.finishedAt, ok: true, partial: null, duration: o.seconds ?? 0 } : null,
|
|
282
303
|
needsRecord: true,
|
package/package.json
CHANGED
package/site/releases.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_readme": "What's new, written for the person using Retake — not for whoever wrote it. Both the landing page and the Retake window read THIS file; there is no second copy. Rules for adding one: say what changed for them, name no files, no flags they will not type, no words like refactor or regression. If a line only makes sense to someone who has read the source, it does not belong here. Newest first. Add an entry on every release that changes what people see.",
|
|
3
3
|
"releases": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.13.2",
|
|
6
|
+
"date": "2026-08-28",
|
|
7
|
+
"headline": "The thing that measures the checks was not measuring",
|
|
8
|
+
"notes": [
|
|
9
|
+
"Calibration is the number that says how much a visual finding is worth. Run against an app that was not even running, it used to print \"no false positives on the control\" and finish happily — because nothing had happened, so nothing was wrong. It now refuses: a run that fell over, recorded no steps, or produced no frames measures nothing, says which, and does not pretend otherwise.",
|
|
10
|
+
"One of its eight seeded faults could never have worked. A typo in the unreadable-contrast seed meant its own self-check could never be true, so that fault reported \"did not land\" on every app it was ever tried on. Fixed, and every seed is now tested in a real browser so a silently dead one cannot happen again.",
|
|
11
|
+
"Two more honest things it now says out loud: that two of the ten things it looks for have no seeded test at all, so the score says nothing about them; and whether asking the same question about the same pictures twice gives the same answer. That second one was promised in the code for two releases and never actually measured.",
|
|
12
|
+
"The checks run in a browser, and the browser was never installed on the build server — so every automated run since those tests were written failed before it started, and the build and packaging checks after them never ran. Installed now.",
|
|
13
|
+
"Fixed: pressing Escape on the What's New window did nothing. Fixed: a quick take whose steps failed showed a neutral \"Brisk take\" and never said it had failed. Fixed: the small print in the review area was too faint to meet the readability standard for text that size. Fixed: naming one demo's product now names the folder for every demo of the same app, not just that one. Fixed: a dry run now really scrolls when the demo says to scroll.",
|
|
14
|
+
"The agent tool for calibration used to advertise a setting that would run a fresh measurement, and always answered by telling you to go and run it yourself. That setting is gone.",
|
|
15
|
+
"And the landing page said \"nothing uploaded\", which was true of recording and not of the optional visual checks — those send the frames they look at to whichever model you point them at. It now says so."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.13.1",
|
|
20
|
+
"date": "2026-08-28",
|
|
21
|
+
"headline": "The help page explains the checks, and the watcher stops saying something untrue",
|
|
22
|
+
"notes": [
|
|
23
|
+
"The How it works page inside Retake never mentioned any of the checks. It now explains them in a paragraph each, and says plainly that a recording is finished when it records — everything else is optional and runs afterwards on a take you already have.",
|
|
24
|
+
"Fixed: the workspace summary told you a demo had never been checked when that demo carried seventeen written expectations, all of which had just been answered. It only says that now when there is genuinely not one."
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.13.0",
|
|
29
|
+
"date": "2026-08-28",
|
|
30
|
+
"headline": "You can now measure how much the picture checks are worth",
|
|
31
|
+
"notes": [
|
|
32
|
+
"New: Retake can test its own eyesight. It takes a demo that works, records it once for each way a screen can go wrong — text cut off, things overlapping, writing you can't read, content off the edge, something drawn twice, a stuck spinner, a broken image, a developer badge left in shot — and once more with nothing wrong at all. Then it counts what the check found.",
|
|
33
|
+
"The first measurement, on Retake's own help page: all eight seeded faults found, and nothing at all reported on the clean recording.",
|
|
34
|
+
"It says what that number is and is not. The faults were written by somebody who knew what the check looks for, so it is a floor for trust rather than a score, and a fault nobody thought to seed is still unmeasured.",
|
|
35
|
+
"It also refuses to fool itself: if a seeded fault never actually appeared on screen, the run says so instead of blaming the check. That happened three times while this was being built, and each time it looked exactly like a miss."
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.12.5",
|
|
40
|
+
"date": "2026-08-28",
|
|
41
|
+
"headline": "How Retake works, shown rather than written",
|
|
42
|
+
"notes": [
|
|
43
|
+
"The How it works page opened with three numbered articles you had to read. It now shows the loop: you say it, you watch it, you change your mind — and when your app changes you run the same file again. Same words, arranged so the shape is visible before you read a sentence.",
|
|
44
|
+
"On the website, the three products Retake was built inside now show a frame from the actual recording instead of only describing one. The third says plainly that its walkthrough belongs to the client and is not mine to publish, which is the honest version of a case study."
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.12.4",
|
|
49
|
+
"date": "2026-08-28",
|
|
50
|
+
"headline": "Your demos get filed under your product's name",
|
|
51
|
+
"notes": [
|
|
52
|
+
"The window used to file demos under whatever it could work out from the address — so real products ended up in folders called localhost:3200 and localhost:4990. True, and no help to anyone looking for their charter demos.",
|
|
53
|
+
"Name your product in its note and that name becomes its folder. One thing to write, two things fixed: the checks stop judging your app as a generic web page, and your library reads like your work instead of a list of ports.",
|
|
54
|
+
"What's new now opens on the newest release, with a way to the rest — instead of every release you have ever had, all at once.",
|
|
55
|
+
"On the website: the five bugs a recording found now sit directly under the first video instead of most of the way down the page, and two sections that explained the same thing twice are one."
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "0.12.3",
|
|
60
|
+
"date": "2026-08-28",
|
|
61
|
+
"headline": "A finished recording now looks finished",
|
|
62
|
+
"notes": [
|
|
63
|
+
"Fixed: a completed recording was quiet grey text while a failed optional check was a bold red mark — so a demo that recorded perfectly read as broken. Every recording now says what it is, next to its name: complete, partial, brisk, or steps failed.",
|
|
64
|
+
"The optional checks have moved into their own area, headed and explained. They are things you can run against a finished recording, and the window now looks like that instead of like four missing parts.",
|
|
65
|
+
"On a phone: each check gets its own line instead of breaking into fragments, the buttons along the top stop wrapping inside themselves, and Use current frame is a proper full-width button rather than three wrapped lines.",
|
|
66
|
+
"On the website: the button that says it copies the setup prompt now copies it. It used to scroll you five thousand pixels down the page to a different button that did the copying.",
|
|
67
|
+
"And two sentences on that page have been corrected. They claimed watching a recording checks everything at once. Retake's own evidence says otherwise — one pass can miss something a second pass finds — so the page now says what is true: a recording puts the whole screen in front of you, the optional checks surface things nobody wrote down, and you decide what matters."
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"version": "0.12.2",
|
|
72
|
+
"date": "2026-08-28",
|
|
73
|
+
"headline": "A security fix, and the starter demo now passes its own check",
|
|
74
|
+
"notes": [
|
|
75
|
+
"Fixed, and please update: naming a demo when writing your product note could write the file outside your demos folder. Any name is now refused unless it is an ordinary demo name.",
|
|
76
|
+
"Fixed: `retake dry` failed on `scroll to: bottom` — it looked for something on the page called \"bottom\" instead of understanding it as the end of the page. The starter demo that ships with a new workspace uses it, so a new person's very first check failed on a demo that records perfectly.",
|
|
77
|
+
"The check that tells you whether something you flagged is fixed now reads your product note too, like the others already did.",
|
|
78
|
+
"Reading your product note is now part of the written order of work, not a footnote — it comes before anything looks at a frame."
|
|
79
|
+
]
|
|
80
|
+
},
|
|
4
81
|
{
|
|
5
82
|
"version": "0.12.1",
|
|
6
83
|
"date": "2026-08-28",
|
package/skill/SKILL.md
CHANGED
|
@@ -49,20 +49,24 @@ say so in one line and stop.
|
|
|
49
49
|
selectors, logins under `auth.setup` with `${ENV}` placeholders and
|
|
50
50
|
`secret: true`, `reducedMotion: true` if things animate forever, scrolls
|
|
51
51
|
before anything below the fold.
|
|
52
|
-
5. **`
|
|
52
|
+
5. **`intent`** — read it. One call, and it is what stops the checks judging
|
|
53
|
+
this app as a generic web page. If there is nothing there and the person
|
|
54
|
+
has told you what their product is, write it; if they have not, say so
|
|
55
|
+
when you report, and never invent one.
|
|
56
|
+
6. **`dry`** — ALWAYS before `run`. Seconds, no camera, strict; failures
|
|
53
57
|
include what was on screen. Fix and repeat until it passes. Never record
|
|
54
58
|
a manifest whose dry run fails.
|
|
55
|
-
|
|
59
|
+
7. **While iterating, record at `--preset draft`** (CLI) — same layout as the
|
|
56
60
|
final at a quarter of the pixels, so a take costs ~real time instead of
|
|
57
61
|
real time plus an encoder. Switch to a post preset only for the take you
|
|
58
62
|
intend to keep.
|
|
59
|
-
|
|
63
|
+
8. **`run` with `preview=true`**, then read the `receipts` AND `look` at
|
|
60
64
|
the take. Receipts say what happened; `look` shows how it looks — one
|
|
61
65
|
image per scene. Judge it like a viewer would: is the thing the caption
|
|
62
66
|
names actually on screen? Anything in shot that shouldn't be (dev
|
|
63
67
|
badges, leftover data, cut-off text)? Wrong story or failed step →
|
|
64
68
|
`edit`, run again. More than four rounds → ask the person.
|
|
65
|
-
|
|
69
|
+
9. **Before the expensive take, if it is expensive, show them the draft.**
|
|
66
70
|
`run` prints an estimate before capture. If it said this is a long take
|
|
67
71
|
(over ~2.5 minutes), do not go straight to full quality: `ask` them,
|
|
68
72
|
naming the demo and pointing at the draft in the Retake window, and say
|
|
@@ -79,7 +83,7 @@ say so in one line and stop.
|
|
|
79
83
|
- **Nobody there to ask?** Never block and never guess. Leave the draft,
|
|
80
84
|
`done` with what you have and what it would cost, and let them start
|
|
81
85
|
the expensive one when they are back.
|
|
82
|
-
|
|
86
|
+
10. When they say go: `run` with `preview=false` once (full quality), then
|
|
83
87
|
`done` with one sentence. Outputs: `demo.mp4`, `stills/` (one PNG per
|
|
84
88
|
scene), `proof-log.md`.
|
|
85
89
|
|
|
@@ -130,6 +134,18 @@ Three things about reading what it says:
|
|
|
130
134
|
- **A clean sweep is a floor, not a ceiling.** A closed checklist cannot find
|
|
131
135
|
what is not on it. It catches the ten things that go wrong in every app; it
|
|
132
136
|
does not certify the frame.
|
|
137
|
+
- **Say what it is worth, if anybody has measured.** `calibrate` seeds known
|
|
138
|
+
defects into a real recording of the app and counts what `sweep` finds. Read
|
|
139
|
+
the report before telling somebody how much a finding is worth, and quote its
|
|
140
|
+
limits rather than only its number: the seeds were written by somebody who
|
|
141
|
+
had read the checklist, so recall is an upper bound; two of the ten checklist
|
|
142
|
+
items have no seed at all, so the number says nothing about them; and a run
|
|
143
|
+
whose control did not record cleanly measured nothing. Earlier versions of
|
|
144
|
+
this file quoted "seven or eight of eight, no false positives" — one of those
|
|
145
|
+
eight seeds could never have landed, because its proof was broken, so the
|
|
146
|
+
denominator was wrong and the claim about repeated runs had never been
|
|
147
|
+
measured. Quote the report you have, not a remembered figure. Never run it
|
|
148
|
+
unasked: nine takes, about ten minutes, and only from a terminal.
|
|
133
149
|
- **One pass is not reliable, and this is measured.** On a frame with a real
|
|
134
150
|
overlap on it, three consecutive sweeps missed it and a fourth found it. The
|
|
135
151
|
judge is not deterministic. So a frame that has already been looked at is
|
|
@@ -163,6 +179,12 @@ left in the navigation of a customer-facing recording.
|
|
|
163
179
|
Same number of findings. Better findings.
|
|
164
180
|
|
|
165
181
|
- `intent` with no text **reads** it. Do this before you report anything.
|
|
182
|
+
- **The first heading names the product, and that name becomes its folder in
|
|
183
|
+
the window.** Without one the window files demos by URL — real workspaces
|
|
184
|
+
end up with folders called `localhost:3200` and `localhost:4990`, which is
|
|
185
|
+
accurate and no help to anybody looking for their charter demos. Naming the
|
|
186
|
+
product once, for the checks, names its folder too. That part is your job:
|
|
187
|
+
the person should not have to tidy up after you.
|
|
166
188
|
- `intent` with text **writes** it — the first time the person explains what
|
|
167
189
|
their product does, who uses it, or why something unusual is on purpose.
|
|
168
190
|
Use `demo` to write it for one app; a workspace with three products cannot
|
|
@@ -209,6 +231,7 @@ These are separate questions. Running the wrong one and reporting it as
|
|
|
209
231
|
| What happens just off the happy path? | `destroy` | minutes | flags, you judge |
|
|
210
232
|
| Did the things they flagged get fixed? | `fixed` | ~13s each | yes — exit 3 |
|
|
211
233
|
| What is this product FOR? | `intent` | instant | context, not a check |
|
|
234
|
+
| How much is a `sweep` finding worth here? | `calibrate` | ~10 min, nine takes | a measurement, not a check |
|
|
212
235
|
| What keeps going wrong across every demo? | `notes` | instant | no |
|
|
213
236
|
|
|
214
237
|
`dry` really does click and fill — at full speed, with short timeouts, no
|