Skip to content

Make the monorepo the product - #1

Merged
Gakshith merged 27 commits into
mainfrom
feat/mvp
Aug 3, 2026
Merged

Gakshith merged 27 commits into
mainfrom
feat/mvp

Conversation

@Gakshith

@Gakshith Gakshith commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Phases 0-9 of the macOS MVP, cut from the Swift app that main currently holds.

packages/format (six-stage validator, deterministic packer) and
packages/renderer (imported by both the site and the overlay, which is what
makes fidelity structural rather than a QA effort), apps/web (the gallery),
apps/desktop (the Tauri overlay), apps/relay (the GitHub token exchange,
the only server code in the product), and registry/ with its build gate.

The Swift app moves to apps/macos-swift/ and still builds; it is the
reference implementation.

./scripts/verify.sh is green on 9 stages.

GOJURU AKSHITH and others added 26 commits July 29, 2026 19:50
Rename the user-facing product name from "Butterfly Agent Notifier" to
"Agent Notifier" across the app bundle, menu bar, Settings window, README,
Info.plist, packaging/install scripts, and companion manifest authors. The
product now ships butterfly and firecracker companions, so the butterfly-only
name was stale. Butterfly-animation assets and internal Swift module names are
intentionally left unchanged.

Fix the README clone URL to the renamed repository (agent-notifier).

Remove internal assistant tooling from the public repo and gitignore it:
.claude/, CLAUDE.md, and the docs/superpowers/ design notes.
The website preview and the desktop overlay must run the same renderer
module, and two repositories cannot do that without publishing and
versioning a package, which guarantees drift. So the gallery, the shared
renderer and the format validator move in here alongside the app.

The Swift app moves wholesale to apps/macos-swift/ and keeps building as
the reference implementation. It is not maintained as a product and is
not released from this branch.

Brings PRODUCT-SPEC.md and ENGINEERING-DESIGN.md over from the
agent-notifier-companions repository, which this supersedes.

scripts/verify.sh runs swift build as well as the core smoke: the smoke
compiles ButterflyCore only, so it stayed green while a stray sync
duplicate gave the executable target two top-level entry points and
broke the build.
ENGINEERING-DESIGN §4 asserts six things about the macOS overlay that came
from research and had never been observed on this machine. Proving them
before phase 5 is much cheaper than discovering one of them is false after
the renderer and the site have been built on top.

Five hold: transparency under macOSPrivateApi, undecorated windows,
always-on-top over a fullscreen app, ActivationPolicy::Accessory keeping the
app out of the Dock, and one window per display. The click-through mechanism
holds too, verified by a self-test that moves a window under the cursor
rather than waiting for the user to move the cursor over a window. A click
physically passing through to the app underneath is still unproven and needs
a real click.

The spike earned its keep by finding that Tauri reports multi-display
geometry in at least three coordinate spaces while typing all of them
Physical*: monitor.position is logical, monitor.size is physical, and
cursor_position is logical scaled by the PRIMARY display's factor. The hit
test missed every time until everything was converted to logical.

A gap remains: outer_position does not round-trip set_position, so windows
land on the right display at the wrong offset within it. Recorded in the
code and in the spec as phase 5 work.
Every validation stage throws this, so it lands before any of them. A
failure that cannot name the stage it came from and the file that caused it
gives a creator nothing to act on, and PRODUCT-SPEC A-1 calls that a
specification failure rather than a rough edge.
Field rules copied from ENGINEERING-DESIGN section 6 rather than
reinterpreted.

Two rules exist because of past bugs and not because of taste: hovering is
rejected because it never existed but a validator once accepted it and two
companions declared it, and an authored distribution is rejected because it
is derived from the id prefix, so writing it is a way to claim builtin
status without earning it.

Text fields are stripped of control characters before they are measured. The
other order lets a field of nothing but control characters pass as non-empty,
and lets control padding smuggle a value past its length limit.

The control-character class and the test fixtures are both built from
character codes rather than written as literals, because the literals were
silently eaten twice while writing this.
Stages 1 and 2, both security stages, so the central directory is read
directly rather than through a library. The declared sizes have to be
inspected BEFORE anything is decompressed, which is the only order in which
a zip bomb can be refused rather than survived, and a library hides that
distinction behind an API that has already decompressed.

Backslashes are rejected before the path is split on '/', because Windows
resolves a backslash as a separator and a traversal written that way would
otherwise sail past a check that only ever looked at slash-separated
segments.

