From 74a8f85f49905597ac982d18c5eef0301533f60a Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:07:05 +0200 Subject: [PATCH 1/3] docs: record the five handovers that were confidently wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo already documents ways a TEST reported green while proving nothing. This week produced a different and more expensive shape: briefs that told an agent something false with enough confidence to be taken on trust. Five in one week, all mine. The Nix floor is the clearest. A 44-second figure was handed over as fact and had already propagated into five files as the justification for design decisions; measured, it is 4.6-5.0 s and clean-vs-dirty is not the variable. It had been real once — during a crash loop fixed hours earlier. A wrong number stated once became load-bearing in four other files within a day. The others: git-lfs framed as that floor's fix (it makes `git status` truthful and changes no timing); a vacuity guard removed on the theory that a no-op preview lists nothing, which made the two assertions after it pass instantly against a page that never rendered; "the devcontainer can drive k3d", which mounts no Docker socket; and a liveness-probe consequence that stopped being true when the landing page learned to catch its own gRPC failures. Every one was caught because the brief said VERIFY THIS rather than transcribe it, and named what would count as disproof. The failure mode is not a lie — it is a true-once observation restated after its context moved, which is exactly the shape nobody re-checks. Hence the two habits recorded with it: attribute a number to the run that produced it, and when handing over a diagnosis, hand over the measurement that would falsify it. --- .claude/CLAUDE.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3e8b1aa8..354818e9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -504,6 +504,43 @@ fixes the only ones with no hit are the ones that are DATA (a hackathon name, a persona, an `aria-label` template, organiser-authored form fields). Run that before a journey rather than paying a full run per red. +## Ways a HANDOVER was confidently wrong + +Distinct from the section below, and it cost more. Those are tests that agreed +with a broken product; these are briefs that told an agent something false with +enough confidence that it could have been taken on trust. Five in one week, all +mine, all caught only because the brief said "verify this before acting on it" +and the agent did: + +- **A 44-second Nix floor that did not exist.** Handed over as fact and already + propagated into five files as the justification for design decisions. + Measured: 4.6-5.0 s steady state, and clean-vs-dirty is not the variable. The + 44 s had been measured during a frontend crash loop that was fixed hours + earlier. A wrong number stated once became load-bearing in four other files + within a day. +- **git-lfs framed as the fix for that floor.** It makes `git status` truthful, + which is worth having; it does not change the timing at all. The agent A/B'd + it before touching the Dockerfile and said so. +- **A vacuity guard removed on a wrong theory.** "A no-op preview lists nothing" + — except the table renders every planned row including unchanged ones. + Deleting the wait made the two assertions after it pass instantly against a + page that never rendered. I introduced two silent-green assertions while + explaining why I was right. +- **"The devcontainer can drive k3d."** It mounts no Docker socket and has no + docker CLI. The agent found out in its first minute and rewrote that half of + the brief. +- **A stale consequence stated as current.** "A backend outage restarts every + frontend pod" was true before the landing page learned to catch its own gRPC + failures. The cost is real (the probe issues up to five calls per pod every 15 + s) but the consequence had changed. + +What made the difference every time was a brief that said **verify this rather +than transcribe it**, and named what would count as disproof. The failure mode +is not a lie — it is a true-once observation restated after its context moved, +which is exactly the shape nobody re-checks. Two habits follow: attribute a +number to the run that produced it, and when handing over a diagnosis, hand over +the measurement that would falsify it. + ## Ways a test reported green while proving nothing The most expensive category of bug here, because nothing turns red. All of these From abb7f921fb609ec96a9f418bbe16d564b1e4dfb9 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:28:28 +0200 Subject: [PATCH 2/3] feat(frontend): a welcoming social card, and a script that can rebuild it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The premise needed correcting first: og-default.jpg was already a branded typographic card, not the stock photo I assumed. But dbbc8c81's own message says it was "rendered once with satori + resvg-js in a throwaway script, not a project dependency" — so the real gap was reproducibility, not design. The template and build script now live in the repo and print dimensions, size, alpha, headline fill and grid contrast on every run. Kept the established design language rather than restyling: static/social/ {banner,square,story}.png are the same design at three other sizes, and changing only the OG card would desync the set. The SDSC mark is placed and scaled, never restyled — it is a real organisation's identity. What changed is the words and one repetition. The mark carried a "Hackathons" lockup that the headline then repeated, so it is the bare mark now, as the app's own footer uses it. The subline was a feature list — "Propose projects, form teams, and build solutions together" — and is now an invitation: "Bring an idea, or join one. Then build it together." Set in sans, per the theme's rule that mono carries labels and sans carries sentences; an invitation in a typewriter face reads as a terminal banner. Event pages pass NO image to , so this card is literally what appears when a hackathon link is shared — the title carries the event, so the image carries the platform. ## Two bugs found while building it My own headline check was VACUOUS: it measured 1056px, which is exactly 1200 − 144 of padding, because #headline is a block. It was asserting on the padding and would have passed in any font. It measures the text itself through a Range over the element's contents now (882px, 83% of the content box). And `oklch()` colour stops render as NOTHING inside repeating-linear-gradient in Playwright's Firefox — no error, no console message, and getComputedStyle happily reports the fully-resolved gradient. The grid was invisible in the first two renders and was found only by sampling pixels out of the PNG. The template uses each token's measured sRGB with the oklch source in a comment, and the renderer asserts the grid's 60px period IN THE FINISHED JPEG, because a wash can render and still be destroyed by the downscale or the quantiser. 1200×630 exactly, opaque, 56.8 KiB, identical md5 across runs. The lfs clean filter's pointer oid and size match the file's sha256, and the file starts ff d8 — a real content change, not a pointer mismatch. og:image resolves absolute at runtime, which matters because most clients ignore a relative one. Playwright and sharp are borrowed from the e2e skill's node_modules at render time rather than added to the frontend's package.json — no dependency or lockfile change for a build-time-only tool. Note the running :8081 server still serves the old card: build/ snapshots static/ at build time, and it was left alone deliberately because two servers have been running off it for hours and a concurrent agent depends on them. It needs a rebuild and restart to serve this. --- components/frontend/design/og/og-card.html | 224 ++++++++++++++++ components/frontend/design/og/render.mjs | 290 +++++++++++++++++++++ components/frontend/static/og-default.jpg | 4 +- 3 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 components/frontend/design/og/og-card.html create mode 100644 components/frontend/design/og/render.mjs diff --git a/components/frontend/design/og/og-card.html b/components/frontend/design/og/og-card.html new file mode 100644 index 00000000..426ec666 --- /dev/null +++ b/components/frontend/design/og/og-card.html @@ -0,0 +1,224 @@ + + + + + SDSC Hackathons — link preview card + + + +
+ SDSC + +
+
+

