@augurworks/augur 0.15.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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // augur read <prototype> [--dir <folder>] — a read-only copy of a unit, for context. Lands
3
+ // under _read/<unit>/ beside the draft folders; files carry no write bit and the deny hook
4
+ // refuses edits there. `augur close` inside it removes it. See docs/drafts-that-land.md §7.
5
+ import path from "node:path";
6
+ import { target } from "./lib/store.mjs";
7
+ import { markPathFor } from "./lib/marks.mjs";
8
+ import { unitClient, doRead, readDirFor } from "./lib/draft.mjs";
9
+ import { normUnit } from "../src/unit-core.mjs";
10
+
11
+ const log = (m) => console.error(`\x1b[35m[read]\x1b[0m ${m}`);
12
+ const die = (m) => { console.error(`\x1b[31m[read]\x1b[0m ${m}`); process.exit(1); };
13
+ const argv = process.argv.slice(2);
14
+ const opt = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
15
+ const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1].startsWith("--")));
16
+ const raw = positional[0];
17
+ if (!raw) die("name a prototype: `augur read <opportunity>/<prototype>`.");
18
+ const unit = normUnit(markPathFor(raw) || raw);
19
+ if (!unit) die(`"${raw}" is not a prototype path.`);
20
+ let origin, token;
21
+ try { ({ origin, token } = target({ needToken: true })); } catch (e) { die(e.message); }
22
+ const dir = path.resolve(opt("--dir") || readDirFor(unit));
23
+ const client = unitClient({ origin, token, space: "", session: "" });
24
+ const r = await doRead({ client, unit, dir, origin, now: new Date().toISOString() });
25
+ if (!r.ok) {
26
+ if (r.error === "folder-not-empty") die(`${r.dir} is not empty — pick another folder with --dir.`);
27
+ if (r.error === "units-not-configured") die("this instance does not serve drafts yet (no unit store bound).");
28
+ die(`could not read: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}`);
29
+ }
30
+ log(`${r.files} file(s) of ${unit} at revision ${r.revision}, read-only`);
31
+ console.log(dir);
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env node
2
+ // refine — the screenshot-verified refine loop, as a harness rather than a habit.
3
+ //
4
+ // WHAT IT IS FOR. Rebuilding a design system's components against a real product is not
5
+ // finished when an agent says it is finished. It is finished when each component, rendered
6
+ // and photographed, looks like the original — and that is a measurement, taken by
7
+ // something with no stake in the answer. This runs that loop: for every component in a
8
+ // manifest, render the candidate, render (or load) the original, compare the two images,
9
+ // and record the number. Then it prints a pass-rate.
10
+ //
11
+ // augur refine verify every component in refine.json
12
+ // augur refine --gate 0.99 …and exit 1 unless 99% of them pass
13
+ // augur refine --only card,toolbar just these
14
+ // augur refine --restart ignore the ledger and re-measure everything
15
+ // augur refine --audit re-derive every verdict from the saved shots, no browser
16
+ //
17
+ // THE ONE RULE. No self-assessment. Nothing an agent says about its own work reaches the
18
+ // verdict: the harness renders the pixels itself, measures them itself, and derives pass
19
+ // and fail from the number every time the report is drawn. There is no "mark as done", no
20
+ // approve, and — deliberately — **no command that adopts a candidate render as the
21
+ // reference**. A blessing tool would be the whole hole: an agent that can nominate its own
22
+ // output as the thing to match can make anything pass. References come from OUTSIDE the
23
+ // loop, from the original product.
24
+ //
25
+ // Two smaller holes are closed the same way. `--gate` REFUSES to run with `--only`, because
26
+ // a pass-rate over components you picked is a claim about your own work wearing a
27
+ // measurement's clothes. And the threshold is printed on the same line as the pass-rate,
28
+ // every time, because loosening the bar is the cheapest way to turn a night green and it
29
+ // should never be possible to quote the rate without it.
30
+ //
31
+ // WHAT REMAINS THE READER'S JOB. The manifest and the references are the canon; anyone who
32
+ // can edit those can change what "correct" means, and no harness can tell that from a real
33
+ // correction. That is why references are extracted from the original product and reviewed
34
+ // like source. This tool guarantees the number describes the pixels — not that the pixels
35
+ // are the right ones to have asked for.
36
+ //
37
+ // RESUMABLE, BECAUSE OVERNIGHT RUNS DIE. Each component's measurement is appended to a
38
+ // JSONL ledger and fsynced before the next one starts. A run killed at component 340 of
39
+ // 500 restarts at 341. Skipping is by content digest, not by position, so editing one
40
+ // component re-verifies that one and nothing else. See `scripts/lib/refine-ledger.mjs`.
41
+ //
42
+ // WHAT A REFERENCE MAY BE — one string per side, kind inferred:
43
+ // `components/card` a folder (or .html file) on disk, rendered over file://
44
+ // `/components/card/` a site path, rendered through --base <origin> — the REAL
45
+ // serving path, e.g. against `npm run offline -- --sandbox`
46
+ // `https://…` a URL, rendered as-is
47
+ // `shots/card.png` an image already captured; loaded, not rendered
48
+ //
49
+ // PREFER RENDERING BOTH SIDES OVER A CAPTURED PNG. When both sides are rendered in the
50
+ // same browser in the same run, font rasterisation, hinting and antialiasing are identical
51
+ // on both sides and cancel exactly — the comparison is about the component. A PNG captured
52
+ // on another machine brings that machine's font stack with it, and the threshold then has
53
+ // to absorb text-edge noise, which is how a harness ends up too loose to catch anything.
54
+ //
55
+ // Requires: playwright (devDep). Run output lands in `.augur-refine/` (gitignored).
56
+
57
+ import { promises as fs } from "node:fs";
58
+ import path from "node:path";
59
+ import { pathToFileURL } from "node:url";
60
+ import { decodePng, encodePng } from "./lib/refine-png.mjs";
61
+ import { compareImages, verdict, DEFAULT_THRESHOLD, DEFAULT_PIXEL_TOLERANCE } from "./lib/refine-compare.mjs";
62
+ import {
63
+ LEDGER_NAME, appendLedger, contentDigest, isFresh, readLedger, report, settingsFingerprint, sha256,
64
+ } from "./lib/refine-ledger.mjs";
65
+
66
+ const RUN_DIR = ".augur-refine";
67
+ const MANIFEST = "refine.json";
68
+ // The gallery tiers build.js publishes, plus each opportunity's prototypes — the same
69
+ // list `scripts/shoot.mjs` walks, and for the same reason: these are the folders that
70
+ // hold one rendered thing each.
71
+ const TIERS = ["base", "components", "patterns", "pages"];
72
+
73
+ const argv = process.argv.slice(2);
74
+ const flag = (name) => argv.includes(`--${name}`);
75
+ const opt = (name, dflt) => {
76
+ const i = argv.indexOf(`--${name}`);
77
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : dflt;
78
+ };
79
+
80
+ if (flag("help") || flag("h")) {
81
+ console.log(await fs.readFile(new URL(import.meta.url), "utf8").then((s) =>
82
+ s.split("\n").filter((l) => l.startsWith("//")).map((l) => l.replace(/^\/\/ ?/, "")).join("\n")));
83
+ process.exit(0);
84
+ }
85
+
86
+ const ROOT = path.resolve(opt("root", process.cwd()));
87
+ const base = opt("base", process.env.AUGUR_REFINE_BASE || "");
88
+ const only = (opt("only", "") || "").split(",").map((s) => s.trim()).filter(Boolean);
89
+ const gate = opt("gate", "") ? Number(opt("gate")) : null;
90
+ const auditOnly = flag("audit");
91
+ const restart = flag("restart");
92
+
93
+ // A GATE IS A STATEMENT ABOUT THE CANON, NOT ABOUT A CHOSEN SUBSET OF IT. `--only` is for
94
+ // working on one component; `--gate` is the number a caller has to satisfy. Together they
95
+ // are the one way left to manufacture a pass-rate without touching a pixel — measure the
96
+ // four components that already work and report 100%. So they are refused together, by name.
97
+ if (gate != null && only.length) {
98
+ console.error("refine: --gate and --only cannot be combined — a pass-rate over components you chose is not a pass-rate.");
99
+ console.error(" Run --only while you work; run the whole manifest when you want the number.");
100
+ process.exit(2);
101
+ }
102
+ if (gate != null && !(Number.isFinite(gate) && gate >= 0 && gate <= 1)) {
103
+ console.error(`refine: --gate ${opt("gate")} is not a fraction between 0 and 1 (0.99 means 99%)`);
104
+ process.exit(2);
105
+ }
106
+
107
+ const exists = async (p) => { try { await fs.access(p); return true; } catch { return false; } };
108
+
109
+ // ---------------------------------------------------------------- the manifest
110
+
111
+ async function loadManifest() {
112
+ const file = path.join(ROOT, MANIFEST);
113
+ if (await exists(file)) {
114
+ const m = JSON.parse(await fs.readFile(file, "utf8"));
115
+ if (!Array.isArray(m.components) || !m.components.length) {
116
+ throw new Error(`${MANIFEST} has no components — a manifest with nothing in it is not a verified canon`);
117
+ }
118
+ return m;
119
+ }
120
+ return { ...(await discover()), discovered: true };
121
+ }
122
+
123
+ // Without a manifest, a component is a tier folder that has something to be compared
124
+ // AGAINST — `reference.png` beside it, or a `reference/` folder to render. A folder with
125
+ // no reference is not verifiable, so it is listed as such rather than silently dropped.
126
+ async function discover() {
127
+ const components = [];
128
+ const unverifiable = [];
129
+ const dirs = [];
130
+ for (const tier of TIERS) {
131
+ const t = path.join(ROOT, tier);
132
+ if (!(await exists(t))) continue;
133
+ for (const e of await fs.readdir(t, { withFileTypes: true })) {
134
+ if (e.isDirectory() && !e.name.startsWith(".")) dirs.push([`${tier}/${e.name}`, path.join(t, e.name)]);
135
+ }
136
+ }
137
+ for (const e of await fs.readdir(ROOT, { withFileTypes: true })) {
138
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
139
+ const pp = path.join(ROOT, e.name, "prototypes");
140
+ if (!(await exists(pp))) continue;
141
+ for (const p of await fs.readdir(pp, { withFileTypes: true })) {
142
+ if (p.isDirectory() && !p.name.startsWith(".")) dirs.push([`${e.name}/${p.name}`, path.join(pp, p.name)]);
143
+ }
144
+ }
145
+ for (const [id, dir] of dirs) {
146
+ if (await exists(path.join(dir, "reference.png"))) {
147
+ components.push({ id, candidate: path.relative(ROOT, dir), reference: path.relative(ROOT, path.join(dir, "reference.png")) });
148
+ } else if (await exists(path.join(dir, "reference"))) {
149
+ components.push({ id, candidate: path.relative(ROOT, dir), reference: path.relative(ROOT, path.join(dir, "reference")) });
150
+ } else {
151
+ unverifiable.push(id);
152
+ }
153
+ }
154
+ return { components, unverifiable };
155
+ }
156
+
157
+ // ---------------------------------------------------------------- targets
158
+
159
+ // Entry HTML for a folder: index.html, else the first .html. Same rule `shoot.mjs` uses —
160
+ // if that rule ever changes, change it in both, because a poster and a verification shot
161
+ // of the same folder must be of the same page.
162
+ async function entryFile(dir) {
163
+ if ((await fs.stat(dir)).isFile()) return dir;
164
+ const index = path.join(dir, "index.html");
165
+ if (await exists(index)) return index;
166
+ const es = await fs.readdir(dir, { withFileTypes: true });
167
+ const h = es.find((e) => e.isFile() && e.name.endsWith(".html"));
168
+ if (!h) throw new Error(`no .html entry in ${path.relative(ROOT, dir)}`);
169
+ return path.join(dir, h.name);
170
+ }
171
+
172
+ /**
173
+ * Resolve one side of a comparison to `{kind, url|file, digestOf}`.
174
+ *
175
+ * `digestOf` is the thing on disk whose bytes decide whether this side has CHANGED since
176
+ * the ledger last measured it. For a folder or a PNG that is the target itself. For a URL
177
+ * it is a question the URL cannot answer — and getting this wrong is a lie the harness
178
+ * would tell every night: digest the URL STRING and an edited component keeps its stale
179
+ * verdict forever, because the string never changes. So:
180
+ *
181
+ * • a site path resolves to the same-named folder under the canon root, when there is
182
+ * one — serving your canon over a local server is the normal reason to use `--base`,
183
+ * and `/components/card/` is `components/card` on disk;
184
+ * • `candidateSource` / `referenceSource` in the manifest override that, for a server
185
+ * whose URLs are not its folder names;
186
+ * • anything left with no source on disk is VOLATILE: re-measured every run, never
187
+ * resumed, and said out loud, because "I cannot tell whether this changed" is an
188
+ * honest answer and "unchanged" is not.
189
+ */
190
+ async function resolveTarget(spec, label, sourceOverride) {
191
+ if (typeof spec !== "string" || !spec) throw new Error(`${label} is missing`);
192
+ let source = null;
193
+ if (sourceOverride) {
194
+ source = path.resolve(ROOT, sourceOverride);
195
+ if (!(await exists(source))) throw new Error(`${label} source "${sourceOverride}" does not exist`);
196
+ }
197
+ if (/^https?:\/\//.test(spec)) return { kind: "url", url: spec, digestOf: source };
198
+ if (spec.startsWith("/")) {
199
+ if (!base) throw new Error(`${label} "${spec}" is a site path — pass --base <origin> (e.g. a local offline server) to render it`);
200
+ if (!source) {
201
+ const guess = path.resolve(ROOT, spec.replace(/^\/+/, "").replace(/\/+$/, ""));
202
+ if (guess.startsWith(ROOT) && (await exists(guess))) source = guess;
203
+ }
204
+ return { kind: "url", url: base.replace(/\/+$/, "") + spec, digestOf: source };
205
+ }
206
+ const abs = path.resolve(ROOT, spec);
207
+ if (!(await exists(abs))) throw new Error(`${label} "${spec}" does not exist`);
208
+ if (abs.endsWith(".png")) return { kind: "image", file: abs, digestOf: abs };
209
+ return { kind: "page", file: await entryFile(abs), digestOf: abs };
210
+ }
211
+
212
+ // ---------------------------------------------------------------- rendering
213
+
214
+ async function renderShot(browser, target, viewport) {
215
+ const page = await browser.newPage({ viewport, deviceScaleFactor: 1 });
216
+ try {
217
+ await page.emulateMedia({ reducedMotion: "reduce" });
218
+ const url = target.kind === "url" ? target.url : pathToFileURL(target.file).href;
219
+ await page.goto(url, { waitUntil: "load", timeout: 30000 });
220
+ // Everything that moves is noise in a still photograph. A CSS animation caught
221
+ // mid-frame is the classic source of a flaky screenshot verdict.
222
+ await page.addStyleTag({
223
+ content: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}",
224
+ });
225
+ await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
226
+ await page.waitForTimeout(250);
227
+ const buf = await page.screenshot({ clip: { x: 0, y: 0, ...viewport } });
228
+ if (!buf || !buf.length) throw new Error("empty screenshot");
229
+ return buf;
230
+ } finally {
231
+ await page.close().catch(() => {});
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------- the run
236
+
237
+ function pct(n) { return (n * 100).toFixed(2) + "%"; }
238
+
239
+ function printReport(r, { gate: g, unverifiable = [], threshold, manifestTotal }) {
240
+ const w = Math.max(9, ...r.rows.map((x) => x.id.length));
241
+ console.log("");
242
+ console.log("component".padEnd(w) + " diff verdict");
243
+ console.log("-".repeat(w) + " -------- -------");
244
+ for (const row of r.rows) {
245
+ const d = row.diffRatio == null ? " — " : pct(row.diffRatio).padStart(8);
246
+ const mark = row.state === "pass" ? "pass" : row.state === "incomplete" ? "not measured" : "FAIL";
247
+ console.log(row.id.padEnd(w) + " " + d + " " + mark + (row.reason ? " " + row.reason : ""));
248
+ }
249
+ console.log("");
250
+ console.log(`${r.total} components · ${r.passed} pass · ${r.failed} fail · ${r.incomplete} not measured`);
251
+ // The threshold is printed with the pass-rate, always. The number only means something
252
+ // next to the bar it was measured against, and a loosened bar is the cheapest way to
253
+ // turn a red run green — so it travels in the same line the caller reads.
254
+ console.log(`measured pass-rate ${pct(r.passRate)} at threshold ${pct(threshold)}`
255
+ + (g != null ? ` · gate ${pct(g)} → ${r.passRate >= g ? "MET" : "NOT MET"}` : ""));
256
+ if (manifestTotal != null && manifestTotal !== r.total) {
257
+ console.log(`FILTERED: ${r.total} of ${manifestTotal} components in the manifest were measured — this rate is not the canon's.`);
258
+ }
259
+ if (unverifiable.length) {
260
+ console.log(`\n${unverifiable.length} folder(s) carry no reference and were not verified: ${unverifiable.join(", ")}`);
261
+ console.log("A component nothing can be compared against is not a passing component.");
262
+ }
263
+ }
264
+
265
+ async function main() {
266
+ const manifest = await loadManifest();
267
+ const viewport = { width: manifest.viewport?.width ?? 1000, height: manifest.viewport?.height ?? 700 };
268
+ const threshold = manifest.threshold ?? DEFAULT_THRESHOLD;
269
+ const pixelTolerance = manifest.pixelTolerance ?? DEFAULT_PIXEL_TOLERANCE;
270
+ const fingerprint = settingsFingerprint({ viewport, threshold, pixelTolerance, base, v: 1 });
271
+
272
+ const manifestTotal = manifest.components.length;
273
+ let components = manifest.components;
274
+ if (only.length) components = components.filter((c) => only.includes(c.id));
275
+ if (!components.length) throw new Error("no components selected");
276
+
277
+ const runDir = path.join(ROOT, RUN_DIR);
278
+ const shotsDir = path.join(runDir, "shots");
279
+ const ledgerFile = path.join(runDir, LEDGER_NAME);
280
+ await fs.mkdir(shotsDir, { recursive: true });
281
+ if (restart) await fs.rm(ledgerFile, { force: true });
282
+
283
+ const { entries, torn } = await readLedger(ledgerFile);
284
+ if (torn) console.log(`ledger: ${torn} torn line(s) from an interrupted run, discarded`);
285
+
286
+ if (auditOnly) return audit(components, threshold, pixelTolerance, shotsDir, entries, manifest, manifestTotal);
287
+
288
+ const { chromium } = await import("playwright");
289
+ // ONE BROWSER FOR THE RUN, REPLACED IF IT DIES. A five-hundred-component night is long
290
+ // enough for a browser to be killed by the OS, run out of memory, or be caught by a
291
+ // laptop sleeping. Holding a single handle for the whole run means the first crash turns
292
+ // every remaining component into an error line; a lazily (re)launched one costs the same
293
+ // when nothing goes wrong and survives when something does.
294
+ let browser = null;
295
+ const browserNow = async () => {
296
+ if (browser && browser.isConnected()) return browser;
297
+ if (browser) {
298
+ console.log("browser: the previous one is gone — launching a replacement");
299
+ await browser.close().catch(() => {});
300
+ }
301
+ browser = await chromium.launch();
302
+ return browser;
303
+ };
304
+ let done = 0, skipped = 0, relaunched = 0;
305
+ const volatile = [];
306
+ try {
307
+ for (const c of components) {
308
+ const safe = c.id.replace(/[^a-z0-9]+/gi, "-");
309
+ let cand, ref;
310
+ try {
311
+ cand = await resolveTarget(c.candidate, `${c.id}: candidate`, c.candidateSource);
312
+ ref = await resolveTarget(c.reference, `${c.id}: reference`, c.referenceSource);
313
+ } catch (e) {
314
+ await appendLedger(ledgerFile, { id: c.id, fingerprint, diffRatio: 1, error: e.message, measuredAt: new Date().toISOString() });
315
+ entries.set(c.id, { id: c.id, fingerprint, diffRatio: 1, error: e.message });
316
+ console.log(`✗ ${c.id} — ${e.message}`);
317
+ done++;
318
+ continue;
319
+ }
320
+ // `null` where there is no source on disk — and null is what makes the entry
321
+ // volatile, never what makes it match. See `resolveTarget`.
322
+ const candidateDigest = cand.digestOf ? await contentDigest(cand.digestOf) : null;
323
+ const referenceDigest = ref.digestOf ? await contentDigest(ref.digestOf) : null;
324
+ if (!candidateDigest || !referenceDigest) volatile.push(c.id);
325
+
326
+ if (isFresh(entries.get(c.id), { candidateDigest, referenceDigest, fingerprint })) {
327
+ skipped++;
328
+ continue;
329
+ }
330
+
331
+ let record;
332
+ // Two attempts, and the second only ever happens because the BROWSER went away. A
333
+ // component that is genuinely unrenderable fails on the first attempt and is not
334
+ // retried — retrying a real fault just doubles the night.
335
+ for (let attempt = 1; attempt <= 2; attempt++) {
336
+ try {
337
+ const b = await browserNow();
338
+ const candBuf = cand.kind === "image" ? await fs.readFile(cand.file) : await renderShot(b, cand, viewport);
339
+ const refBuf = ref.kind === "image" ? await fs.readFile(ref.file) : await renderShot(b, ref, viewport);
340
+ await fs.writeFile(path.join(shotsDir, `${safe}.candidate.png`), candBuf);
341
+ await fs.writeFile(path.join(shotsDir, `${safe}.reference.png`), refBuf);
342
+ const m = compareImages(decodePng(candBuf), decodePng(refBuf), { pixelTolerance, withDiff: true });
343
+ if (m.diff && m.diffPixels) await fs.writeFile(path.join(shotsDir, `${safe}.diff.png`), encodePng(m.diff));
344
+ record = {
345
+ id: c.id, fingerprint, candidateDigest, referenceDigest,
346
+ diffRatio: m.diffRatio, diffPixels: m.diffPixels, pixels: m.pixels,
347
+ maxDelta: m.maxDelta, meanDelta: m.meanDelta,
348
+ sizeMismatch: m.sizeMismatch || undefined,
349
+ referenceSize: m.referenceSize, candidateSize: m.candidateSize,
350
+ candidateShot: sha256(candBuf).slice(0, 16), referenceShot: sha256(refBuf).slice(0, 16),
351
+ measuredAt: new Date().toISOString(),
352
+ };
353
+ break;
354
+ } catch (e) {
355
+ const msg = e.message.split("\n")[0];
356
+ if (attempt === 1 && !(browser && browser.isConnected())) {
357
+ relaunched++;
358
+ console.log(`… ${c.id} — ${msg} (the browser died; retrying this one with a fresh browser)`);
359
+ continue;
360
+ }
361
+ record = { id: c.id, fingerprint, candidateDigest, referenceDigest, diffRatio: 1, error: msg, measuredAt: new Date().toISOString() };
362
+ }
363
+ }
364
+ await appendLedger(ledgerFile, record);
365
+ entries.set(c.id, record);
366
+ done++;
367
+ const v = verdict(record, c.threshold ?? threshold);
368
+ console.log(`${v.pass ? "✓" : "✗"} ${c.id} ${record.error ? record.error : pct(record.diffRatio)}`);
369
+ }
370
+ } finally {
371
+ if (browser) await browser.close().catch(() => {});
372
+ }
373
+
374
+ const r = report(components, entries, threshold);
375
+ await fs.writeFile(path.join(runDir, "report.json"), JSON.stringify({
376
+ ranAt: new Date().toISOString(),
377
+ // Relative to where the run was started, never absolute: a report is a file people
378
+ // paste, and an absolute path carries the name of whoever ran it.
379
+ root: path.relative(process.cwd(), ROOT) || ".",
380
+ viewport, threshold, pixelTolerance, base: base || null,
381
+ manifestComponents: manifestTotal, filtered: r.total !== manifestTotal,
382
+ measuredNow: done, resumedFromLedger: skipped, browserRelaunches: relaunched,
383
+ volatile,
384
+ ...r,
385
+ }, null, 2));
386
+ if (skipped) console.log(`\nresumed: ${skipped} component(s) already measured in a previous run, ${done} measured now`);
387
+ if (relaunched) console.log(`browser: replaced ${relaunched} time(s) mid-run`);
388
+ if (volatile.length) {
389
+ console.log(`\n${volatile.length} component(s) render from a URL with nothing on disk behind it, so they are re-measured every`);
390
+ console.log(`run and never resumed: ${volatile.slice(0, 8).join(", ")}${volatile.length > 8 ? ", …" : ""}`);
391
+ console.log('Give them "candidateSource"/"referenceSource" in the manifest to make them resumable.');
392
+ }
393
+ printReport(r, { gate, unverifiable: manifest.unverifiable || [], threshold, manifestTotal });
394
+ console.log(`\nreport: ${path.join(RUN_DIR, "report.json")} · shots: ${path.join(RUN_DIR, "shots")}`);
395
+ finish(r);
396
+ }
397
+
398
+ // Re-derive every verdict from the SAVED SHOTS, with no browser and no trust in the
399
+ // ledger's arithmetic. This is what makes the record auditable rather than merely
400
+ // tamper-evident-in-principle: a line whose diffRatio was edited disagrees with the
401
+ // pixels it claims to describe, and this says so by name.
402
+ async function audit(components, threshold, pixelTolerance, shotsDir, entries, manifest, manifestTotal) {
403
+ const rebuilt = new Map();
404
+ const discrepancies = [];
405
+ for (const c of components) {
406
+ const safe = c.id.replace(/[^a-z0-9]+/gi, "-");
407
+ const cf = path.join(shotsDir, `${safe}.candidate.png`);
408
+ const rf = path.join(shotsDir, `${safe}.reference.png`);
409
+ if (!(await exists(cf)) || !(await exists(rf))) continue;
410
+ const candBuf = await fs.readFile(cf), refBuf = await fs.readFile(rf);
411
+ const m = compareImages(decodePng(candBuf), decodePng(refBuf), { pixelTolerance });
412
+ const claimed = entries.get(c.id);
413
+ rebuilt.set(c.id, { id: c.id, ...m, diff: undefined, measuredAt: claimed?.measuredAt ?? null, fingerprint: claimed?.fingerprint });
414
+ if (claimed && typeof claimed.diffRatio === "number" && Math.abs(claimed.diffRatio - m.diffRatio) > 1e-9) {
415
+ discrepancies.push(`${c.id}: ledger says ${pct(claimed.diffRatio)}, the shots say ${pct(m.diffRatio)}`);
416
+ }
417
+ if (claimed?.candidateShot && sha256(candBuf).slice(0, 16) !== claimed.candidateShot) {
418
+ discrepancies.push(`${c.id}: the saved candidate shot is not the one the ledger recorded`);
419
+ }
420
+ }
421
+ const r = report(components, rebuilt, threshold);
422
+ console.log(`audit — every verdict re-derived from ${rebuilt.size} saved shot pair(s), no browser, ledger numbers not trusted`);
423
+ printReport(r, { gate, unverifiable: manifest.unverifiable || [], threshold, manifestTotal });
424
+ if (discrepancies.length) {
425
+ console.log("\nLEDGER DISAGREES WITH THE PIXELS:");
426
+ for (const d of discrepancies) console.log(" " + d);
427
+ process.exitCode = 1;
428
+ return;
429
+ }
430
+ console.log("\nledger agrees with the pixels on every measured component");
431
+ finish(r);
432
+ }
433
+
434
+ // The exit code IS the answer, because the caller is an agent's shell loop and a printed
435
+ // table is not something a loop can read. With a gate, the gate alone decides — a canon at
436
+ // 99.4% of five hundred components is a canon that passed, and three stragglers are not a
437
+ // reason to report failure. Without one, any fail or any component the run never reached
438
+ // is a non-zero exit: silence is not a pass.
439
+ //
440
+ // It sets `process.exitCode` rather than calling `process.exit`, on purpose. An unattended
441
+ // run is read through a pipe (`refine … | tee`, an agent's captured stdout), and
442
+ // `process.exit` abandons whatever is still buffered on the way to that pipe — so the exit
443
+ // code and the report it is supposed to explain can disagree, and the table that vanished
444
+ // is the one a person would have needed. Letting the process end on its own flushes first.
445
+ function finish(r) {
446
+ if (gate != null) {
447
+ const met = r.passRate >= gate;
448
+ console.log(`\nrefine: pass-rate ${pct(r.passRate)} ${met ? "meets" : "is below"} the gate ${pct(gate)}`);
449
+ process.exitCode = met ? 0 : 1;
450
+ return;
451
+ }
452
+ process.exitCode = r.failed || r.incomplete ? 1 : 0;
453
+ }
454
+
455
+ main().catch((e) => { console.error("refine: " + e.message); process.exitCode = 2; });
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-drift — is anybody still cutting tags?
4
+ *
5
+ * WHY THIS EXISTS, AND WHY IT IS NOT HYGIENE. Every instance on `TRACK: release` follows
6
+ * the newest GitHub release, faithfully and forever. So when tagging stops, those
7
+ * instances do not break, do not warn, and do not fall behind visibly — they keep
8
+ * auto-updating to the same old tag and reporting themselves healthy. The failure is
9
+ * SILENT ON THE FOLLOWER and invisible on the publisher, which is the shape nobody
10
+ * notices without a clock.
11
+ *
12
+ * It has already happened twice. The release track was abandoned once because no tag had
13
+ * been cut since v0.9.0 and pins had drifted ~87 commits behind while looking fine.
14
+ * Tagging resumed at v0.11.0 and v0.12.0, then lapsed again.
15
+ *
16
+ * This is the supply-side guard, and it is deliberately on the ENGINE rather than in each
17
+ * shell: a shell can only see the tags that exist, so a shell-side check can never say
18
+ * "the tag you are following is the newest one AND it is four months old".
19
+ *
20
+ * WHAT IT DOES NOT DO. It does not cut a tag. Deciding that a set of commits is a release
21
+ * is a judgement about what changed and who it reaches, and an automatic tag would remove
22
+ * exactly the review that `TRACK: release` exists to provide.
23
+ *
24
+ * Run: node scripts/release-drift.mjs [--max-age-days N] [--max-commits N] [--json]
25
+ * Exit 1 when either ceiling is passed, 0 when clean, 2 when it could not tell.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
32
+
33
+ // The cadence, as two numbers. They are ceilings on DRIFT, not a promised schedule: a
34
+ // quiet fortnight with four commits is fine, and a busy afternoon with ninety is not.
35
+ const DEFAULT_MAX_AGE_DAYS = 21;
36
+ const DEFAULT_MAX_COMMITS = 60;
37
+
38
+ const arg = (name, fallback) => {
39
+ const i = process.argv.indexOf(name);
40
+ return i > -1 && process.argv[i + 1] ? Number(process.argv[i + 1]) : fallback;
41
+ };
42
+ const MAX_AGE_DAYS = arg("--max-age-days", DEFAULT_MAX_AGE_DAYS);
43
+ const MAX_COMMITS = arg("--max-commits", DEFAULT_MAX_COMMITS);
44
+ const asJson = process.argv.includes("--json");
45
+
46
+ const git = (...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8" }).trim();
47
+
48
+ // A RELEASE tag, not any tag. The repo also carries working tags (backup points before a
49
+ // rebase, for instance), and treating one of those as a release would report the drift as
50
+ // closed on a tag no self-hoster will ever be offered.
51
+ const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
52
+
53
+ let newest, tagIso, ahead;
54
+ try {
55
+ const tags = git("tag", "--sort=-creatordate").split("\n").map((t) => t.trim()).filter((t) => RELEASE_TAG.test(t));
56
+ if (!tags.length) {
57
+ const out = { ok: false, reason: "no release tag exists at all", newest: null };
58
+ console.log(asJson ? JSON.stringify(out) : `release-drift: NO RELEASE TAG EXISTS. Every instance on TRACK: release has nothing to follow.`);
59
+ process.exit(1);
60
+ }
61
+ newest = tags[0];
62
+ tagIso = git("log", "-1", "--format=%cI", newest);
63
+ ahead = Number(git("rev-list", "--count", `${newest}..HEAD`));
64
+ } catch (e) {
65
+ // A shallow clone has no tags and no history to count. Saying "clean" there would be a
66
+ // guard that reports success precisely when it cannot see.
67
+ console.error(`release-drift: could not read tags or history (${(e && e.message) || e}). This is not a pass.`);
68
+ process.exit(2);
69
+ }
70
+
71
+ const ageDays = Math.floor((Date.now() - Date.parse(tagIso)) / 86_400_000);
72
+ const findings = [];
73
+
74
+ // ── A TAG IS NOT WHAT A SELF-HOSTER FOLLOWS ──────────────────────────────────
75
+ //
76
+ // `engine-bump.yml` in release mode asks GitHub for the newest RELEASE and opens a pin PR
77
+ // against that. A tag with no release attached is therefore invisible to every instance on
78
+ // `TRACK: release` — and it silences this alarm, because everything above is measured from
79
+ // tags. That is the precise shape of the failure this file exists to catch, arriving
80
+ // through the file itself: main looks current, the newest tag looks fresh, and every
81
+ // self-hoster is still being offered the release before it.
82
+ //
83
+ // It is not hypothetical. Cutting v0.15.0 as a tag while the newest release was still
84
+ // v0.14.0 put the repo in exactly this state, and everything above reported OK.
85
+ //
86
+ // ⚠️ IT RUNS ONLY WITH A TOKEN, AND THAT IS DELIBERATE. This is the one part of this file
87
+ // that needs the network, and the rest of it — plus its whole test suite — must not. A
88
+ // script that reaches GitHub whenever it is executed is a script whose tests fail on a
89
+ // train, and one that can be made to fail by somebody else's outage.
90
+ //
91
+ // `release-drift.yml` always has `GITHUB_TOKEN`, so the scheduled run — the one that
92
+ // actually watches this — always checks. A local run without one says UNCHECKED in its own
93
+ // output rather than passing silently, which is the honest answer to "I could not look".
94
+ let releaseNote = "unchecked (no GITHUB_TOKEN — the scheduled run has one)";
95
+ if (process.env.GITHUB_TOKEN) {
96
+ try {
97
+ const repo = process.env.GITHUB_REPOSITORY || "andratwiro/augur";
98
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
99
+ headers: {
100
+ accept: "application/vnd.github+json",
101
+ "user-agent": "augur-release-drift",
102
+ authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
103
+ },
104
+ });
105
+ if (!res.ok) throw new Error(`GET releases/latest → ${res.status}`);
106
+ const latest = (await res.json()).tag_name;
107
+ releaseNote = latest;
108
+ if (latest !== newest) {
109
+ findings.push(
110
+ `the newest TAG is ${newest} but the newest RELEASE is ${latest} — ` +
111
+ `every instance on TRACK: release is still being offered ${latest}, because engine-bump reads releases, not tags`,
112
+ );
113
+ }
114
+ } catch (e) {
115
+ releaseNote = `unchecked (${(e && e.message) || e})`;
116
+ findings.push(
117
+ `could not confirm the newest RELEASE matches the newest tag: ${releaseNote}. ` +
118
+ `A tag with no release attached reaches nobody, and a token was present, so this is a failure to look rather than a decision not to.`,
119
+ );
120
+ }
121
+ }
122
+ if (ageDays > MAX_AGE_DAYS) {
123
+ findings.push(`no release cut in ${ageDays} days (ceiling ${MAX_AGE_DAYS}) — newest is ${newest}, from ${tagIso.slice(0, 10)}`);
124
+ }
125
+ if (ahead > MAX_COMMITS) {
126
+ findings.push(`main is ${ahead} commits ahead of ${newest} (ceiling ${MAX_COMMITS}) — a self-hoster on TRACK: release is running none of them`);
127
+ }
128
+
129
+ const result = { ok: !findings.length, newest, newestRelease: releaseNote, tagIso, ageDays, ahead, maxAgeDays: MAX_AGE_DAYS, maxCommits: MAX_COMMITS, findings };
130
+ if (asJson) { console.log(JSON.stringify(result, null, 2)); process.exit(findings.length ? 1 : 0); }
131
+
132
+ if (!findings.length) {
133
+ console.log(`release-drift: OK — ${newest} is ${ageDays}d old with ${ahead} commit(s) since, release: ${releaseNote} (ceilings: ${MAX_AGE_DAYS}d, ${MAX_COMMITS})`);
134
+ process.exit(0);
135
+ }
136
+ for (const f of findings) console.log(` ${f}`);
137
+ console.log("\nInstances on TRACK: release follow the newest tag faithfully and report themselves healthy while doing it, so this drift is invisible from their side. Cut a release, or move the ceilings deliberately.");
138
+ process.exit(1);