Totals are checked before per-entry compression ratios so an archive that
breaks both reports the cap it broke rather than an incidental ratio.

Hostile archives are built in memory by a test helper rather than committed
as binaries: a committed malicious ZIP is awkward to review, easy to mistake
for a real asset, and hides the property under test.
Invariant F3. Digests are required to be lowercase rather than normalised on
read, so every comparison in the pipeline can be plain string equality and no
caller has to remember to fold case.

The hex conversion pads each byte. Without that a byte below 0x10 renders as
one character and the digest silently comes out short for roughly one input
in sixteen, which a test now pins.

checksums.json is written with sorted keys so regenerating it produces no
diff when nothing changed.
Stage 6, and a security stage. A declared-versus-actual mismatch is exactly
what a hostile image exploits, so the IDAT stream is inflated and its length
checked against the dimensions IHDR claims, in both directions. Not a full
pixel decode and not meant to be: the property that matters is that a frame
cannot claim one size and carry another, which is what makes it safe for
anything downstream to trust the header.

Non-RGBA frames are refused because an opaque image renders as a rectangle
over the user's work rather than as a companion.

The test helper deflates a real IDAT payload rather than stubbing one, since
the validator inflates it and a stub would only test the stub.
The order is the security property, so it is asserted rather than assumed.
Five tests build archives that are broken at several stages at once and
require the earlier stage to be the one reported. Without them the order
could drift during a refactor and nothing would fail.

The sharpest of the five is a frame that is both the wrong size and carries
the wrong digest: the digest is cheaper to check, so checksums must be what
is reported, not frame-decode.
The convention is one command that decides pass or fail. Until the package is
in it, a green run says nothing about whether the format still validates.
Three properties make the output byte-for-byte identical across runs, and a
published archive digest rests on all three: entries are written in sorted
order rather than map insertion order, timestamps are fixed rather than taken
from the clock, and everything is stored rather than deflated so the bytes
cannot vary with a compression implementation or level. Storing costs little
because PNG frames barely compress.

Without reproducibility the digest would change on every rebuild and silently
break every install of that companion, which is exactly the failure the design
forbids by refusing hand-maintained digests.

checksums.json is generated here rather than authored, which is F3, and frame
bytes are copied verbatim and never re-encoded, which is F1. Both are pinned
by tests rather than left as intentions.

crc32 now has one implementation, imported by the test helper instead of
duplicated there.
Recovers the Tabby Cat from c97a4fb rather than redrawing it: 24 frames at
256x256, plus the procedural generators that produced them, which now live in
tools/artwork. The pack that was already published validates clean against the
new six-stage validator, which is the first check of that work against real
data rather than fixtures.

The gate reads companions/, validates each one with the same validator the
browser and CI run, and writes pack.zip, its digest and index.json. It
validates what will be PUBLISHED rather than what was read, so a packer bug
has to fail here instead of at a user's shell.

Two decisions worth stating.

An archive URL is omitted rather than degraded when there is no production
https origin at build time. The install path refuses non-https, so a link that
cannot work must be absent, not present and broken.

--check compares index.json alone. dist/ is generated and not committed, so a
digest file on disk proves nothing about the repository, whereas index.json is
committed and carries every archive digest -- which means a frame changed
without a rebuild shows up there and nowhere else. verify.sh runs it.

A licence is a build failure, not a warning: section 9 makes a fully
licence-clean catalogue a launch metric, and the SPDX line in each LICENSE file
is what populates the licence shown on a companion's page.

Invariants F1 and F2 are pinned against the real pack rather than a fixture:
all 24 source frames survive the pipeline byte-identically, and a fingerprint
of companions/ taken before the build is compared after it.
This is the module the website preview and the desktop overlay both call. Not
a shared spec and not a port -- the same file, which is what makes the fidelity
promise structural rather than a QA effort.

Every state carries two independent channels, because colour alone cannot
deliver the product's central claim. The suite proves that rather than
assuming it: simulating protanopia and deuteranopia over the Tabby Cat's
ginger coat collapses the distance between `failed` red and `input-needed`
amber from 34 to about 14.5, and those are precisely the two states a
developer most needs to tell apart. The motion channel is therefore load
bearing, and a test requires every pair of states to differ in at least one
trait that survives greyscale -- whether it repeats, pulses, reverses
direction, or fades out.

The tint is asserted against a reference blend written out longhand from the
design rather than derived from the code under test, and the feColorMatrix the
browser runs is checked to agree with that arithmetic for every state and
sample. That agreement is what F7 rests on: a creator approves a preview, and
the preview has to be what ships.