SDSC Hackathons

+

Bring an idea, or join one. Then build it together.

+
+ + +
+ + diff --git a/components/frontend/design/og/render.mjs b/components/frontend/design/og/render.mjs new file mode 100644 index 00000000..a2105272 --- /dev/null +++ b/components/frontend/design/og/render.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// Render og-card.html into static/og-default.jpg — the image every link +// preview of this platform shows. +// +// Why a script and not a hand-exported binary: the card that was here before +// was produced once by a throwaway satori script, so the only way to change a +// word in it was to rebuild the tooling from the commit message. The template +// beside this file is the source; this turns it into the asset. +// +// node components/frontend/design/og/render.mjs +// node components/frontend/design/og/render.mjs --thumb /tmp/thumb.png +// +// Rendered at 2x and downscaled, because type rasterised at 1200px wide and +// type rasterised at 2400px and resampled are visibly different at the sizes +// this card uses — the second is what survives a feed thumbnail. +// +// Playwright and sharp are borrowed from .claude/skills/hackathon-e2e rather +// than added to the frontend's package.json: this asset is regenerated when +// the wording changes, and that is not worth ~200 MB of devDependency and a +// lockfile entry in the app that serves it. + +import { createRequire } from "node:module" +import { existsSync, statSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const FRONTEND = resolve(HERE, "../..") +const ROOT = resolve(FRONTEND, "../..") + +const TEMPLATE = join(HERE, "og-card.html") +const MARK = join(FRONTEND, "static/logos/sdsc_white.svg") + +const WIDTH = 1200 +const HEIGHT = 630 +const SCALE = 2 + +// --hk-canvas, the theme's dark ground, as sRGB. The same value the template +// paints with: a preview card is composited onto whatever colour the client +// uses, so it has to carry its own opaque background rather than inherit one. +const CANVAS = "#0c1212" + +// A preview card is fetched by a scraper on someone else's schedule, and some +// of them give up on a slow one. Comfortably under the brief's ceiling. +const MAX_BYTES = 300 * 1024 + +// The headline is the only thing that has to read at thumbnail size, so how +// much of the card it fills is the one number worth failing on: a stack that +// falls through to this container's CJK generic, or a font that is simply +// absent, lands outside this band — and would otherwise render a perfectly +// plausible-looking card in the wrong typeface with no error at all. +// +// Expressed as a fraction of the content box, not in pixels, so editing the +// wording or the type size does not require re-deriving a magic number. The +// upper bound is what keeps the line off the margins; the lower bound is what +// keeps it dominant enough to survive being drawn ~300px wide. +const HEADLINE_MIN_FILL = 0.55 +const HEADLINE_MAX_FILL = 0.98 +const HEADLINE_MAX_LINE_HEIGHT = 140 + +// The grid behind the card is drawn every GRID_PX, and is checked for rather +// than assumed. It went missing twice while this card was being built — an +// oklch() colour stop in a repeating-linear-gradient renders as nothing at all +// in this Firefox, with a valid computed style and an empty console — and a +// wash that quietly disappears looks exactly like a wash that was never asked +// for. GRID_ROW is a band with no text in it at any point across the card. +const GRID_PX = 60 +const GRID_ROW = 470 +const GRID_MIN_CONTRAST = 5 +const GRID_MIN_LINES = 8 + +function parseArgs(argv) { + const args = { out: join(FRONTEND, "static/og-default.jpg"), thumb: null } + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--out") { + args.out = resolve(argv[(i += 1)]) + } else if (argv[i] === "--thumb") { + args.thumb = resolve(argv[(i += 1)]) + } else { + throw new Error(`unknown argument: ${argv[i]}`) + } + } + return args +} + +// Resolve a dependency from whichever node_modules in this repo has it. +function borrow(name) { + const dirs = [ + join(ROOT, ".claude/skills/hackathon-e2e/node_modules"), + join(FRONTEND, "node_modules"), + ] + for (const dir of dirs) { + try { + return createRequire(join(dir, "borrow.cjs"))(name) + } catch { + continue + } + } + throw new Error( + `${name} not installed. Run 'pnpm install' in ` + + `.claude/skills/hackathon-e2e, which owns this toolchain.`, + ) +} + +// Read one row out of the FINISHED jpeg and prove the grid survived into it. +// Deliberately the finished file and not the browser's PNG: a wash can render +// and still be destroyed by the downscale or the JPEG quantiser, and the file +// is what a scraper fetches. Returns the detected line spacing. +async function assertGridVisible(sharp, file) { + const { data, info } = await sharp(file) + .raw() + .toBuffer({ resolveWithObject: true }) + const row = [] + for (let x = 0; x < info.width; x += 1) { + row.push(data[(GRID_ROW * info.width + x) * info.channels]) + } + + const min = Math.min(...row) + const max = Math.max(...row) + if (max - min < GRID_MIN_CONTRAST) { + throw new Error( + `no grid in the output: row ${GRID_ROW} varies by only ${max - min} ` + + `levels. An oklch() stop in a repeating-linear-gradient is the usual ` + + `cause — see the warning in og-card.html.`, + ) + } + + // Collect runs of above-midpoint pixels; each run is one grid line. + const threshold = min + (max - min) / 2 + const centres = [] + let run = null + row.forEach((v, x) => { + if (v >= threshold) { + run = run ?? { start: x } + run.end = x + } else if (run) { + centres.push((run.start + run.end) / 2) + run = null + } + }) + if (run) centres.push((run.start + run.end) / 2) + + if (centres.length < GRID_MIN_LINES) { + throw new Error( + `found ${centres.length} grid lines in row ${GRID_ROW}, expected at ` + + `least ${GRID_MIN_LINES} — the band may have drifted onto text`, + ) + } + + const gaps = centres.slice(1).map((c, i) => c - centres[i]) + gaps.sort((a, b) => a - b) + const spacing = gaps[Math.floor(gaps.length / 2)] + if (Math.abs(spacing - GRID_PX) > 2) { + throw new Error( + `grid lines are ${spacing}px apart, expected ${GRID_PX}px — that is ` + + `not the grid, so something else is being measured`, + ) + } + return { spacing, lines: centres.length, contrast: max - min } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + for (const [what, path] of [ + ["template", TEMPLATE], + ["SDSC mark", MARK], + ]) { + if (!existsSync(path)) throw new Error(`missing ${what}: ${path}`) + } + + const { firefox } = borrow("@playwright/test") + const sharp = borrow("sharp") + + const browser = await firefox.launch() + let png + let measured + try { + const page = await browser.newPage({ + viewport: { width: WIDTH, height: HEIGHT }, + deviceScaleFactor: SCALE, + }) + await page.goto(pathToFileURL(TEMPLATE).href, { waitUntil: "load" }) + + // The mark is an ; screenshotting before it decodes yields a card + // with a hole where the logo goes, and nothing about that fails loudly. + await page.waitForFunction(() => { + const img = document.querySelector("#mark") + return img && img.complete && img.naturalWidth > 0 + }) + + measured = await page.evaluate(() => { + const cardEl = document.querySelector("#card") + const headlineEl = document.querySelector("#headline") + const card = cardEl.getBoundingClientRect() + + // Measure the TEXT, via a Range over the element's contents — not the + // element. #headline is a block, so its own rect is the width of the + // column it sits in: 1056px whatever font rendered, whatever the words + // say. Asserting on that number is asserting on the padding. + const range = document.createRange() + range.selectNodeContents(headlineEl) + const text = range.getBoundingClientRect() + + return { + card: { width: card.width, height: card.height }, + // The box the headline is allowed to fill, from the element's own + // layout rather than a repeated padding constant. + contentWidth: headlineEl.getBoundingClientRect().width, + headline: { width: text.width, height: text.height, right: text.right }, + } + }) + + png = await page.locator("#card").screenshot({ type: "png" }) + } finally { + await browser.close() + } + + const { card, contentWidth, headline } = measured + const fill = headline.width / contentWidth + + if (card.width !== WIDTH || card.height !== HEIGHT) { + throw new Error( + `template laid out at ${card.width}x${card.height}, expected ` + + `${WIDTH}x${HEIGHT}`, + ) + } + if (fill < HEADLINE_MIN_FILL || fill > HEADLINE_MAX_FILL) { + throw new Error( + `headline fills ${(fill * 100).toFixed(1)}% of the content box ` + + `(allowed ${HEADLINE_MIN_FILL * 100}–${HEADLINE_MAX_FILL * 100}%) — ` + + `the intended font probably did not load, or the wording changed`, + ) + } + if (headline.height > HEADLINE_MAX_LINE_HEIGHT) { + throw new Error( + `headline wrapped to ${Math.round(headline.height)}px tall — it must ` + + `stay on one line`, + ) + } + if (headline.right > WIDTH) { + throw new Error( + `headline overflows the card by ${headline.right - WIDTH}px`, + ) + } + + // flatten() drops any alpha onto the card's own ground rather than letting + // the JPEG encoder pick: the card is composited onto whatever colour the + // client uses, so it has to carry its own background. + await sharp(png) + .resize(WIDTH, HEIGHT, { kernel: "lanczos3" }) + .flatten({ background: CANVAS }) + .jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: true }) + .toFile(args.out) + + const meta = await sharp(args.out).metadata() + const bytes = statSync(args.out).size + + if (meta.width !== WIDTH || meta.height !== HEIGHT) { + throw new Error( + `wrote ${meta.width}x${meta.height}, expected ${WIDTH}x${HEIGHT}`, + ) + } + if (meta.hasAlpha) throw new Error("output carries an alpha channel") + if (bytes > MAX_BYTES) { + throw new Error(`${bytes} bytes exceeds the ${MAX_BYTES} byte ceiling`) + } + + const grid = await assertGridVisible(sharp, args.out) + + if (args.thumb) { + // The check that matters: most clients draw this around 300px wide. + await sharp(args.out).resize(300).toFile(args.thumb) + } + + const kib = (bytes / 1024).toFixed(1) + process.stdout.write( + `${args.out}\n` + + ` ${meta.width}x${meta.height} ${kib} KiB ${meta.format} ` + + `alpha=${Boolean(meta.hasAlpha)}\n` + + ` headline text ${Math.round(headline.width)}px wide ` + + `(${(fill * 100).toFixed(0)}% of the content box), rendered at ` + + `${SCALE}x\n` + + ` grid ${grid.lines} lines at ${grid.spacing}px, ` + + `${grid.contrast} levels of contrast\n` + + (args.thumb ? ` thumbnail: ${args.thumb}\n` : ""), + ) +} + +await main() diff --git a/components/frontend/static/og-default.jpg b/components/frontend/static/og-default.jpg index cb52ee82..09b26cd9 100644 --- a/components/frontend/static/og-default.jpg +++ b/components/frontend/static/og-default.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e127edfd50e88be88caf5fbec5beb75a5070f45e895ae8349df04d1d92b4d2d -size 90689 +oid sha256:00eb530a63ba1d0daaf1f2d2622d2d06d0cb85ab3f03cfdb86bd0c2920d58de6 +size 58130 From 0fc67a8f664a20eacfbf2f87cd456b404d8ee6e0 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:43:23 +0200 Subject: [PATCH 3/3] fix(e2e): validate the player before writing it, and refresh the mutation record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `embed-run-report.mjs` had the same bug `build-quality-report.mjs` was fixed for in 226d2016: it wrote `recipe-player.html` and only then ran its read-back, spec-count and close-tag checks. A bad splice corrupted the player and the validator reported it afterwards — the corrupt file existed either way. Same treatment, reusing that file's approach rather than inventing a second one: assemble in memory, run every check against the string, and only on a clean pass write a same-directory temp, fsync, rename (retrying EPERM — 9p, container trap 5), then re-read the destination and throw unless it matches. Note the invariant differs by file: the player asserts THREE literal close-script tags, the quality report asserts one. Both now say "checked first, then written" on success. ## The mutation record is current again `verify.json` held 23 EXACT / 11 GAP from 2026-08-13, two days behind a manifest that declares 37 EXACT / 1 GAP — so the quality report was printing a derived callout about its own stale input. Re-run: **38 run, 36 exact, 1 known gap, 0 untested properties, 1 other failure.** That confirms from a fresh run what the manifest had only claimed: the eleven gaps closed by the 28 backend specs are genuinely closed, and no property is untested. `EXIT=1` is the runner working — any non-EXACT verdict fails the run, which is what stops a quiet regression sliding past. The single other failure is the known shape rather than a new one: `capacity.oversell-by-one`'s witness is a declared-flaky concurrency spec, so it can come back MISMATCH when the flake fires rather than when anything is wrong. A second run was started to separate those two; the record here is the first run's, and the entry stays listed as flaky-witnessed rather than quietly excused. Journal empty and `components/` clean afterwards, verified — a mutation left applied and then committed is the worst thing this tooling can do, and it is why nothing was staged while the sweep was running. --- .claude/skills/hackathon-e2e/.gitignore | 8 +- .../skills/hackathon-e2e/mutations/run.mjs | 43 +++++- .../skills/hackathon-e2e/quality-report.html | 55 ++------ .../scripts/embed-run-report.mjs | 132 +++++++++++++++--- 4 files changed, 177 insertions(+), 61 deletions(-) diff --git a/.claude/skills/hackathon-e2e/.gitignore b/.claude/skills/hackathon-e2e/.gitignore index e4be43da..4da3b954 100644 --- a/.claude/skills/hackathon-e2e/.gitignore +++ b/.claude/skills/hackathon-e2e/.gitignore @@ -3,7 +3,9 @@ node_modules/ .artifacts/ test-results/ playwright-report/ -# build-quality-report.mjs stages the report beside itself and renames it into -# place, so this only exists while a build is in flight — or after one was -# killed mid-write, which is exactly when it must not read as a tracked change. +# build-quality-report.mjs and embed-run-report.mjs stage their output beside +# itself and rename it into place, so these only exist while a build is in +# flight — or after one was killed mid-write, which is exactly when they must +# not read as a tracked change. .quality-report.html.tmp-* +.recipe-player.html.tmp-* diff --git a/.claude/skills/hackathon-e2e/mutations/run.mjs b/.claude/skills/hackathon-e2e/mutations/run.mjs index 61368302..567b11b4 100644 --- a/.claude/skills/hackathon-e2e/mutations/run.mjs +++ b/.claude/skills/hackathon-e2e/mutations/run.mjs @@ -201,6 +201,47 @@ function apply(mut) { }) } +/** + * Put one backup back, retrying the 9p refusal — and never by COPYING. + * + * `fs.copyFileSync` on this repo's bind mount intermittently answers + * `EPERM: operation not permitted, copyfile` with nothing holding either file: + * the same refusal container trap 5 documents for a rename, and the same one + * `writeChecked` in build-quality-report.mjs already retries around. Here it was + * far worse than a failed restore, because **Node removes the destination when a + * copy fails after opening it** — so the file being restored was DELETED, the + * journal still claimed the mutation was applied, and every later entry aborted + * on `assertCleanTree`. Observed three times in one afternoon (2026-08-15), at + * entries 1, 7 and 9 of three separate runs; each run lost every entry after it. + * + * Two changes, for two different halves of that: + * - read-then-write instead of copyfile. A write can leave the destination + * wrong, but it cannot unlink it, so the worst case stops being "the source + * file is gone" — which is the only outcome nothing downstream can undo. + * - retry the EPERM, because it is transient (the very next restore of the + * same file succeeded, every time). + * The bytes are read back and compared, because a restore this tool did not + * verify is exactly what "restoration is verified, not assumed" is about. + */ +function restoreFile(backup, abs) { + const bytes = fs.readFileSync(backup) + const sleep = (ms) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + for (let attempt = 1; ; attempt++) { + try { + fs.writeFileSync(abs, bytes) + break + } catch (e) { + if (attempt >= 5 || e.code !== "EPERM") throw e + sleep(250) + } + } + if (!fs.readFileSync(abs).equals(bytes)) + throw new Error( + `restore: ${abs} does not match its backup after being written back`, + ) +} + /** * Undo everything the journal records, NEWEST FIRST. * @@ -225,7 +266,7 @@ function restore({ quiet = false } = {}) { spawnSync("git", ["checkout", "--", e.file], { cwd: ROOT, env: ENV }) continue } - fs.copyFileSync(e.backup, path.join(ROOT, e.file)) + restoreFile(e.backup, path.join(ROOT, e.file)) if (!quiet) console.log(C.dim(` restored ${e.file} (${e.id})`)) } writeJournal([]) diff --git a/.claude/skills/hackathon-e2e/quality-report.html b/.claude/skills/hackathon-e2e/quality-report.html index c2f34981..d08f0380 100644 --- a/.claude/skills/hackathon-e2e/quality-report.html +++ b/.claude/skills/hackathon-e2e/quality-report.html @@ -186,7 +186,7 @@
Hackagon · e2e quality report
-
sketch/06-08-26 @ ea66ff97 · 2026-08-15
+
chore/loose-ends @ abb7f921 · 2026-08-15