A filter is used rather than a CSS mask because a filter applies to the
rendered element, so every frame is tinted and the sequence keeps animating; a
mask freezes it on frame one. sRGB interpolation is set explicitly because the
SVG default of linearRGB mixes in the wrong colour space and is visibly off.

The document is injected rather than reached for, and a test running in the
node environment imports the whole public surface with no DOM present to prove
it. A module-level `document` would break the Next.js static export, and the
failure would surface in a different package rather than here.

Under reduced motion the sequence is never started rather than paused, since
an animated image cannot be paused by CSS, and a labelled glyph takes over as
the second channel.
The install prompt is the sharpest edge in the product: a user copies it and
pastes it into an agent that has shell access on their machine. It therefore
takes only machine-constrained values -- an id matching the published pattern,
an absolute https URL, a 64-character lowercase digest, and one of three known
operating systems -- and creator text is not a parameter at all.

Checking the scheme alone would not be enough, because
'https://host/x.zip; rm -rf /' is a perfectly good-looking https URL, so the
URL must also match a character set that cannot change the meaning of the
shell line it lands in. Tests push shell metacharacters, newlines, command
substitution and prompt-injection text through every field and require either
a rejection or an output that does not contain them.

The digest is verified before anything is unpacked, and the test asserts that
ORDER rather than merely the presence of both steps: reversing them would
unpack a tampered archive and only then notice.

Facets are derived from the manifests and never authored, with a test
requiring every count to equal the number of companions it filters to. An
authored count is a bug waiting for its first edit.

The data seam is pinned: loading the index with an explicit path must not
touch the network, or a stray environment variable could redirect a
production build at a live service.
Landing, browse and detail as a Next.js static export over the registry, with
the four-state switcher rendered by the same module the overlay will use.

The palette is derived from the product rather than picked from a generic
dark-mode set: warm ink with the companion's own input-needed amber as the
accent, because attention is what this product sells. The four state colours
appear only where they mean state, never as decoration.

Verified in a real browser rather than assumed. The feColorMatrix the page
runs is 0.8 on the diagonal with 0.2, 0.145882 and 0.033725 in the fifth
column for input-needed, and 0.72 with 0.26902 for failed -- the exact values
the renderer's tests assert, in sRGB, with the alpha row untouched. Switching
tabs swaps the filter and starts a 2200ms single-iteration animation, which is
the failed motion signature. That is F7 holding end to end.

Two fixes came out of looking at it rather than reasoning about it.

The browse tiles rendered EMPTY: they used `completed`, whose signature ends
at opacity 0 with fill-forwards, so every thumbnail animated itself out and
left a blank card. The renderer now takes motionSignatureEnabled, so a
thumbnail can play the companion's own walk cycle and its tint without the
state choreography, and a regression test pins it.

`.js` import specifiers had to go: Next resolves TypeScript extensionless, and
the whole monorepo now matches. All 186 tests still pass after the change.

scripts/verify.sh gains the static export, which is the only stage that
catches a module-resolution or server/client boundary mistake -- typecheck and
the unit tests both passed while the build was broken.

Checked at 375px: no horizontal scroll, no overflowing element, no touch
target under 44px. The state switcher exposes real role=tablist, aria-selected,
aria-controls and roving tabindex, with exactly one panel visible.

The install panel omits the copy button when the build had no production https
origin, rather than offering a command that cannot work.
The webview imports @agent-notifier/renderer — the same module the gallery
imports, not a port of it — so the tint, the frame timing and the motion
signature cannot drift between what a creator previews and what a user sees.
That is invariant F7, and it is the reason this app is a webview at all.

What lives here is only what the desktop does differently from the site:
reading the payload the host injected, observing reduced motion, reporting
when a state's animation is over, and reporting anything that failed to load —
an overlay with no chrome is otherwise completely silent when a frame is
missing.
One transparent, undecorated, always-on-top, unfocused window per display,
because a single window cannot span displays with separate Spaces on macOS.
Windows are click-through by default and a Rust cursor poll turns that off only
over the companion, so it never competes with the render thread.

The host validates every companion itself rather than trusting the TypeScript
validator: a hostile companion arrives as files on disk having never been near
packages/format, so this is the only check an attacker cannot skip. A companion
that fails is skipped with the rule it broke and never hides its neighbours.

Phase 0 left the placement unexplained and suspected a fourth coordinate space.
Measured from screenshots rather than from the API, it is two defects:
outer_position() is frozen at the creation position and never updates, and the
first move after a window is shown lands at P0 + scale x (asked - P0). A second
identical call lands exactly, verified on two displays at different scale
factors, so the window is simply asked twice and no model of the defect is
applied anywhere.
Two stages, in the order the design fixes: the overlay UI bundle, then cargo
test. The bundle stage is not optional housekeeping — generate_context! embeds
the webview into the binary, so a UI change that is not rebuilt is a UI change
that does not ship, and the failure mode is a binary that runs the previous
version of the overlay with no sign that anything is stale.
The host stops being a one-shot. It binds an ephemeral loopback port, writes the
port and a token to runtime.json at 0600, and turns POST /state into a companion
on every display.

Every endpoint requires the bearer token, including the health check. Any local
process can reach loopback, so the token is the authorization boundary and there
is no unauthenticated endpoint to probe. The request parser is written here
rather than taken from a crate because the surface is one endpoint: being
hand-written is what makes it strict, with caps on the head, the header count and
the body, a required Content-Length, and a refusal for anything it does not
understand.

The hook is a safety boundary, not a convenience. It runs inside the user's
agent, so it has a hard sub-second timeout, always exits 0, never writes to
stdout or stderr, and never retries. It reads the token at invocation instead of
carrying one, so rotating the token will not mean rewriting anyone's settings and
no secret is written into a file people paste into bug reports. Installation is
idempotent and backs the settings file up first.

StopFailure turns out to exist, so failed has a real Claude Code trigger and all
four states are reachable from a real agent rather than inferred from an event
that means something else.

Two defects that only a real second event could find:

Tauri exits when the last window closes, which is right for a one-shot and fatal
for a host that waits — the first animation ending took the process down and
every later event silently found no runtime file. And a fullscreen application
gets its own Space, so an overlay created elsewhere stayed behind it, making the
companion invisible exactly when someone is concentrating.
The four rules only matter if they hold when the app is not there. The suite
drives a script generated by the binary itself, so it cannot drift from what
setup writes, and asserts all four rules in each mode: exit 0, nothing on
stdout, nothing on stderr, and under a second.

The mode that matters is the third — a port held by something that accepts the
connection and never replies, which is the only one that can actually hang. It
is also what set the curl timeout: at a full second the hook finished in 1098ms,
over the line, because the budget is a second for the whole hook and not for the
request inside it.
Sign in with GitHub, drop a bundle, see it validated and previewed in all
four states, and open a pull request against the registry.

The bundle is checked in the browser by the same packages/format validator
CI runs, so a creator learns it is broken before a pull request exists
rather than from a red build on one. The submission uses the Git Data API
rather than the Contents API, which commits per file: a 24-frame companion
would otherwise arrive as 24 commits.

apps/relay is the only server code in the product, and it exists because
GitHub's token exchange sends no CORS headers and needs a client secret --
the device flow is CORS-blocked for the same reason, so a static site
cannot complete OAuth alone. It holds the secret, exchanges the code, and
stores nothing.

Without the three NEXT_PUBLIC_ values the publish page says publishing is
not configured and everything else builds exactly as before. A missing
deployment secret must never fail the build of a gallery that is otherwise
perfectly good.
CI runs verify.sh on every push. A v* tag builds a universal macOS bundle,
signs it, notarizes it, and refuses to publish anything unstapled -- the
signing secrets are checked before the build rather than after, because a
forty-minute build that produces an unsignable artefact tempts somebody to
publish it anyway.

scripts/require-notarized.sh checks three things that are easy to confuse:
the signature is intact, Gatekeeper would let it run, and the ticket is
stapled. Only the last survives being copied to a machine with no network.

scripts/launch-gate.sh reports the seven conditions as PASS, FAIL or
BLOCKED. Blocked is not a pass and not a failure -- it is a thing still
owed, and it has its own exit code.

Preview weight is fixed where the NFR says to fix it: in asset generation.
The build gate now emits a 160px, 12-frame preview per companion and fails
over 150 KB. 96,647 bytes, from 340 KB. Colour is weighted by alpha when
downscaling, because a companion is mostly transparent and a straight RGB
average pulls every edge towards the empty pixels, which reads as a halo.

Measuring the homepage afterwards found what the NFR had already predicted:
the four-state section was loading its real 24 frames on first paint. It is
now behind an IntersectionObserver, which is what "excluding lazily-loaded
below-the-fold cards" assumes. ~175 KB gzipped, from ~493 KB.

The colour-blind check does not demand that colour survives simulation. It
demands that every pair a simulation collapses is still separated by motion,
which is the claim the design actually makes. failed against input-needed
under deuteranopia is confirmed to be that pair.

Two conditions stay blocked and cannot be closed here: the loop verified by
someone who is not us, and notarization, which needs the Apple Developer
Program at $99/yr.
Warm paper, deep teal ink, one bronze accent, and a serif that carries
every heading. Ported from the v6 mockup: the tokens, the four fixed
background layers, the glass masthead, the dark plate, the tile, the
sidebar and the state switcher are the mockup's values rather than a
reinterpretation of them.

The plate is the load-bearing idea. A companion is drawn to read against
a desktop, so it is shown on a dark surface at every size -- and it is
now the only saturated thing on the page.

New pages and parts:

- /start, the page the mockup's nav asks for. It says there is no
  download rather than offering one: a build Gatekeeper refuses is worse
  than no build, because the first thing anyone concludes about a blocked
  download is malware.
- The browse sidebar, with a filter box and facets that actually filter.
- The app itself, drawn rather than screenshotted, cycling its four
  states from the renderer's own palette so it cannot drift from what the
  overlay paints.

lib/companions.ts is split. It reads the index off disk, and a module
that imports node:fs cannot be imported by a client component -- but the
sidebar filters in the browser and has to use the same derivations the
server does, or the counts become two opinions that can disagree. The
pure half is lib/catalogue.ts and companions.ts re-exports it, so a page
still has one place to import from.

What the mockup shows and this does not build: sign-in, accounts, likes
and creator profiles. Every one of them needs a database, and there is
deliberately no database -- the app makes no network request while
notifying, so an install count would have to be invented. The landing
says so in the section the design set aside for saying it.

The four-state section that used to sit below the fold on the landing is
gone, so WhenVisible went with it. The homepage no longer loads a single
real frame: ~286 KB over the wire against the ~300 KB budget.
The moon jelly is procedural like the cat: shapes declared as data, drawn
twice — grown in ink for one continuous outline, then at true size in colour.
Its pixel plumbing is a deliberate copy rather than a shared module, because
the cat's frames are committed PNGs whose digests index.json depends on, and a
refactor that moved one of its pixels would break every install of it.

Two things the drawing needed that the cat did not. The threads lag further
down their length, so the motion travels rather than the whole tentacle
swinging as a rigid wire. And the gonads sit as a clover high in the bell
rather than as a row across its middle, which is both what the animal looks
like and the only way the face is legible — a row lands exactly where the eyes
go and the two read as one band of blobs.

The bundle itself is not committed. It goes through the gallery's publish flow
like any other submission, which is the point of having a second companion.
The gate ran the registry build with --check, which fails unless the committed
index.json already matches a fresh build. A creator cannot produce that file:
it is derived from every companion in the catalogue, and the publish flow sends
only a manifest, a LICENSE and frames — deliberately, for the same reason it
drops checksums.json. So every submission arrived without an index update and
every submission went red, in a registry whose whole purpose is accepting them.
The pull request the gallery opens even promises CI regenerates it. Nothing did.

index.json is generated output, exactly like registry/dist, which was already
ignored. Tracking one and not the other is what made this possible, so it is
now ignored too and the gate builds instead of checking. Nothing is weakened: a
digest cannot go stale if every build computes it from the frames, which is what
--check existed to notice, and --check still asserts determinism in build.test.ts
and in the launch gate, both of which build first. The build stage also moves
ahead of the tests, because loadIndex reads that file, and it gives the later
stages the registry/dist that --check never wrote — so the static export now
carries the archives it offers for download.

The second companion then found what one companion had been hiding: three
assertions read companions[0], which is not "the tabby cat" but "whichever id
sorts first". The first submission that was not CC-BY-4.0 at 12 fps would have
turned the suite red for a reason that had nothing to do with what broke.
@Gakshith

Gakshith commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

done

main and this branch both applied the same rename on top of 6092bdb as separate
commits, so git saw the whole repository as changed in both — plus every Swift
file as deleted here, when it had only moved to apps/macos-swift/.

Resolved by keeping this tree. That is safe rather than convenient: all 106
paths in main exist here, at the same path or relocated under apps/macos-swift/,
and every one is byte-identical except .gitignore, where this branch's copy is a
strict superset of main's.
@Gakshith
Gakshith merged commit 082861a into main Aug 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant