6 Commits

Author SHA1 Message Date
Paperclip Agent
89f56fc78a metrics: stand up the weekly funnel job (FRE-202)
`marketing/metrics/weekly.md` has been a placeholder since the marketing tree
landed — it promised a funnel and the one thing that moved, and nothing
produced it. This adds the producer: one command reads the live databases and
rewrites the file.

`scripts/weekly-metrics.ts` writes `marketing/metrics/weekly.md` from
`VisitorAnalytics` (visits, downloads, campaigns) and the-nook's `trials`,
`licenses`, `activations` (the paid funnel). It needs four env vars —
TURSO_DB_URL/TOKEN and NOOK_DB_URL/TOKEN — and imports no server env, so the
job runs from a checkout that has no Stripe or Apple secrets.
`src/server/weekly-metrics.ts` holds the queries and the renderer, taking both
connections as arguments; the tests run them against an in-memory SQLite shaped
like production, the same way the backfill suite does.

The campaign breakdown groups exactly as `getAnalyticsSummary.topCampaigns`
does (top 10, untagged excluded), so the weekly read and /analytics cannot
drift into different label sets.

The first real report says more about the instrumentation than about demand,
and the renderer computes that rather than asserting it:

- every addressed row the table has ever held (12,035 of 12,048) is loopback,
  so "unique visitors" is one host and per-channel reach cannot be read;
- the landing door and the file route both write `/api/downloads/thenook`, so
  "download click" and "DMG served" are one label — one served click writes two
  rows, a failed click one, a Sparkle update one;
- `POST /api/the-nook/checkout` records nothing, so checkout start is only a
  page-view proxy;
- `VisitorAnalytics` has no host dimension, so the Nook's `/` is freno.me's `/`.

Those three gaps are reported beside the numbers, not papered over. Week 0
(2026-10-22 → 2026-10-29): visit 61, download 156, trial 2, checkout 0,
purchase 0, activation 0 — and the one thing that moved is the download stage,
0 → 156, because the label was introduced that week.

Tests: `bun test src/server/weekly-metrics.test.ts` — 12 pass, 0 fail. The full
suite's 15 failures are pre-existing: this checkout's .env lacks
NOOK_LICENSE_PRIVATE_KEY, NOOK_STRIPE_SK/WEBHOOK/PRICE and LINEAGE_JWT_SECRET,
and every failure is that env validation.

Scripts/ is gitignored, so `scripts/weekly-metrics.ts` was added with `-f`, the
way the existing tracked scripts are.

FRE-202
2026-10-29 15:58:51 +00:00
Paperclip Agent
d44133fcb3 site: guard the removed remote claim and agent count against return (FRE-323, FRE-359) 2026-10-29 14:38:31 +00:00
Freno CMO
578ee58f9c marketing: the remote correction is committed, not deployed
copy.md said the sentences were "removed from the live site" and
rationale.md said "the landing page now says only what the product can back
today". Neither is true: nook.freno.me still serves all of them (HTTP 200,
re-fetched 2026-10-29T14:20Z) because the push is blocked on a write
credential (card 82d1e1b7). Both files now say committed-not-deployed, and
positioning.md's excluded-count row records FRE-359 verdict B and where the
count was removed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:12 +00:00
Freno CMO
d2c2e6a4ee agents: drop the count and the coverage claim (FRE-359 verdict B)
FRE-359's verdict is B: the page's thirteen is the registry's first thirteen
of eighteen rows, so the count, "every coding agent The Nook supports" and
"Supported agents" all overstate or understate the app. The permitted
sentences are the CTO's, verbatim.

- agents.tsx: head description, ogTitle and H1 paragraph replaced with the
  verdict's sentences; title/H1 "Supported agents" -> "Agent coverage".
- agents.test.ts: the stale NOOK-336 guard asserted "all thirteen" and
  the 13-name roster, so it passed in lockstep with the page. It now asserts
  no count of any form, no completeness phrase, and that the rows are a
  subset of the 18-name registry list (copied 2026-10-29, @ f4258034) in
  registry order. The table still lists 13 rows; regenerating it from the
  registry is its own ticket, and the guard no longer pretends otherwise.
- FeatureBreakdowns.tsx / IslandExtras.tsx: the landing "13 agents:" bullet
  and the "13 agents" marquee chip lose the count; the chip becomes
  "Agents wired in".

bun test src/routes/nook/agents.test.ts: 4 pass. Server security and appcast
suites fail in this checkout on missing env (Environment validation failed /
mainDBConnection), unrelated to these files.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:11 +00:00
Paperclip Agent
c718370674 /agents: take the remote-session sentence off the page
FRE-323 removed the remote claim from the landing page and missed the second
page that carries it. /agents said 'Agents can run on this Mac or on remote
machines over SSH — remote sessions report into the same grid', which is the
same unverified claim (FRE-315 §1, FRE-320), in front of the same launch
traffic. Removed; the paragraph now ends at 'the agent keeps its own prompting'.

Not deployed: same read-only credential as c1c498e.
2026-10-29 10:20:13 +00:00
Paperclip Agent
210a7e6e75 marketing: reconcile positioning.md with the CTO verdicts and the price decision
The source-of-truth file still sold claims the board has since ruled on:

- 'thirteen agents' as a public coverage count — FRE-322 §1 counts 18 specs,
  9 of which can raise an approval card; no count is publishable (FRE-203 plan-b).
- 'Price rises to 5 at 1.0' — withdrawn by the Chief of Staff on 2026-10-29
  04:01; price stays $10 and is not a launch asset.
- 'macOS 14+' — the app cannot start below 14.2 (881190d, site 134f41e).
- Approved-claims table carried no launch sentence at all, and no row for the
  remote-SSH hold or the banned absolute privacy sentences.
- Pointed at the-nook/docs/marketing-strategy.md, which no longer exists.

Now: the Plan B claim set, a per-claim status table with verdict pointers,
FRE-354's corrections to the hook/bridge sentence and the Sentry disclosure,
the iPhone TestFlight state, and an explicit excluded/banned list.
2026-10-29 10:19:14 +00:00
21 changed files with 1509 additions and 1190 deletions

View File

@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
under their own account → the artifact (or a pointer) lands in `posted/`.
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
## The weekly read
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.

View File

@@ -1,478 +0,0 @@
# `/faq` from the objections we already have
**Date:** 2026-10-29 · **Owner:** CMO · **Status:** CTO-verified (FRE-329) and CoS-decided (FRE-334, option A: withdraw both) on 2026-10-29; **awaiting the sponsor's publish approval** — not approved, not published · **Source issue:** FRE-326
**Page:** `nook.freno.me/faq` · **Page source:** `freno-dev/src/routes/nook/faq.tsx` · **Site commit read:** `c1c498e` (branch `faq/from-objections-2026-10-29`, unmerged)
**Product commit read:** The-Nook `f9cbd879` — the exact revision the CTO verified against (0.13.4, build 37)
Every entry below is traceable to a real question or a real report. Nothing is invented to fill a
category. No testimonial is used. No price, refund term, or umbrella/cross-promotion answer appears
here — those stay with FRE-204, the Chief of Staff and the sponsor.
---
## 1. What I could read, and what I could not
| Source | What it is | Read on | What it yielded |
|---|---|---|---|
| `nook.freno.me/faq` | The live page | 2026-10-29 | Six questions already published: privacy, notch, battery, permissions, macOS 14.2, offline. |
| FRE-326 description | This board issue | 2026-10-29 | Two purchase-blocking questions named verbatim: *"does it work with Cursor?"* and *"what happens when my agent is idle?"* |
| NOOK issue corpus | 948 issues imported from Plane, plus the 324-issue Paperclip board | 2026-10-08 → 2026-10-28 | User-observable defect reports. These are reports, not captured asks; they prove an objection is live. |
| `freno-dev/marketing/positioning.md` | Voice, approved claims, competitor ban | 2026-10-29 | The register and the "never name a competitor" rule. |
| The-Nook code | `AgentRegistry.swift`, `SessionModels.swift`, `Terminal*`, `hook/setup.go`, `PluginInstaller.swift`, `LicenseService.swift`, `main.swift` | 2026-10-29 | The technical facts each answer is allowed to use. |
| NOOK-121 | The original `/faq` creation (2026-10-08) | 2026-10-29 | Confirms the six live questions came from the strategy's named objections, not from a support corpus. Refund was deliberately dropped by Mike. |
### The gap that shapes this draft
**Support mail is not readable from this run.** There is no mailbox on the machine; the in-app
"Report a bug" form `POST`s to `freno-dev/src/routes/api/the-nook/report.ts`, which emails the
developer and stores nothing queryable. `/privacy` and the repo contain no support corpus.
**Reply threads are not collected.** `freno-dev/marketing/listening.md` is an empty table; FRE-201
(the listening job) has not produced a thread yet.
**Directory comments do not exist yet.** FRE-207 has a submission packet, not live listings; none of
the directories has a comment thread about The Nook to mine.
**Consequence:** I cannot rank by observed ask frequency, because no ask corpus exists. What follows
ranks by **purchase-blocking weight first**, then by **the number of named reports in our own issue
corpus that belong to the cluster**. The report counts are a proxy for "this objection is live", not
a count of people who asked. I have not put a percentage on anything.
---
## 2. The register — clusters, sources, frequency, and whether they block a purchase
| # | Cluster | The question in the buyer's words | Verbatim source(s), with dates | Frequency evidence | Blocks a purchase? | Disposition |
|---|---|---|---|---|---|---|
| 1 | Privacy / what leaves the Mac | "Does The Nook read my prompts or my code?" | Live `/faq`, 2026-10-29; code findings in `main.swift:272-278`, `LicenseService.swift:254`, `docs/sidecar-frames.md` | 1 live question + 3 named code findings | **Yes — the whole decision for this audience** | **Withdraw the live answer; replacement held.** See §4. |
| 2 | Agent fit / Cursor | "Does it work with Cursor — and can I approve from the notch, or only watch?" | FRE-326, 2026-10-29: *"does it work with Cursor?"*; NOOK-643 (2026-10-20), NOOK-695 (2026-10-20), NOOK-699 (2026-10-20), NOOK-707 (2026-10-21), NOOK-638 (2026-10-20) | 1 board question + 5 reports | **Yes** | **Propose E1** |
| 3 | Idle / is it actually working? | "What happens when my agent is idle?" | FRE-326, 2026-10-29: *"what happens when my agent is idle?"*; NOOK-281 (2026-10-12), NOOK-415 (2026-10-15), NOOK-417 (2026-10-15), NOOK-823 (2026-10-22), NOOK-898 (2026-10-23) | 1 board question + 5 reports | **Yes** | **Propose E2** |
| 4 | Install trust / permissions | "What permissions does it need — and what does it write into my agent's config?" | Live `/faq`, 2026-10-29; NOOK-733 (2026-10-21), NOOK-648 (2026-10-20), NOOK-685 (2026-10-20), NOOK-418 (2026-10-15), NOOK-568 (2026-10-17) | 1 live question + 5 reports | **Yes** | **Propose E3** |
| 5 | Terminal / tmux fit | "Does it work with my terminal, including tmux?" | NOOK-302 (2026-10-13), NOOK-310 (2026-10-13), NOOK-788 (2026-10-22) | 3 reports; no captured ask | **Yes for tmux-first buyers** | **Propose E4** — report-sourced, not an ask |
| 6 | Notch / display | “Do I need a Mac with a notch?” | Live `/faq`, 2026-10-29; NOOK-290 (2026-10-12), NOOK-291 (2026-10-12), NOOK-386 (2026-10-14) | 1 live question + 3 reports | Compatibility; rarely the blocker | **Rewrite E5** — the pointer-display sentence was never true |
| 7 | Battery / cost | "How much battery does it cost?" | Live `/faq`, 2026-10-29; NOOK-850 (2026-10-23), NOOK-851 (2026-10-23), NOOK-877 (2026-10-23) | 1 live question + 3 reports | Curious, not a hard blocker | **Rewrite E6 without a number** |
| 8 | macOS floor | "Why macOS 14.2 or later?" | Live `/faq`, 2026-10-29; NOOK-881 (2026-10-23) | 1 live question + 1 report | Compatibility; a hard no on 14.0/14.1 | **Keep current answer** |
| 9 | Offline | "Does it work offline?" | Live `/faq`, 2026-10-29; NOOK-494 (2026-10-16) | 1 live question + 1 report | Compatibility; a hard no for offline-only | **Rewrite E8 for the phone exception** |
| 10 | Trial / licence mechanics | "What am I actually buying — trial, Macs, updates?" | NOOK-682 (2026-10-20), NOOK-684 (2026-10-20), NOOK-60 (2026-10-08) | 2 reports; no captured ask | **Yes** | **Held** — no verbatim ask; do not invent one |
| 11 | Remote agents / containers | "Can I approve an agent running on a remote box or in Docker?" | FRE-323, FRE-320, FRE-315; `marketing/positioning.md` claims remote SSH | Many reports; the claim is unverified | **Yes — the launch claim** | **Held** — no answer before the demo |
| 12 | Phone / Watch | "Is there an iPhone app? Do I need one?" | NOOK-939 (2026-10-27), NOOK-294 (backlog) | 2 reports; status unverified | Curious; would be blocking if sold | **Held** — CTO status answer first |
| 13 | Refund / price | "What's your refund policy? Is $10 the real price?" | FRE-204, NOOK-121 | No readable source; contractual | **Yes** | **Out of scope** — FRE-204, CoS + sponsor |
### What changed since the last register (FRE-204)
- The live page already has six entries; this draft **keeps one** (macOS 14.2), **rewrites four**
(permissions, battery, offline, and notch — whose pointer-display sentence the CTO found was never
true), **withdraws one** (privacy), and **proposes three new ones** (E1, E2, E4). It does not re-ask
the six questions as if they were new.
- The privacy answer is not merely unverified — it is **false at the current build**. It must come
down in the same edit, not wait for a nicer replacement.
- Cursor is now a supported agent row (`AgentRegistry.swift:524`), so "does it work with Cursor?"
has a factual answer today. It was an open question when the live page was written.
### CTO verdict (FRE-329, 2026-10-29)
The CTO read The-Nook `f9cbd879`, the site at `c1c498e` and the live page, and checked every entry
against the code. **No entry is cut.** Six change text, exactly as below; E1 and E7 ship as written.
| Entry | Verdict | What changed in this revision |
|---|---|---|
| E1 Agent fit / Cursor | **verified** | nothing |
| E2 Idle | verified, one sentence tightened | the row-eviction sentence |
| E3 Permissions / config | **not verified** | three sentences: Login Items toggle, per-event entries, `~/.nook` |
| E4 Terminal / tmux | **not verified** | one sentence: which terminals take a typed reply |
| E5 Notch | **not verified** | one sentence: one island per enabled display, not the pointer's display |
| E6 Battery | verified, one clause tightened | "never runs them in the background" |
| E7 macOS 14.2 | **verified** | nothing |
| E8 Offline | **not verified** | one sentence, plus a six-row network list in its gate |
| §4 privacy | all three findings confirmed; withdrawal is right | `positioning.md` rule 3 comes down in the same change |
The verdict adds two things to the plan, both now in §4 and §7: the false claim's **source rule** in
`marketing/positioning.md` — rule 3 and its approved-claims row — must be struck in the same change
as the withdrawal, or the next page restates it; and the egress inventory's scope widens to cover
weather, the network panel, the speed test, the changelog, the bug report and Sparkle, plus Sentry's
`sendDefaultPii` and trace sample rate.
The verdict's full text and its per-sentence evidence is
[FRE-329's `faq-verdict` document](/FRE/issues/FRE-329#document-faq-verdict). Nothing in it is
reproduced here as a new claim; the entry text below is the verdict's own replacement wording, except
E4, where I took the verdict's first option (keep the Alacritty clause) and narrowed the jump
sentence, which the verdict itself notes is `open -b` for three of the seven names.
---
## 3. Proposed entries — page-ready
Each entry carries the six required things: **claim, audience, channel, owner, date, and what has to
be true before it ships.** The answer text is the copy to paste; the trace is the evidence a CTO can
check.
### E1 · "Does it work with my agent — and can I approve from the notch, or only watch?"
> **It depends on the agent, and Cursor is the useful example.** Cursor is watched, not gated: its
> sessions, finished turns and errors appear in the island, but when Cursor asks for permission the
> prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs
> and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the
> decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit:
> `cursor-agent -p` one-shot runs send no turn end, so they appear but do not get a completion card.
- **Question source:** FRE-326, 2026-10-29 (*"does it work with Cursor?"*); NOOK-643, 2026-10-20
(*"No permission hook is installed, deliberately: Cursor blocks the action when a permission hook
prints nothing…"*).
- **Claim:** agent gating is per-agent; Cursor is watch-only.
- **Audience:** the buyer whose agent is not Claude Code.
- **Channel:** `/faq`. No FAQ structured data in this pass — see §6.
- **Owner:** CMO writes; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** at the shipped commit, Cursor still reads `.missing(.approvals)` with the same
reason, and `.available(.sessions)`, `.available(.completion)` and `.available(.errors)` as stated
(`AgentRegistry.swift:523-555`, which is where the CTO read it). The separate `/agents` page is
incomplete for Cursor and the other newer rows; that is a page correction, not a gate for this
self-contained answer.
### E2 · "What happens when my agent is idle?"
> **Idle means the last turn is over, or the session has not produced anything yet — not that the
> process is gone.** A running process is liveness, not work: a just-opened agent window can show
> idle until you type. When an agent finishes a turn with a final message, the row stays green for
> ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot
> report for that session, and the row says so instead of pretending it is idle. Nothing is lost when
> a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the
> island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session
> reappears on its next event instead of sitting there. If a session that is obviously working says
> idle, that is a bug; tell us.
- **Question source:** FRE-326, 2026-10-29 (*"what happens when my agent is idle?"*); NOOK-898,
2026-10-23 (*"kimi payloads fail to decode, so every Kimi session stays idle"*); NOOK-823,
2026-10-22 (*"Codex terminal: sessions become idle or disappear prematurely"*); NOOK-417,
2026-10-15 (*"Never render plain Idle for a session whose integration is dark"*).
- **Claim:** idle is a statement about the last turn, not a guess at the process; stale is a separate
state.
- **Audience:** anyone watching a fleet and wondering whether to intervene.
- **Channel:** `/faq`.
- **Owner:** CMO writes; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** `SessionModels.swift:511,552,564-577` still defines `islandStatus` this way (no
content → idle; complete → ready for 10 minutes → idle; `integrationStale` → stale) and
`SessionState.swift:35,40` still evicts a finished turn after 15 minutes, 5 when it ended without a
reply. If an open bug makes a working agent render idle at the shipped commit, the "that is a bug"
sentence stays but the bug gets a named issue.
### E3 · "What permissions does it need — and what does it write into my agent's config?"
> **The app itself runs unprivileged.** Fan control and charge limiting go through a small helper
> that macOS requires to run as root for hardware access; the app registers it on first use and takes
> you to System Settings › General › Login Items & Extensions, where you switch it on once. For each
> agent it adds what that agent needs — a hook group per event it listens to, or one plugin link —
> inside the agent's own config file or directory. It keeps its own files under `~/.nook` — the hook
> binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook
> entry you added yourself in the same file survives install and uninstall, and it only touches the
> records and switches it created. Uninstall removes exactly what it wrote — and switches off what it
> turned on — with no leftover links or orphaned files.
- **Question source:** live `/faq`, 2026-10-29 (*"What permissions does it need?"*); NOOK-418,
2026-10-15 (a custom agent's install target is never shown to the user); NOOK-733, 2026-10-21 (a
Codex hook installed but not trusted, so it never ran); NOOK-648, 2026-10-20 and NOOK-685,
2026-10-20 (uninstall leftovers); NOOK-568, 2026-10-17 (helper re-registration tore down an
approved helper).
- **Claim:** reversible install; the helper is the only root step; uninstall is exact.
- **Audience:** the buyer who has a hand-tuned `~/.claude/settings.json` or `~/.codex/hooks.json`.
- **Channel:** `/faq`.
- **Owner:** CMO writes; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** `hook/setup.go`'s strip functions and `PluginInstaller.uninstall` still remove
only their own records at the shipped commit (`removeIfOurs`, `stripOurGroups`/`groupIsOurs`); the
helper is still registered through `SMAppService.daemon` with a Login Items toggle and no password
prompt (`PrivilegedHelper.swift:62-112,204-208`); the custom-agent target is still shown and
accepted before install (NOOK-418); the only host switch uninstall flips is Amp's own hook-enable
flag (`PluginInstaller.swift:379-385`) — and the answer now says so.
### E4 · "Does it work with my terminal, including tmux?"
> **Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those
> take a typed reply; the rest are watched.** A session is tied to the terminal that started it. Jump
> lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right
> pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back
> into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the
> app's own window, and Alacritty takes no typed reply from another app at all.
- **Question source:** no captured ask. Underlying reports: NOOK-302, 2026-10-13 (*"a tmux session
shows only 'tmux', and its outer terminal comes from whoever started the tmux server"*); NOOK-310,
2026-10-13 (*"jump and send address tmux panes on the default server, ignoring the session's tmux
socket"*).
- **Claim:** seven terminal families and tmux panes included. Remote panes are not claimed here;
the remote launch story is still held.
- **Audience:** anyone whose workflow is tmux-first.
- **Channel:** `/faq`.
- **Owner:** CMO writes; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** `TerminalAncestry.programs` still recognises those seven names; `TerminalSend.attempts`
still builds a delivery channel only for tmux, herdr, kitty, iTerm2 and Terminal.app and refuses
Alacritty by name; `TerminalJump` still raises the app rather than the window for Warp, WezTerm and
Ghostty. This entry is **report-sourced, not ask-sourced**; if a captured ask never arrives, it is
still publishable because the reports are real, but it ranks below E1–E3 until one does.
### E5 · "Do I need a Mac with a notch?"
> **No. On a notched MacBook the island sits in the notch. On machines and displays without one, it
> sits at the top centre of the display — the same pill with the same slots.** You get one island on
> every display it is turned on for (all connected displays on first launch), each anchored to the top
> of its own screen; Settings › Displays trims that list, and the pointer only decides which display's
> floating controls are live. Everything inside the island — agent cards, fan control, the stats strip
> — works identically either way.
- **Question source:** live `/faq`, 2026-10-29; NOOK-290, 2026-10-12 and NOOK-291, 2026-10-12
(non-notch display geometry bugs), NOOK-386, 2026-10-14 (empty pill tucks away on a non-notch
display).
- **Claim:** a notch is optional; external displays work.
- **Audience:** Mac mini / Studio / external-monitor buyers.
- **Channel:** `/faq`.
- **Owner:** CMO keeps the question and rewrites the answer; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** `IslandDisplayManager` still owns one island per enabled display, `DisplayStore`
still snapshots every connected display on first launch (`DisplayStore.swift:52-59`), and the
non-notch geometry tests still pass. Nothing tests that first-launch snapshot today — that is a test
gap to file, not a gate on this answer.
### E6 · "How much battery does it cost?"
> **It is a native app, not a background agent.** It watches the agents you already run and never
> runs them in the background, so their draw is the number that matters. We do not have a published battery
> measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs
> more than it should on your Mac, that is a bug — tell us.
- **Question source:** live `/faq`, 2026-10-29; NOOK-850, 2026-10-23 (*"the idle app does work
nothing asked for (CPU wins from a live profile)"*), NOOK-851, 2026-10-23 (a 30 fps timeline ticks
the fan icon at idle), NOOK-877, 2026-10-23 (compact Nook still costs too much CPU at rest).
- **Claim:** native app, not a background agent; **no numeric battery claim**.
- **Audience:** the buyer checking whether this is another menu-bar battery sink.
- **Channel:** `/faq`.
- **Owner:** CMO writes; CTO verified the no-number wording 2026-10-29.
- **Date:** CTO verified the no-number wording 2026-10-29; publish on approval, target 2026-11-05.
- **Must be true:** either a measurement exists and is quoted, or the no-number sentence ships. Do
not restore "the heaviest thing it does is a sensor read" — the profile findings above contradict
it.
### E7 · "Why macOS 14.2 or later?"
> **The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS
> 14.2 (Sonoma).** The app links against it, so 14.0 and 14.1 cannot launch it.
- **Question source:** live `/faq`, 2026-10-29; NOOK-881, 2026-10-23 (*"A macOS 14.0/14.1 Mac cannot
launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol"*).
- **Claim:** the floor is 14.2 because of the audio tap API, not a policy choice.
- **Audience:** anyone on an older macOS.
- **Channel:** `/faq`.
- **Owner:** CMO keeps; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** `Package.swift:12` and `LSMinimumSystemVersion` still say 14.2, and
`DeploymentFloorTests` still refuses an older floor.
### E8 · "Does it work offline?"
> **The Mac app does.** The island, agent cards, permission cards and fan control work with no
> network; the hooks talk to the app over a local socket. The network is used for update checks,
> licence activation, crash reports and the optional trial email, plus a few things only when you use
> them: weather (asked for through our relay, with the location you chose), the network panel's
> IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The
> phone link is the exception: when you are away from your Mac, the phone reaches it through our
> relay, so that part needs a connection.
- **Question source:** live `/faq`, 2026-10-29; NOOK-494, 2026-10-16 (*"iOS: the main list gives no
sign the phone is offline from the Mac"*).
- **Claim:** the Mac app is offline-first; the phone link is not.
- **Audience:** the buyer with a disconnected or privacy-sensitive setup.
- **Channel:** `/faq`.
- **Owner:** CMO writes; CTO verified 2026-10-29.
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
- **Must be true:** the only network uses at the shipped commit are the update check, licence
activation, crash reporting, the optional trial email and the phone relay, plus exactly these five
user-triggered ones — `/v1/weather` on the relay (`SidecarRelayAPI.swift:182-196`),
`NetworkInfoService.swift:60-88,276`, `SpeedTest.swift:36,57`, `ChangelogModel.swift:102`,
`BugReportClient.swift:72-94` — and `/privacy` lists the same set. This entry shares its gate with
the privacy correction below.
---
## 4. The correction that is not a new entry: privacy
**Live answer, quoted from `nook.freno.me/faq` on 2026-10-29:**
> "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which
> agent is running, which session, which event happened, which terminal it lives in. No prompt text
> and no code content leaves your device. Two things do leave the device, and we'd rather say so
> plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start.
> That's the whole list."
**Why it cannot stay up:** it is contradicted by the current build.
1. **Sentry starts unconditionally with `sendDefaultPii = true`** (`Sources/NookApp/main.swift:272-278`).
That is crash and error reporting with personal identifiers, not "anonymous usage telemetry".
2. **The licence/trial call sends `IOPlatformUUID` and the Mac's device name** (`LicenseService.swift:254`,
`:305`). Precision from the verdict: the identifiers ride on activation, not on every launch — a
licensed Mac's launch call is `/status` with the key only (`:290-295`).
3. **With the phone link on, prompt and reply text transit `nook-relay.freno.me`, sealed end to end**
(`docs/sidecar-frames.md`). The relay can route the payload but cannot read it; the live answer
still says no prompt text leaves the device.
`/privacy` discloses the hardware UUID, device name and Stripe, but **not** Sentry and **not** the
relay. So there is no replacement sentence I can write that is both true and consistent with the
privacy page today.
**Proposed:** remove the privacy entry from `/faq` in the same edit that ships the other entries,
rather than replace it with another sentence I cannot stand behind. The replacement is held until
the Security Engineer's egress inventory and the `/privacy` update land.
**Decided 2026-10-29 — option A, withdraw both.** The Chief of Staff ruled on
[FRE-334](/FRE/issues/FRE-334) (`decision` document) that the `/faq` answer and `positioning.md`
rule 3 with its approved-claims row come down **in one commit**, and that no privacy claim is written
anywhere until FRE-333 lands and the CTO verifies it. Three consequences for the sequence below: the
withdrawal is no longer one of the sponsor's options, it is settled company policy; the publish card
asks one thing, publish the eight entries with the privacy answer withdrawn; and if an answer ever
ships the seven entries with the false answer still up, that is not a variant to absorb — it stops
and goes back to the CoS, because it would publish something the CoS has ruled out.
**The rule that produced the falsehood comes down with it.** The CTO's verdict traces the live answer
to `freno-dev/marketing/positioning.md` rule 3 (“Honest privacy scoping”) and to the matching
approved-claims row, which state the defensible claim as “No prompt or code content leaves the
device” with the backing “envelope carries session/event/terminal metadata only”. That backing is true
of the hook→app envelope and says nothing about what leaves the device, so if rule 3 stands while the
FAQ answer comes down, the next page restates the claim. Rule 3 and that row are struck in the same
commit as this patch (§7 step 3; the same edit also fixes the stale “macOS 14+” row in that table,
which NOOK-881 retired).
- **Claim:** none until the inventory exists — deliberately.
- **Audience:** the buyer for whom this is the whole decision.
- **Channel:** `/faq` (withdrawal), `/privacy` (replacement), `marketing/positioning.md` (the rule).
- **Owner:** CoS for the withdrawal decision — **done 2026-10-29, option A**; Security Engineer for the
egress inventory and the disclosure text; CTO for any build change and for verifying the replacement;
CMO for the replacement copy and the rule-3 strike.
- **Date:** withdrawal decision **done 2026-10-29** (was targeted 2026-10-31); inventory 2026-11-05;
replacement copy 2026-11-09, and it needs both the CTO's verification and the sponsor's approval —
rule-3 strike in the same commit as the page patch (§7 step 3).
- **Must be true:** every egress in the inventory is either in the answer or gone from the build, and
`/privacy` lists the same set. The inventory's scope is wider than the draft first drew it — it must
cover `main.swift`, `LicenseService.swift`, `Info.plist`, `ChangelogRelease.swift`,
`SidecarController.swift`, **plus** `SidecarRelayAPI`/`WeatherService` (`/v1/weather`),
`NetworkInfoService`, `SpeedTest`, `ChangelogModel`, `BugReportClient` and Sparkle's appcast — and
on the Sentry side not only that it starts but `sendDefaultPii` and the trace sample rate. Until
then, do not publish a privacy answer.
---
## 5. Held — clusters with no entry yet
| Cluster | Why it is held | Owner and date |
|---|---|---|
| Trial / licence mechanics ("trial, Macs, updates") | No captured ask. NOOK-682 and NOOK-684 are defect reports, not a buyer question. The answer is factual (14 days, 3 Macs, Sparkle updates), but the task says do not invent a question to fill the slot. | CMO to capture an ask; CTO confirms `LicenseService.swift:43`, `:262` by 2026-11-03. |
| Remote agents / containers | The launch claim is unverified (FRE-315, FRE-322, FRE-323, FRE-320). No FAQ answer before the demo. | CTO evidence; CMO copy the day it exists. Revisit 2026-11-12. |
| Phone / Watch | Status is unverified; the landing page and FAQ currently disagree. | CTO status answer by 2026-11-03; CMO copy after. |
| Refund / price | Contractual, and explicitly out of this issue. | FRE-204, CoS + sponsor. |
| Custom agents | No captured ask; the `/agents` page already says a custom agent can wire itself in. | No entry until asked. |
---
## 6. What the page edit would change
**File:** `freno-dev/src/routes/nook/faq.tsx` (site `master` at `134f41e`; the branch is cut from
local `c1c498e`, which removed the remote claim from the landing page, not from the FAQ).
| Action | Entry |
|---|---|
| **Remove** | “Does The Nook read my prompts or my code?” — until the egress inventory and `/privacy` are true |
| **Rewrite (correction only)** | “Do I need a Mac with a notch?” → E5 (per-display island, not the pointer's display) |
| **Keep, with the exact text above** | “Why macOS 14.2 or later?” |
| **Rewrite** | “What permissions does it need?” → E3 (adds the config/uninstall answer) |
| **Rewrite** | “How much battery does it cost?” → E6 (no number) |
| **Rewrite** | “Does it work offline?” → E8 (phone exception, six-row network list) |
| **Add** | E1 (agent fit / Cursor) |
| **Add** | E2 (idle) |
| **Add** | E4 (terminal / tmux) |
| **Edit** | The file header comment that cites `positioning.md` rule 3 → “Privacy is deliberately absent until the egress inventory and /privacy make an honest answer possible.” |
| **Edit** | The `PageHead` description, which still advertises the privacy question → the new eight-entry list |
Order on the page: E1, E2, E3, E4, E5, E6, E7, E8. The mailto link, the accordion behavior and the
page title stay unchanged.
**Not in this pass:** FAQPage JSON-LD. The page has none today; adding structured data is an SEO
change, not an objection-mining change. It should be its own reviewed edit — and E1 no longer promises
it.
**Patch artifacts:** `freno-dev/marketing/drafts/faq-page.patch`, regenerated for this revision, and
`freno-dev/marketing/drafts/positioning-privacy-withdrawal.patch` for the rule-3 strike step 3 needs
to land with it (both proposals, neither applied). The page file and `positioning.md` are untouched
until the gates below are satisfied; `faq-page.patch` re-checked against `freno-dev` master
`c1c498e` on 2026-10-29 and applies cleanly.
---
## 7. Publish gates and sequence
| Step | Owner | Date | What settles it |
|---|---|---|---|
| 1. CTO verifies the claims and evidence pointers in E1–E8 | CTO | **done 2026-10-29** | [FRE-329](/FRE/issues/FRE-329), verdict in its `faq-verdict` document: E1/E7 verified as written, E2/E6 verified after one sentence each, E3/E4/E5/E8 corrected above, **no entry cut**. Applied in this revision. |
| 2. Human approves this revision | Sponsor | target 2026-10-31 | The draft moves from `marketing/drafts/` to `marketing/approved/`, or the confirmation card on FRE-326 is accepted. The card asks one question now — publish the eight entries with the privacy answer withdrawn — because 2b is settled. |
| 2b. Withdrawal of the false privacy answer and of `positioning.md` rule 3 | Chief of Staff | **done 2026-10-29** | [FRE-334](/FRE/issues/FRE-334) `decision`: **option A, withdraw both, one commit**, no privacy claim anywhere until FRE-333 lands and the CTO verifies it. Recorded here so the page, the rule and the card cannot disagree. |
| 3. Apply the page patch, strike rule 3 + its approved-claims row, commit on `freno-dev` | CMO | 2026-11-03 | Both patches apply together to `freno-dev` master `c1c498e` (checked 2026-10-29, in a throwaway worktree, as one change set); the diff contains only the FAQ entries, the page comment/description, the `positioning.md` rule-3 strike and the 14.2 row. **The site's suite has not been run — this host has no `bun`** — so step 3 still has to run it before the commit. One commit, so the page and the rule move together. |
| 4. Merge to `freno-dev/master` (the publish) | Sponsor or Chief of Staff | 2026-11-05 | A human merge; Vercel deploys. No agent pushes to master. |
| 5. Verify the live page | CMO | 2026-11-05 | `https://nook.freno.me/faq` returns 200 and shows the eight entries with no privacy answer; the text matches the approved draft, sentence for sentence. |
**If the CTO had rejected an entry:** cut it from the patch and ship the rest — it did not reject any.
**If the sponsor does not approve:** nothing is applied; the draft stays in `drafts/`.
**If the support mailbox becomes readable:** re-sort the register by observed asks before step 3;
the top three may change, and the page order should follow the asks, not this draft's proxy ranking.
**If a `Must be true` line stops being true** before step 3 — a fix regresses, a line moves — the
entry goes back to the CTO, not onto the page.
---
## Appendix A — verbatim source notes
| Date | Source | Verbatim |
|---|---|---|
| 2026-10-29 | Live `/faq` | "Does The Nook read my prompts or my code?" / "Do I need a Mac with a notch?" / "How much battery does it cost?" / "What permissions does it need?" / "Why macOS 14.2 or later?" / "Does it work offline?" |
| 2026-10-29 | FRE-326 description | “does it work with Cursor?” / “what happens when my agent is idle?” |
| 2026-10-29 | [FRE-329](/FRE/issues/FRE-329) CTO verdict | “Four entries ship as written, two ship after a wording fix… two (E3, E4) currently say something false to a buyer.” Every correction in §3 is that document's own replacement sentence. |
| 2026-10-08 | NOOK-121 | "Add /faq from real objections" — shipped the six live topics; refund dropped by Mike. |
| 2026-10-20 | NOOK-643 | "agents: Cursor via hooks.json" — "No permission hook is installed, deliberately: Cursor blocks the action when a permission hook prints nothing…" |
| 2026-10-21 | NOOK-707 | "agents: Cursor desktop chats bind to the shared extension-host pid and never end" |
| 2026-10-23 | NOOK-898 | "hook: kimi payloads fail to decode, so every Kimi session stays idle" |
| 2026-10-22 | NOOK-823 | "Codex terminal: sessions become idle or disappear prematurely" |
| 2026-10-15 | NOOK-417 | "Never render plain Idle for a session whose integration is dark" |
| 2026-10-15 | NOOK-415 | "Sessions whose integration predates the bridge credential freeze as Idle forever" |
| 2026-10-21 | NOOK-733 | "codex: a non-interactive session never runs an installed hook (0.163.0)" |
| 2026-10-20 | NOOK-648 | "opencode: uninstalling a pre-package install leaves its old nook.js link dangling" |
| 2026-10-20 | NOOK-685 | "tests: InstallerTests.clineHookFiles writes through the managed TaskComplete symlink and then expects that path to survive uninstall" |
| 2026-10-15 | NOOK-418 | "security: a custom agent's install target is never shown to, or approved by, the user" |
| 2026-10-17 | NOOK-568 | "helper: launch-time re-registration tears down an approved helper and leaves it unregistered" |
| 2026-10-13 | NOOK-302 | "terminal: a tmux session shows only 'tmux', and its outer terminal comes from whoever started the tmux server" |
| 2026-10-13 | NOOK-310 | "terminal: jump and send address tmux panes on the default server, ignoring the session's tmux socket" |
| 2026-10-22 | NOOK-788 | "pane: a remote reply inside a private tmux server resolves to nothing, and a pane id can be sent without its socket" |
| 2026-10-12 | NOOK-290 | "Compact island: weather slot clips mid-glyph on non-notch displays (no text cap)" |
| 2026-10-12 | NOOK-291 | "Compact island: non-notch pill is 36 pt wider than its slot row (padding double-counted in pillWidth)" |
| 2026-10-14 | NOOK-386 | "island: hide the empty pill under the notch (or above the top edge) and keep a narrower hover trigger" |
| 2026-10-23 | NOOK-850 | "Nook: the idle app does work nothing asked for (CPU wins from a live profile)" |
| 2026-10-23 | NOOK-851 | "island: a 30 fps timeline ticks the fan icon even when the fan is idle" |
| 2026-10-23 | NOOK-877 | "island: compact Nook still costs too much CPU at rest (round two, A/B per fix)" |
| 2026-10-23 | NOOK-881 | "A macOS 14.0/14.1 Mac cannot launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol" |
| 2026-10-16 | NOOK-494 | "iOS: the main list gives no sign the phone is offline from the Mac" |
| 2026-10-20 | NOOK-682 | "licensing: a new user can be blocked at launch instead of getting the 14-day trial" |
| 2026-10-20 | NOOK-684 | "licensing: parseISO rejects the server's fractional-second trial timestamp, so an expired server trial resets to 14 days" |
| 2026-10-08 | NOOK-60 | "Capture email at trial start" |
## Appendix B — frequency method
- **Live questions:** one per entry that quotes the live page. There are six, and they are the only
questions on the page today.
- **Board questions:** the two examples in FRE-326. They are not customer quotes; they are the
board's own words for the purchase-blocking objections this issue was created to answer.
- **Report counts:** the number of distinct NOOK issues I read whose subject or resolution directly
produced the objection, listed by identifier and date in §2 and Appendix A. I did not use a raw
keyword count: "agent" appears in 344 issues and "privacy" in 177, which says nothing about what
buyers ask. The counts are deliberately small and inspectable.
- **No ask frequency, no percentages.** The first thing a real support corpus changes is the order,
not the wording.
---
*Draft ends. Nothing here is published. The page file stays unchanged until the sponsor approves this
revision and a human merges it.*

View File

@@ -1,81 +0,0 @@
diff --git a/src/routes/nook/faq.tsx b/src/routes/nook/faq.tsx
index 03d135a..bc3adef 100644
--- a/src/routes/nook/faq.tsx
+++ b/src/routes/nook/faq.tsx
@@ -1,8 +1,7 @@
/**
* `/faq` on the Nook subdomain — answers to the objections that actually
- * stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
- * "no prompt or code content leaves the device" (telemetry and the trial
- * email do leave — never claim "no data leaves the device").
+ * stop a purchase. Privacy is deliberately absent until the egress
+ * inventory and /privacy make an honest answer possible.
*/
import { For } from "solid-js";
import { createSignal, Show } from "solid-js";
@@ -17,34 +16,46 @@ interface FaqEntry {
const FAQ: FaqEntry[] = [
{
- question: "Does The Nook read my prompts or my code?",
+ question:
+ "Does it work with my agent — and can I approve from the notch, or only watch?",
answer:
- "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
+ "It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
},
{
- question: "Do I need a Mac with a notch?",
+ question: "What happens when my agent is idle?",
answer:
- "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
+ "Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session reappears on its next event instead of sitting there. If a session that is obviously working says idle, that is a bug; tell us."
},
{
- question: "How much battery does it cost?",
+ question:
+ "What permissions does it need — and what does it write into my agent's config?",
+ answer:
+ "The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app registers it on first use and takes you to System Settings › General › Login Items & Extensions, where you switch it on once. For each agent it adds what that agent needs — a hook group per event it listens to, or one plugin link — inside the agent's own config file or directory. It keeps its own files under ~/.nook — the hook binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook entry you added yourself in the same file survives install and uninstall, and it only touches the records and switches it created. Uninstall removes exactly what it wrote — and switches off what it turned on — with no leftover links or orphaned files."
+ },
+ {
+ question: "Does it work with my terminal, including tmux?",
answer:
- "The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
+ "Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those take a typed reply; the rest are watched. A session is tied to the terminal that started it. Jump lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the app's own window, and Alacritty takes no typed reply from another app at all."
},
{
- question: "What permissions does it need?",
+ question: "Do I need a Mac with a notch?",
+ answer:
+ "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it sits at the top centre of the display — the same pill with the same slots. You get one island on every display it is turned on for (all connected displays on first launch), each anchored to the top of its own screen; Settings › Displays trims that list, and the pointer only decides which display's floating controls are live. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
+ },
+ {
+ question: "How much battery does it cost?",
answer:
- "The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
+ "It is a native app, not a background agent. It watches the agents you already run and never runs them in the background, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
},
{
question: "Why macOS 14.2 or later?",
answer:
- "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
+ "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
},
{
question: "Does it work offline?",
answer:
- "Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
+ "The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation, crash reports and the optional trial email, plus a few things only when you use them: weather (asked for through our relay, with the location you chose), the network panel's IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
}
];
@@ -56,7 +67,7 @@ export default function NookFaqPage() {
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Frequently asked questions"
- description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
+ description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
ogTitle="The Nook — FAQ"
/>
<SubdomainHeader />

View File

@@ -1,40 +0,0 @@
diff --git a/marketing/positioning.md b/marketing/positioning.md
index 1bc4973..26d6b04 100644
--- a/marketing/positioning.md
+++ b/marketing/positioning.md
@@ -53,13 +53,20 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
If a claim cannot be pointed at code or a test, it does not go in copy.
-### 3. Honest privacy scoping
+### 3. No privacy claim until the egress inventory exists (withdrawn 2026-10-29)
-Telemetry and the trial email **do** leave the device. The defensible claim is:
+The claim that stood here — "No prompt or code content leaves the device" — is **false at HEAD**
+and must not be restated in any copy until it is verified and replaced. It was backed by an
+argument about envelope metadata, which says nothing about what leaves the machine. Three things
+at HEAD contradict it: crash reporting initialises with `sendDefaultPii = true`; the licence call
+sends the hardware UUID and the device name; the phone relay carries sealed prompt and reply
+payloads. `/privacy` names the hardware UUID and the device name and says nothing about crash
+reporting with PII or about the relay.
-> **No prompt or code content leaves the device.**
-
-Never "no data leaves the device".
+So: write **no** privacy claim at all — not softened, not partial. "Telemetry and the trial email
+do leave the device" is true and insufficient; it is not a substitute. The replacement goes in
+only after FRE-333 lands an egress inventory and `/privacy` text that match what the app does.
+See FRE-326 (the `/faq` withdrawal) and FRE-333 (the inventory).
### 4. Sell what works today
@@ -87,8 +94,7 @@ nothing posts itself.
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
-| macOS 14+ | 27 source files use the `@Observable` framework |
-| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
+| macOS 14.2+ | the CoreAudio overlay symbol the mixer uses only exists from 14.2 (NOOK-881) |
## Held claims (not publishable)

View File

@@ -1,92 +0,0 @@
# `/privacy` — replacement copy, FRE-354 corrections applied 2026-11-04
**Status:** applied on branch `faq/from-objections-2026-10-29` (commit after `bb6cf27`), per the CTO's FRE-354 verdict — "apply the corrections above and the page is signed." The two copy decisions the verdict delegated to the CMO are taken: the **fuller iPhone paragraph** (its push-token evidence row is recorded in the evidence table below and routed to FRE-333's owner to append to the signed inventory), and the **full (c) block with the LAN/tailnet sentence**, which pulls the corresponding Section 3 clause in. Nothing here is published; the sponsor's merge + push on card [`392a8465`](/FRE/issues/FRE-326#interaction-392a8465) is still the deploy step.
**Sequencing fact, updated 2026-11-06:** the contingency has **fired**. `v0.15.1` (tagged 2026-11-04, first tag containing FRE-355's merge `6e74b261`) and `v0.15.2` are live on the appcast (`freno.me/api/TheNook/appcast.xml`, newest item 0.15.2). Shipped behaviour when enabled: crash/error reports, session pings (`enableAutoSessionTracking` untouched), traces at 0.1, and **Sentry also receives the connection's IP address** (`sendDefaultPii = false` is the SDK default and does not suppress it; only the Sentry project's *Prevent Storing of IP Addresses* switch does — FRE-1480). Paragraph (b) and the iPhone paragraph are therefore swapped to the opt-in form in this commit, with two corrections against the pre-written swap text: pings and traces are named (the contingency's own completeness warning), and the IP sentence is kept. Releases 0.3.0 through 0.15.0 started Sentry on every launch with no off switch; the page describes the current release.
**Patch:** `marketing/drafts/privacy-page.patch` — applies cleanly to `c1c498e` (`git apply --check`, verified 2026-10-29; parse-checked with esbuild, not the site suite).
**Evidence base:** [FRE-333 `egress-inventory`](/FRE/issues/FRE-333#document-egress-inventory) rev 1, eleven destinations, file:line per row, read at `The-Nook` `3c3ba7ee`; and the same document's §G, which proposed a page text. This patch is the §G text **corrected against the inventory** — the differences are listed below, each with the row that forces it.
**Why this exists:** [FRE-326](/FRE/issues/FRE-326) + [FRE-334](/FRE/issues/FRE-334#document-decision) recorded option A — the privacy claim is withdrawn everywhere, and no privacy claim returns until the inventory exists, `/privacy` matches it, the CTO verifies both, and the sponsor approves the replacement copy. The inventory landed on 2026-10-29. This is the page half.
| Field | Value |
|---|---|
| Claim this page carries | None. It is a disclosure page, not a claim: it states what the app sends and what it does not. The one favourable sentence is (j), which is the CTO's FRE-322 §2 code fact and which he may still decline. |
| Audience | Someone deciding whether to pay $10 for a tool that reads their prompts. They read this page *before* the FAQ. |
| Channel | `nook.freno.me/privacy`, one page |
| Owner | CMO (copy + commit), CTO (verification), sponsor (approval of the replacement copy), Chief of Staff (publish switch) |
| Date | Prepared 2026-10-29. Applied 2026-11-04 on the branch with the FRE-354 corrections; the contingency (b)/iPhone swap applied 2026-11-06. `Last Updated` on the page now reads November 6, 2026 — re-check it against the actual deploy date at push. Not a public date. |
| What has to be true before it ships | 1) CTO signs the page against `The-Nook` HEAD, paragraph by paragraph — [FRE-354](/FRE/issues/FRE-354), due 2026-11-02. 2) Sponsor accepts the replacement copy. 3) The Sentry paragraph matches the build being deployed — see *Contingency*. 4) It deploys in the same push as the FAQ withdrawal; a page that discloses Sentry next to a FAQ that still says "anonymous usage telemetry" is worse than either alone. |
## What changed against the live page
The live page (fetched 2026-10-29T09:2xZ and again 09:4xZ, HTTP 200, `Last Updated: August 26, 2026`, unchanged) says the app "runs locally on your device and sends only the minimal data required for trial and licensing" and lists four things. Against the inventory that is wrong in two directions: it **omits** Sentry (crash reports with IP address, every launch, no off switch), the relay and its install ID, the licence key crossing to the relay, sealed phone payloads, weather coordinates going to the relay *and* to Apple, the GeoIP third parties, the speed test, the changelog fetch, bug-report contents and update checks. It also calls the hardware UUID "masked", which the code contradicts — it sends the raw `IOPlatformUUID`.
The replacement is organised by *connection*, not by *data type*, because that is how a reader checks it: eleven rows in the inventory, ten of them reachable from the page, one (Sentry) prominent because it is the only one a reader can turn off entirely and still miss in older builds. On the current Mac release (`v0.15.1`+) it is opt-in and off by default; the iPhone build in TestFlight (`ios-v0.3.2`, 2026-10-22) predates it. See *Contingency*.
## Differences from FRE-333 §G, and what forces each
§G is the Security Engineer's proposed text; this patch is that text corrected against §G's own inventory. Six deliberate differences, none of them stylistic:
| # | §G says | This patch says | Why |
|---|---|---|---|
| 1 | Opening: "processed on your device and are never sent to us" | Opening drops "never sent to us"; (c) and (j) state the two paths agent data does take | Sealed frames still transit our relay (§A.5, §B), and a remote machine carries them by design (§C). §G's own §B says the relay routes them. "Never sent to us" is the class of sentence that got the FAQ answer withdrawn. |
| 2 | (d) Weather: relay registration implied only with Phone | (d) adds that Weather registers **even with Phone off, and on a licensed Mac that registration presents the license key** | §A.3 + §D.2. `SidecarController.weather()` routes through the same `keystore.relayCredential(licenseKey:)` as the phone path (`SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`). §G under-disclosed it. |
| 3 | (a) trial/activate/status only | (a) adds `/deactivate` (key + UUID) and the trial email (email + UUID, sent once) | §A.2, `/D.4`. §G omitted both endpoints. |
| 4 | (i) "The email you give at trial start or checkout delivers your license key" | (i) checkout delivers the key; a trial email is one reminder before the trial ends | `LicenseService.submitTrialEmail` + `OnboardingExtrasStep` say the trial email is an expiry reminder; the key is issued at purchase. §G's sentence promised a key that the trial email does not deliver. |
| 5 | (b) Sentry: opt-in, "off unless you turn it on" | (b) is the opt-in form, applied 2026-11-06: off unless enabled in Settings, and it names session pings and the trace sample; **the IP sentence, kept** | The contingency fired with `v0.15.1`. Corrections against the pre-written swap text: pings/traces named because they still flow when enabled; IP kept because `sendDefaultPii = false` does not stop Sentry receiving the connection IP (FRE-1480); the Contingency text already said so. |
| 6 | No (j) | (j) carries the CTO's FRE-322 §2 code fact, with the remote-machine exception | Not required for truth; it is the one favourable sentence, and the CTO may decline it. Delete the block if he does. |
Two facts the inventory corrected and the page does not repeat wrongly: Sparkle's automatic checks are **off by default** here (`UpdateManager` owns the schedule; FRE-322 §2.4 said "on by default" and is stale on this row), and the UUID is **raw, not masked**.
## Paragraph-level evidence
| Paragraph | What it now says | Evidence at `3c3ba7ee` |
|---|---|---|
| (a) Licensing/trial | raw UUID + device name; every launch while trialing; key-only once licensed; deactivate sends key + UUID; trial email sends email + UUID once; trial/status fail open, activation needs a connection once | `LicenseService.swift:300-313` (`/trial`), `:271-284` (`/status`), `:247-262` (`/activate`), `:286-302` (`/deactivate`), `:219-231` (`/trial-email`) |
| (b) Crash reporting | opt-in, **off by default**; when enabled: crash/error reports, session pings, traces at 0.1; **Sentry also receives the IP address** (`Options.swift:267-275` at 9.26.1: `sendDefaultPii = false` is the default and does not suppress it); no prompts or code | `Sources/NookApp/main.swift` `startIfEnabled` — `sendDefaultPii = false` (`policy.sendDefaultPii`), `tracesSampleRate = 0.1` while on (`CrashReporting.onTracesSampleRate`); `CrashReporting.registerDefaults` pins `enabledKey: false`; evidence read 2026-11-06, IP row per FRE-1480 |
| (c) Phone relay | off by default; relay is the default path, LAN/tailnet direct pairing available; install ID + license key at registration; sealed payloads; relay sees device IDs/names/platforms, timing and volume, not content | `SidecarRelayAPI.swift:144-153`, `SidecarPayload.swift:484-490`, `RemoteEndpoint.swift:14-24`, `docs/sidecar-frames.md` (sealing), inventory §B |
| (d) Weather | 0.1° grid snapped **on the Mac**; registers with the relay even with the phone off, licence key included on a licensed Mac; Apple sees place lookups and position | `WeatherForecast.swift:25-30`, `SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`, `WeatherLocation.swift:14-77`, `:89-143` |
| (e) Network panel | third-party IP lookup sees your IP; speed test uploads zeros only | `NetworkInfoService.swift:82,276`, `SpeedTest.swift:10-11,36` |
| (f) Update checks | ship **off**; turning them on fetches `freno.me`, app version + IP | `UpdateManager.swift:48-55` (`automaticallyChecksForUpdates = false`, default key false) |
| (g) Changelog | fetches release notes from `freno.me` | `ChangelogRelease.swift:75-78` |
| (h) Bug reports | user-sent only; machine snapshot listed in full | `BugReportClient.swift:44-94`, `BugReportSnapshot.swift:17-36` |
| (i) Email/payment | checkout email delivers the key; trial email is a reminder; Stripe | `LicenseService.swift:219-231`, `OnboardingExtrasStep.swift:86-110` |
| (j) What does not leave your Mac | hook and bridge are not a cloud service; helper has no networking; two paths carry agent data across the network, both user-switched: remote machines over SSH or the tailnet, and the sealed phone relay | `hook/transport.go:143` (`resolveBridge`), `BridgeBindPlan.swift:9-23`, `RemoteEndpoint.swift:14-24`, inventory §B + §C |
| iPhone companion (TestFlight beta) | the TestFlight build (`ios-v0.3.2` = `2e322da5`, 2026-10-22) predates FRE-355: unconditional start, `sendDefaultPii = true`, traces 1.0, no off switch (`ios/AgentNookMobile/App.swift:13-20`); the opt-in code exists at `v0.15.2` but no `ios-v*` tag carries it; other traffic is the phone connection in (c) plus the APNs push token given to the relay | `RelayLink.pair` `RelayLink.swift:104-118`, `updatePushToken` `:226-232` — row added per FRE-354, TestFlight provenance per FRE-1480; append to the FRE-333 inventory |
## Contingency — the Sentry paragraph is the only one that moves
**FIRED 2026-11-06 — applied on the branch.** `v0.15.1` is the first release containing FRE-355 and is live on the appcast. The swap below was applied with the corrections recorded in the sequencing paragraph at the top of this document (pings and traces named; IP sentence kept — the pre-written text below had it, and dropping it was wrong; FRE-1480). The original text is kept for the record:
(b) as committed describes today's build. The inventory's recommendation (§E.1) is an opt-in setting, default off, dropping `sendDefaultPii` and cutting `tracesSampleRate`, on Mac **and** iOS. If that ships, (b) is replaced in the same commit that changes the behaviour:
> **(b) Crash and error reporting — off unless you turn it on.** If you enable crash reporting in Settings, The Nook sends crash and error reports to Sentry, our crash-reporting processor. Reports include the app version, macOS version, Mac model and what the app was doing when it failed, and Sentry also receives your IP address. They never include your prompts or code.
**Sequence call, and who owns it.** Shipping (b)-with-no-off-switch is honest, but it puts "no off switch" in front of a buyer during launch week. The better order is the build change first, then this page — that is a build/priority call, not mine: it is proposed on the board as [FRE-355](/FRE/issues/FRE-355) (Senior Engineer, date proposed 2026-11-06, CTO to confirm), and if the CTO says the build change cannot land before the page, the page ships with today's text. Publishing the truthful-today version is never *blocked* by that choice; the *preferred* order is the other way.
**This is why (b) must not be softened on the way out.** If the opt-in setting ships but `tracesSampleRate` or session tracking stays on, (b)'s opt-in form is incomplete again — it names only "crash and error reports" while performance traces and session pings still flow. The paragraph and the build change move together or neither does. (FRE-354 rewrote this warning: the original named a macOS username that sentry-cocoa never sends.)
## If the CTO declines paragraph (j)
(j) is the only favourable sentence on the page and the only one carrying a CTO verdict rather than an inventory row. It is [FRE-322 §2](/FRE/issues/FRE-322#document-verdict) — *"verified as a code fact, not publishable yet… publishing it before the disclosure work lands would put a true sentence next to false ones."* The disclosure work is this page. If the CTO still declines it, delete the whole (j) block; nothing else on the page depends on it, and the page stays complete and true.
## Open questions for the CTO's read
1. **The iOS app.** §D.10: the iOS target starts the same Sentry DSN with the same `sendDefaultPii` (`ios/AgentNookMobile/App.swift:12-20`). This page says "runs on your Mac". Is any iOS build in users' hands? If yes, the page needs one sentence (or the build needs the same opt-in); if no, say so and the page stays Mac-only.
2. **Weather registration without a licence key.** §E.3 suggests decoupling weather registration from the licence key. If that ships before the page, (d) loses its licence-key clause; if after, the page is true today.
3. **(j)** — publish, or cut.
## Apply checklist — what the apply commit must do, in order
1. Re-read the live page and the inventory's HEAD; if either moved, re-derive (a)–(j) before applying. The inventory is pinned to `3c3ba7ee`.
2. Bump `Last Updated` to the **deploy** date — the patch carries 2026-10-29 as prepared, and that line is the one place the date must change at apply.
3. If the Sentry opt-in build change is in the same release, swap (b) for the *Contingency* paragraph — one hunk.
4. Apply `privacy-page.patch` and the FRE-326 `faq-page.patch` + `positioning-privacy-withdrawal.patch` in the **same** commit, so no page states the old claim while another corrects it.
5. Run the site's suite — `bun test` — which this host cannot do (no `bun`; `node_modules` present, `package.json` test script is `bun test`). A parse/transpile check with esbuild is **not** that suite and must not be recorded as one.
## Rules this page obeys
- No competitor named (category nouns only, and none needed here).
- No claim without a row in the inventory. Where the inventory has no row, the page says nothing.
- No roadmap promise: the page never says a feature or fix is "coming", so the Sentry paragraph does not promise an off switch.
- No price, no discount, no date, no launch language.
- It is a correction of an existing page, not new marketing; nothing was added to sell anything.
- It does not add a `/faq` privacy answer. That answer's return is gated separately ([FRE-326](/FRE/issues/FRE-326) `privacy-return-path`).

View File

@@ -1,237 +0,0 @@
diff --git a/src/routes/nook/privacy.tsx b/src/routes/nook/privacy.tsx
index da0cc1d..db27d8c 100644
--- a/src/routes/nook/privacy.tsx
+++ b/src/routes/nook/privacy.tsx
@@ -1,9 +1,38 @@
/**
* The Nook privacy policy — `nook.freno.me/privacy`.
*
- * Reflects the actual data The Nook sends when licensing: a masked hardware
- * UUID + device name for trial/activation limits, the email used for purchase,
- * and Stripe payment processing.
+ * Every statement on this page is traced to the egress inventory at
+ * `The-Nook` HEAD (FRE-333, `document-egress-inventory`): eleven internet
+ * destinations, file:line evidence per row. When the app's egress changes,
+ * this page changes in the same commit — not later.
+ *
+ * The crash-reporting paragraphs ((b) and the iPhone companion) describe the
+ * build users run today: crash reporting is opt-in, off by default, on
+ * releases v0.15.1 and later — FRE-355 (merge `6e74b261`) first shipped in
+ * `v0.15.1` (2026-11-04), and the live appcast serves `v0.15.2`. When enabled,
+ * Sentry receives crash and error reports, session pings, and performance
+ * traces at a 0.1 sample rate (`tracesSampleRate = 0.1`), and it also receives
+ * the connection's IP address (`sendDefaultPii = false` is the SDK default and
+ * does not suppress it — sentry-cocoa 9.26.1 sets `infer_ip = auto` for Cocoa,
+ * and only the Sentry project's *Prevent Storing of IP Addresses* switch stops
+ * that; FRE-1480). Releases 0.3.0 through 0.15.0 started Sentry on every
+ * launch with `sendDefaultPii = true` and no off switch; this page describes
+ * the current release. The iPhone paragraph describes `ios-v0.3.2`
+ * (2026-10-22, the build in TestFlight) until an `ios-v*` tag carries
+ * FRE-355's opt-in code.
+ *
+ * The *Contingency* in `marketing/drafts/privacy-page.md` fired with `v0.15.1`
+ * and is applied in this commit, with two corrections against the pre-written
+ * swap text: the shipped build still sends session pings and traces while
+ * enabled, so they are named (the contingency's own completeness warning), and
+ * the IP sentence is kept — the pre-written swap text already had it, and
+ * `sendDefaultPii = false` does not stop Sentry receiving the IP (FRE-1480).
+ *
+ * Corrected against the CTO's /privacy verdict (FRE-354): no macOS username
+ * from Sentry (sendDefaultPii sets the IP only), session pings named, (a)
+ * fail-open scoped, (c) relay-path qualified with the direct-pair sentence,
+ * (j) "not a cloud service" with the tailnet path, the iPhone paragraph added
+ * (TestFlight beta is in users' hands), and section 3 says "content".
*/
import { buildMainSiteUrl } from "~/lib/subdomain-url";
import { PageHead } from "~/components/PageHead";
@@ -21,39 +50,139 @@ export default function NookPrivacyPolicy() {
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">The Nook&apos;s Privacy Policy</div>
- <div class="py-2">Last Updated: August 26, 2026</div>
+ <div class="py-2">Last Updated: November 6, 2026</div>
<div class="py-2">
- Welcome to The Nook (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
- Your privacy is important to us. This policy explains what we collect,
- why, and how it is handled. The app itself runs locally on your device
- and sends only the minimal data required for trial and licensing.
+ The Nook runs on your Mac, and the iPhone and Apple Watch companion
+ is in TestFlight beta. Your prompts, your code and your agent
+ sessions are processed on your own devices. This page lists every
+ connection the apps make over the internet — and the local-network
+ and tailnet paths the phone can use — what each one sends, and
+ whether you can turn it off.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
- <span class="-ml-4 pr-2">1.</span> Data We Collect
+ <span class="-ml-4 pr-2">1.</span> What leaves your devices
</div>
<div class="pl-4">
<div class="pb-2">
- <div class="-ml-6">(a) Device hardware UUID:</div> When you start
- a trial or activate a license, The Nook sends a hardware UUID
- from your Mac. It is used solely to enforce the 14-day trial and
- the 3-device activation limit, and to prevent resetting the
- trial by reinstalling the app.
+ <div class="-ml-6">(a) Licensing and trial:</div> When you start
+ a trial or activate a license, The Nook sends your Mac&apos;s
+ hardware UUID and device name to our licensing server
+ (freno.me). The UUID enforces the 14-day trial and the 3-device
+ activation limit and stops a trial being reset by reinstalling
+ the app; the device name is stored with the activation so the
+ Mac can be identified in your license records. While you are on
+ the trial, this is sent at each launch. Once you are licensed,
+ each launch re-checks only that your key has not been revoked.
+ Deactivating a Mac sends the license key and that Mac&apos;s
+ UUID. If you add an email during the trial, that request
+ carries the email and the UUID, and it is sent once. The trial
+ and status checks fail open: with no connection, an unexpired
+ trial or a stored license keeps working, because license
+ verification itself is offline. Activating a new license needs
+ a connection once.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(b) Crash and error reporting — off unless
+ you turn it on:</div> Crash reporting is off by default and
+ runs only if you enable it in Settings. When it is on, The Nook
+ sends crash reports, error events, hang reports, session pings,
+ and a sample of performance traces to Sentry, our
+ crash-reporting processor. Each report carries the app version,
+ the macOS version, the Mac model and what the app was doing
+ when it failed. Sentry also receives your IP address. They
+ never include your prompts, your code or your session content.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(c) Phone connection:</div> The phone
+ connection ships off and is yours to enable. With the default
+ relay path, turning it on registers your Mac with our relay
+ (nook-relay.freno.me) using a random install ID, and a licensed
+ Mac presents its license key once at registration. Everything
+ your Mac and your phone then exchange — session updates,
+ approvals, replies — is encrypted end-to-end: the relay routes
+ it and cannot read it. On the relay path, the relay can see
+ which devices are connected — their IDs, names and platforms —
+ when, and how much traffic they exchange.
+ </div>
+ <div class="pb-2">
+ You can also pair over your LAN or tailnet instead; those paths
+ carry the same sealed traffic and do not use the relay.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(d) Weather:</div> If you add the Weather
+ module, your Mac asks our relay for forecasts using coordinates
+ rounded to a 0.1° grid — about 11 km — and the rounding happens
+ on your Mac before anything is sent. The Weather module
+ registers with the relay for this even if you never turn on the
+ phone connection, and on a licensed Mac that registration
+ presents the license key, exactly as the phone connection&apos;s
+ does. &quot;My Location&quot; asks macOS for your
+ approximate position, and naming or searching for a place uses
+ Apple&apos;s Maps service, so Apple sees those lookups. Neither
+ feature sends us your precise location.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(e) The Network panel:</div> While the
+ Network panel&apos;s connection section is on screen, The Nook
+ asks a third-party IP-lookup service (get.geojs.io, with
+ ipwho.is as backup) what your public IP address and provider
+ are — that is how the panel can show them to you. Those services
+ necessarily see your IP address. The speed-test button measures
+ against Cloudflare&apos;s public speed endpoint and uploads only
+ zeros, never your data.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(f) Update checks:</div> Automatic update
+ checks ship off. If you turn them on, or press &quot;Check for
+ Updates&quot;, The Nook fetches the update feed from freno.me
+ and the request carries your app version. Like every request on
+ this page, it also arrives from your IP address.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(g) Changelog:</div> Opening Settings ›
+ Changelog fetches the release notes from freno.me.
+ </div>
+ <div class="pb-2">
+ <div class="-ml-6">(h) Bug reports:</div> only what you choose
+ to send. If you write a bug report, you decide what it says.
+ Bug reports also include a machine snapshot — macOS version, Mac
+ model, CPU, memory, free disk, locale, and your displays&apos;
+ names and resolutions — so the problem can be reproduced. The
+ optional contact email is used only to reply to you.
</div>
<div class="pb-2">
- <div class="-ml-6">(b) Device name:</div> The Nook sends your
- Mac&apos;s name so you can recognize the device in your license
- activations. It is not used for any other purpose.
+ <div class="-ml-6">(i) Your email and payment:</div> The email
+ you give at checkout delivers your license key and looks up your
+ order. An email you add during the trial is used for one
+ reminder before the trial ends. Payments are processed by
+ Stripe; we never see or store your card details.
</div>
<div class="pb-2">
- <div class="-ml-6">(c) Email:</div> The email you provide at
- checkout is used to deliver your license key and to look up your
- order.
+ <div class="-ml-6">(j) What does not leave your Mac:</div> The
+ hook and the bridge that connect your agents to The Nook are
+ not a cloud service: the hook dials the bridge on your Mac, and
+ the bridge only listens. Two paths carry agent data across the
+ network, and both are switched on by you: if you add a remote
+ machine, the hook on that machine dials this Mac over SSH or
+ your tailnet; and if you use the phone connection, the session
+ updates, approvals and replies described in (c) travel through
+ the relay, sealed end to end. The privileged helper that
+ controls fans and charging has no networking code at all.
</div>
<div class="pb-2">
- <div class="-ml-6">(d) Payment:</div> Payments are processed by
- Stripe. We never see or store your card details.
+ <div class="-ml-6">The iPhone companion.</div> The iPhone and
+ Apple Watch app is in TestFlight beta. The build in TestFlight
+ starts Sentry on every launch, with no setting that turns it
+ off: it sends crash, error and hang reports, session pings,
+ performance traces, the app version, the iOS version, the
+ iPhone model and your IP address. Those reports never include
+ your prompts, code or session content.
+ Its other network traffic is the phone connection in (c) — the
+ relay, or a direct LAN/tailnet link — plus, if you allow
+ notifications, the push token it gives the relay so Apple can
+ deliver them.
</div>
</div>
</div>
@@ -63,9 +192,11 @@ export default function NookPrivacyPolicy() {
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
- Device identifiers, your email, and license records are used only
- to operate the trial, deliver purchases, and enforce the 3-device
- license. We do not use them for advertising or sell them to anyone.
+ Device identifiers, install IDs, your email and license records are
+ used only to operate the trial and the licenses, to run the relay
+ and the weather service, to answer bug reports, and to deliver
+ purchases. We do not use them for advertising and we do not sell
+ them to anyone.
</div>
</div>
@@ -74,9 +205,13 @@ export default function NookPrivacyPolicy() {
<span class="-ml-4 pr-2">3.</span> Data Security
</div>
<div class="pb-2 pl-4">
- License keys are signed and verified with Ed25519. The connection
- to our servers uses TLS. Your license key is stored in the macOS
- Keychain on your Mac.
+ License keys are signed and verified with Ed25519 and stored in
+ the macOS Keychain. The session content your Mac and phone
+ exchange is encrypted end-to-end with keys your devices generate
+ at pairing; our relay holds no key that can read it, though it
+ can read the routing metadata described in (c). Every internet
+ connection described on this page uses TLS; the direct
+ LAN/tailnet paths use the same end-to-end encryption.
</div>
</div>

View File

@@ -1,6 +1,9 @@
# Remote SSH copy — HELD, do not publish
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
correction, and **not yet off the live site** — the deploy is blocked on a write
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
serves every sentence below until that push or a Vercel deploy lands.
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
verifies that flow, and a recording shows a remote agent joining the island.

View File

@@ -10,10 +10,12 @@ credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
unverified and demo-gated. The CTO named it the largest launch risk because it
was already in front of buyers.
So the landing page now says only what the product can back today: local agents,
the card, and the notch. The remote story is not deleted — it is parked here with
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
is recorded.
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
install ships and is recorded.
## What has to be true before it returns

View File

@@ -1,4 +1,51 @@
# Weekly funnel snapshot
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
Format: the funnel numbers, what moved, and the one thing worth acting on.
Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days). Previous: 2026-10-15 16:00:00 → 2026-10-22 16:00:00 UTC.
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
## Funnel
| Stage | This week | Last week | Change |
|---|---:|---:|---:|
| visit (page views) | 61 | 56 | +5 |
| download click → DMG served (one label) † | 156 | 0 | +156 |
| trial started | 2 | 0 | +2 |
| checkout page visit † | 0 | 0 | 0 |
| purchase (licence issued) | 0 | 0 | 0 |
| activation | 0 | 0 | 0 |
## Campaigns
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
| Source | Medium | Campaign | Rows |
|---|---|---|---:|
| hn | post | launch | 2 |
| direct | — | deploycheck-direct | 1 |
| hn | — | flushproof | 1 |
| hn | post | deploycheck-ref | 1 |
| r_macapps | — | week2 | 1 |
| x | — | — | 1 |
7 of 356 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
## The one thing that moved
**download click → DMG served (one label): 0 → 156 (+156).**
## Before acting on these numbers
- **356 of 356 rows in the window came from a loopback address** (127.0.0.1 / ::1); every one of the 12035 addressed rows the table has ever held is loopback (13 carry no address). Either this host is the only audience, or the app sits behind a local proxy and `getRequestIP` records the proxy, not the visitor. Either way the address identifies nobody — "unique visitors" collapses to one host and per-channel reach cannot be read from it.
- **The download stage is one label, not two.** The landing-door route and the file route that serves the DMG both write `/api/downloads/thenook`, so "download click → DMG served" cannot be split: one served click writes two rows, a click that fails to resolve writes one, and a Sparkle update writes one. Of this window's 156 download rows, 156 are loopback and 117 are not browser user agents (curl, Sparkle, link-preview crawlers).
- **Checkout start is not recorded.** `POST /api/the-nook/checkout` — the call that actually creates the Stripe session — writes nothing to analytics; the only trace is the /checkout page view reported above, which is a page, not a started checkout.
- **There is no host dimension.** `VisitorAnalytics` records a path, not the site, so the Nook's landing page (`/` on nook.freno.me) is the same row as freno.me's root. "visit" above is site-wide page views, not Nook visits.
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
† Proxy or merged label — see *Before acting on these numbers* above.
## Sources
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
- Generated 2026-10-29T16:00:00Z.

View File

@@ -4,13 +4,37 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging.
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
no longer exists; the verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
## The sentence
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
> fleet in the notch — thirteen agents — so you see what is running,
> unblock it in one tap, and keep the machine from overheating while it works.
There is no single approved public sentence yet, and saying otherwise is the one
mistake this file exists to prevent. What exists is a **launch claim set** — two
sentences, both from one 15-second take, recorded as the chosen route by the Chief
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
| Slot | Exact sentence | Status today |
|---|---|---|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
Working line for internal writing, not for publication:
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
> see what is running, you unblock what is waiting, and the machine stays cool
> while it works.
## The wedge
@@ -22,15 +46,18 @@ The thing to say over and over, in every channel, in slightly different words:
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
Allow, work resumes. Ten seconds, no explanation needed.
## The bundling reframe
## The consolidation reframe
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
app reads like a grab bag. Reframe as consolidation, which is the honest story:
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
> stats strip, and a notification hack. Same one-time purchase, no subscription.
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
> strip, and a notification hack. One purchase, no subscription.
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
**No price, no discount and no future price in copy.** The price is $10, it stays
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
price change is a Chief of Staff + sponsor call; it is never a marketing decision
and never a launch asset.
## Hard rules
@@ -48,30 +75,49 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
community replies written by a human, where quoting what a user said about another
tool is normal conversation.
### 2. A claim requires a test to exist
### 2. A claim needs a verdict, not just a test
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
If a claim cannot be pointed at code or a test, it does not go in copy.
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
tables below, it does not go in copy.
### 3. Honest privacy scoping
Telemetry and the trial email **do** leave the device. The defensible claim is:
Telemetry and the licence check **do** leave the device: Sentry runs on **every
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
> **No prompt or code content leaves the device.**
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
- "nothing leaves your Mac"
Never "no data leaves the device".
The one favourable engine sentence — about the **hook and bridge only**, never
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
service**. Do not write "talk to nothing but your Mac" (too absolute: the
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
it to "over SSH" (a tailnet peer dials directly).
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
**held** until the disclosures land: Sentry gated behind a preference or removed,
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
false ones is worse than no sentence.
### 4. Sell what works today
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
parentheticals. The launch story is the approval card: an agent blocked on a
permission prompt, unblocked from the Mac's notch, and work resumes.
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **held**, not approved. The one-click install has not shipped and
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
demo exists.
Remote SSH is **held**, not approved: the one-click install has not
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
recording shows a remote agent joining the notch.
### 5. Humans hold every account
@@ -79,28 +125,29 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself.
## Approved claims (each backed by code)
## Claims: status at 2026-10-29
| Claim | Backing |
|---|---|
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
| macOS 14+ | 27 source files use the `@Observable` framework |
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
## Held claims (not publishable)
| Claim | Why it is held | What has to be true first |
| Claim | Status | Evidence / next step |
|---|---|---|
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
"one tap", "$10 once").
- The notch/island is the product's home; agents are the product's point.
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
counts (see the excluded row above).
- The notch is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
"game-changer", "seamlessly". Unverifiable superlatives generally.
"game-changer", "seamlessly", any unverifiable superlative — and the specific
banned sentences in rule 3 and the table above.

View File

@@ -12,6 +12,7 @@
"test:coverage": "bun test --coverage",
"perf": "bun run scripts/perf-test.ts",
"perf:compare": "bun run scripts/perf-compare.ts",
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
},
"dependencies": {

153
scripts/weekly-metrics.ts Normal file
View File

@@ -0,0 +1,153 @@
import { createClient } from "@libsql/client/web";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
getWeeklyReport,
renderWeeklyMarkdown,
type Connections
} from "~/server/weekly-metrics";
/**
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
*
* bun --env-file=.env scripts/weekly-metrics.ts
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
*
* Required environment (all four; the analytics token also opens the licence
* DB, which is why the weekly job can run from a checkout that has no Stripe
* or Apple secrets):
*
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
*
* This script deliberately does not import `~/env/server`: that schema demands
* every production secret, and the metrics job needs two databases and nothing
* else.
*
* The output path is anchored at the repo root, so running it from any cwd
* writes the same file.
*/
interface Options {
days: number;
now: Date;
out: string;
stdout: boolean;
}
function usage(): string {
return [
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
"",
" --days N window length in days (default 7)",
" --now ISO end of the window (default: now)",
" --out PATH where to write (default: marketing/metrics/weekly.md)",
" --stdout print the report instead of writing a file"
].join("\n");
}
function parseArgs(argv: string[]): Options {
const repoRoot = resolve(import.meta.dir, "..");
const options: Options = {
days: 7,
now: new Date(),
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
stdout: false
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const value = () => {
const next = argv[i + 1];
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
i += 1;
return next;
};
switch (arg) {
case "--days": {
const days = Number(value());
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`--days must be a positive number\n\n${usage()}`);
}
options.days = days;
break;
}
case "--now": {
const now = new Date(value());
if (Number.isNaN(now.getTime())) {
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
}
options.now = now;
break;
}
case "--out":
options.out = resolve(value());
break;
case "--stdout":
options.stdout = true;
break;
case "-h":
case "--help":
console.log(usage());
process.exit(0);
default:
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
}
}
return options;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value || value.trim() === "") {
throw new Error(
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
);
}
return value;
}
function connect(url: string, token: string) {
return createClient({ url, authToken: token });
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const connections: Connections = {
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
};
const report = await getWeeklyReport(connections, {
days: options.days,
now: options.now
});
const markdown = renderWeeklyMarkdown(report);
if (options.stdout) {
process.stdout.write(markdown);
return;
}
await mkdir(dirname(options.out), { recursive: true });
await writeFile(options.out, markdown, "utf8");
const f = report.counts;
console.log(
`wrote ${options.out}\n` +
`window ${report.window.start} → ${report.window.end}\n` +
`visit ${f.visit}, download ${f.download}, trial ${f.trial}, ` +
`checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -851,7 +851,7 @@ export default function FeatureBreakdowns() {
title="Your whole fleet, in one pixel strip"
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
bullets={[
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more",
"Custom agents: your own coding agent can wire itself in from the published contract",
"Slot rankings — you choose which data earns the notch",
"You don't need a notch — external displays get the same island, floating top-center"

View File

@@ -722,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [
"13 agents",
"Agents wired in",
"Custom agents",
"Six chiptunes",
"Focus-aware slots",

View File

@@ -1,5 +1,6 @@
/**
* Regression test for the /agents roster (NOOK-336).
* Regression test for the /agents roster (NOOK-336) and its coverage wording
* (FRE-359).
*
* The page mirrors the app's registry, `AgentSpec.all` in
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
@@ -8,6 +9,12 @@
* them. The registry lives in another repo, so the expected roster is
* restated here; when an agent is added there, add it here and on the page.
*
* FRE-359 verdict B: the page no longer states a count or claims coverage
* ("all thirteen", "every coding agent The Nook supports"). The page still
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its
* regeneration is its own ticket — so the roster check here is a subset
* check in registry order, not an equality check.
*
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/
import { describe, it, expect } from "bun:test";
@@ -16,7 +23,10 @@ import { join } from "node:path";
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
/** AgentSpec.all display names, in registry order. */
/** User-facing copy only: comments are not copy and may name the old wording. */
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
const REGISTRY = [
"Claude Code",
"Codex",
@@ -30,25 +40,59 @@ const REGISTRY = [
"Gemini CLI",
"Cline",
"Qwen Code",
"DeepSeek"
"DeepSeek",
"Kimi",
"GitHub Copilot CLI",
"Crush",
"Cursor",
"Amp"
];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
/** The page's rows are a subset of the registry, in registry order. */
function subsetInOrder(page: string[], registry: string[]): boolean {
let cursor = 0;
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => {
it("lists every registry agent, in registry order", () => {
expect(entries).toEqual(REGISTRY);
it("lists only registry agents, in registry order", () => {
expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
});
it("states the same count everywhere it names one", () => {
const word = NUMBER_WORDS[entries.length - 12];
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
expect(SOURCE.toLowerCase()).not.toContain(other);
}
expect(SOURCE).toContain(`all ${word}`);
expect(SOURCE).toContain(`The ${entries.length} agents`);
expect(SOURCE).toContain(`watches ${word} coding agents`);
it("lists each agent at most once", () => {
expect(new Set(entries).size).toBe(entries.length);
});
});
describe("Nook /agents coverage wording (FRE-359)", () => {
it("states no agent count anywhere on the page", () => {
for (const word of NUMBER_WORDS) {
expect(COPY.toLowerCase()).not.toContain(word);
}
// A bare numeral count ("13 agents", "The 13") is just as precise and just
// as unverified; the page must not state one in any form.
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
});
it("makes no completeness claim over the agent set", () => {
for (const banned of [
"every coding agent",
"the agents the nook supports",
"agents the nook supports",
"supported agents"
]) {
expect(COPY.toLowerCase()).not.toContain(banned);
}
});
});

View File

@@ -1,5 +1,7 @@
/**
* `/agents` on the Nook subdomain — supported agents and their event coverage.
* `/agents` on the Nook subdomain — the agent roster and each row's event
* coverage. It states no agent count and claims no completeness over the
* registry (FRE-359 verdict B).
*
* The registry below mirrors `AgentSpec.all` in the app
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
@@ -282,26 +284,25 @@ export default function NookAgentsPage() {
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Supported agents"
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The 13 agents The Nook supports, and what each hook sees"
title="Agent coverage"
description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The coding agents The Nook connects to, and what each hook sees"
/>
<SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Supported agents
Agent coverage
</h1>
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
The Nook watches thirteen coding agents in one place — your menu bar and
notch show every session that is running, thinking, or blocked, and a
card answers permission prompts without leaving your keyboard.
The Nook watches coding agents in one place — your menu bar and notch
show sessions that are running, thinking, or blocked, and a card can
answer a permission prompt without leaving your keyboard.
</p>
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
Each agent reports its own set of events. "Allow / Deny from a card"
means the agent hands the decision to The Nook while it waits;
"Observe" means every event still appears live, but the agent keeps
its own prompting. Agents can run on this Mac or on remote machines
over SSH — remote sessions report into the same grid.
its own prompting.
</p>
<div class="grid gap-4 lg:grid-cols-2">

View File

@@ -1,7 +1,8 @@
/**
* `/faq` on the Nook subdomain — answers to the objections that actually
* stop a purchase. Privacy is deliberately absent until the egress
* inventory and /privacy make an honest answer possible.
* stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
* "no prompt or code content leaves the device" (telemetry and the trial
* email do leave — never claim "no data leaves the device").
*/
import { For } from "solid-js";
import { createSignal, Show } from "solid-js";
@@ -16,46 +17,34 @@ interface FaqEntry {
const FAQ: FaqEntry[] = [
{
question:
"Does it work with my agent — and can I approve from the notch, or only watch?",
question: "Does The Nook read my prompts or my code?",
answer:
"It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
},
{
question: "What happens when my agent is idle?",
answer:
"Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session reappears on its next event instead of sitting there. If a session that is obviously working says idle, that is a bug; tell us."
},
{
question:
"What permissions does it need — and what does it write into my agent's config?",
answer:
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app registers it on first use and takes you to System Settings › General › Login Items & Extensions, where you switch it on once. For each agent it adds what that agent needs — a hook group per event it listens to, or one plugin link — inside the agent's own config file or directory. It keeps its own files under ~/.nook — the hook binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook entry you added yourself in the same file survives install and uninstall, and it only touches the records and switches it created. Uninstall removes exactly what it wrote — and switches off what it turned on — with no leftover links or orphaned files."
},
{
question: "Does it work with my terminal, including tmux?",
answer:
"Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those take a typed reply; the rest are watched. A session is tied to the terminal that started it. Jump lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the app's own window, and Alacritty takes no typed reply from another app at all."
"No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
},
{
question: "Do I need a Mac with a notch?",
answer:
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it sits at the top centre of the display — the same pill with the same slots. You get one island on every display it is turned on for (all connected displays on first launch), each anchored to the top of its own screen; Settings › Displays trims that list, and the pointer only decides which display's floating controls are live. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
},
{
question: "How much battery does it cost?",
answer:
"It is a native app, not a background agent. It watches the agents you already run and never runs them in the background, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
"The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
},
{
question: "What permissions does it need?",
answer:
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
},
{
question: "Why macOS 14.2 or later?",
answer:
"The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
"The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
},
{
question: "Does it work offline?",
answer:
"The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation, crash reports and the optional trial email, plus a few things only when you use them: weather (asked for through our relay, with the location you chose), the network panel's IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
"Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
}
];
@@ -67,7 +56,7 @@ export default function NookFaqPage() {
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Frequently asked questions"
description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
ogTitle="The Nook — FAQ"
/>
<SubdomainHeader />

View File

@@ -1,38 +1,9 @@
/**
* The Nook privacy policy — `nook.freno.me/privacy`.
*
* Every statement on this page is traced to the egress inventory at
* `The-Nook` HEAD (FRE-333, `document-egress-inventory`): eleven internet
* destinations, file:line evidence per row. When the app's egress changes,
* this page changes in the same commit — not later.
*
* The crash-reporting paragraphs ((b) and the iPhone companion) describe the
* build users run today: crash reporting is opt-in, off by default, on
* releases v0.15.1 and later — FRE-355 (merge `6e74b261`) first shipped in
* `v0.15.1` (2026-11-04), and the live appcast serves `v0.15.2`. When enabled,
* Sentry receives crash and error reports, session pings, and performance
* traces at a 0.1 sample rate (`tracesSampleRate = 0.1`), and it also receives
* the connection's IP address (`sendDefaultPii = false` is the SDK default and
* does not suppress it — sentry-cocoa 9.26.1 sets `infer_ip = auto` for Cocoa,
* and only the Sentry project's *Prevent Storing of IP Addresses* switch stops
* that; FRE-1480). Releases 0.3.0 through 0.15.0 started Sentry on every
* launch with `sendDefaultPii = true` and no off switch; this page describes
* the current release. The iPhone paragraph describes `ios-v0.3.2`
* (2026-10-22, the build in TestFlight) until an `ios-v*` tag carries
* FRE-355's opt-in code.
*
* The *Contingency* in `marketing/drafts/privacy-page.md` fired with `v0.15.1`
* and is applied in this commit, with two corrections against the pre-written
* swap text: the shipped build still sends session pings and traces while
* enabled, so they are named (the contingency's own completeness warning), and
* the IP sentence is kept — the pre-written swap text already had it, and
* `sendDefaultPii = false` does not stop Sentry receiving the IP (FRE-1480).
*
* Corrected against the CTO's /privacy verdict (FRE-354): no macOS username
* from Sentry (sendDefaultPii sets the IP only), session pings named, (a)
* fail-open scoped, (c) relay-path qualified with the direct-pair sentence,
* (j) "not a cloud service" with the tailnet path, the iPhone paragraph added
* (TestFlight beta is in users' hands), and section 3 says "content".
* Reflects the actual data The Nook sends when licensing: a masked hardware
* UUID + device name for trial/activation limits, the email used for purchase,
* and Stripe payment processing.
*/
import { buildMainSiteUrl } from "~/lib/subdomain-url";
import { PageHead } from "~/components/PageHead";
@@ -50,139 +21,39 @@ export default function NookPrivacyPolicy() {
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">The Nook&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: November 6, 2026</div>
<div class="py-2">Last Updated: August 26, 2026</div>
<div class="py-2">
The Nook runs on your Mac, and the iPhone and Apple Watch companion
is in TestFlight beta. Your prompts, your code and your agent
sessions are processed on your own devices. This page lists every
connection the apps make over the internet — and the local-network
and tailnet paths the phone can use — what each one sends, and
whether you can turn it off.
Welcome to The Nook (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This policy explains what we collect,
why, and how it is handled. The app itself runs locally on your device
and sends only the minimal data required for trial and licensing.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> What leaves your devices
<span class="-ml-4 pr-2">1.</span> Data We Collect
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Licensing and trial:</div> When you start
a trial or activate a license, The Nook sends your Mac&apos;s
hardware UUID and device name to our licensing server
(freno.me). The UUID enforces the 14-day trial and the 3-device
activation limit and stops a trial being reset by reinstalling
the app; the device name is stored with the activation so the
Mac can be identified in your license records. While you are on
the trial, this is sent at each launch. Once you are licensed,
each launch re-checks only that your key has not been revoked.
Deactivating a Mac sends the license key and that Mac&apos;s
UUID. If you add an email during the trial, that request
carries the email and the UUID, and it is sent once. The trial
and status checks fail open: with no connection, an unexpired
trial or a stored license keeps working, because license
verification itself is offline. Activating a new license needs
a connection once.
<div class="-ml-6">(a) Device hardware UUID:</div> When you start
a trial or activate a license, The Nook sends a hardware UUID
from your Mac. It is used solely to enforce the 14-day trial and
the 3-device activation limit, and to prevent resetting the
trial by reinstalling the app.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Crash and error reporting — off unless
you turn it on:</div> Crash reporting is off by default and
runs only if you enable it in Settings. When it is on, The Nook
sends crash reports, error events, hang reports, session pings,
and a sample of performance traces to Sentry, our
crash-reporting processor. Each report carries the app version,
the macOS version, the Mac model and what the app was doing
when it failed. Sentry also receives your IP address. They
never include your prompts, your code or your session content.
<div class="-ml-6">(b) Device name:</div> The Nook sends your
Mac&apos;s name so you can recognize the device in your license
activations. It is not used for any other purpose.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Phone connection:</div> The phone
connection ships off and is yours to enable. With the default
relay path, turning it on registers your Mac with our relay
(nook-relay.freno.me) using a random install ID, and a licensed
Mac presents its license key once at registration. Everything
your Mac and your phone then exchange — session updates,
approvals, replies — is encrypted end-to-end: the relay routes
it and cannot read it. On the relay path, the relay can see
which devices are connected — their IDs, names and platforms —
when, and how much traffic they exchange.
<div class="-ml-6">(c) Email:</div> The email you provide at
checkout is used to deliver your license key and to look up your
order.
</div>
<div class="pb-2">
You can also pair over your LAN or tailnet instead; those paths
carry the same sealed traffic and do not use the relay.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Weather:</div> If you add the Weather
module, your Mac asks our relay for forecasts using coordinates
rounded to a 0.1° grid — about 11 km — and the rounding happens
on your Mac before anything is sent. The Weather module
registers with the relay for this even if you never turn on the
phone connection, and on a licensed Mac that registration
presents the license key, exactly as the phone connection&apos;s
does. &quot;My Location&quot; asks macOS for your
approximate position, and naming or searching for a place uses
Apple&apos;s Maps service, so Apple sees those lookups. Neither
feature sends us your precise location.
</div>
<div class="pb-2">
<div class="-ml-6">(e) The Network panel:</div> While the
Network panel&apos;s connection section is on screen, The Nook
asks a third-party IP-lookup service (get.geojs.io, with
ipwho.is as backup) what your public IP address and provider
are — that is how the panel can show them to you. Those services
necessarily see your IP address. The speed-test button measures
against Cloudflare&apos;s public speed endpoint and uploads only
zeros, never your data.
</div>
<div class="pb-2">
<div class="-ml-6">(f) Update checks:</div> Automatic update
checks ship off. If you turn them on, or press &quot;Check for
Updates&quot;, The Nook fetches the update feed from freno.me
and the request carries your app version. Like every request on
this page, it also arrives from your IP address.
</div>
<div class="pb-2">
<div class="-ml-6">(g) Changelog:</div> Opening Settings ›
Changelog fetches the release notes from freno.me.
</div>
<div class="pb-2">
<div class="-ml-6">(h) Bug reports:</div> only what you choose
to send. If you write a bug report, you decide what it says.
Bug reports also include a machine snapshot — macOS version, Mac
model, CPU, memory, free disk, locale, and your displays&apos;
names and resolutions — so the problem can be reproduced. The
optional contact email is used only to reply to you.
</div>
<div class="pb-2">
<div class="-ml-6">(i) Your email and payment:</div> The email
you give at checkout delivers your license key and looks up your
order. An email you add during the trial is used for one
reminder before the trial ends. Payments are processed by
Stripe; we never see or store your card details.
</div>
<div class="pb-2">
<div class="-ml-6">(j) What does not leave your Mac:</div> The
hook and the bridge that connect your agents to The Nook are
not a cloud service: the hook dials the bridge on your Mac, and
the bridge only listens. Two paths carry agent data across the
network, and both are switched on by you: if you add a remote
machine, the hook on that machine dials this Mac over SSH or
your tailnet; and if you use the phone connection, the session
updates, approvals and replies described in (c) travel through
the relay, sealed end to end. The privileged helper that
controls fans and charging has no networking code at all.
</div>
<div class="pb-2">
<div class="-ml-6">The iPhone companion.</div> The iPhone and
Apple Watch app is in TestFlight beta. The build in TestFlight
starts Sentry on every launch, with no setting that turns it
off: it sends crash, error and hang reports, session pings,
performance traces, the app version, the iOS version, the
iPhone model and your IP address. Those reports never include
your prompts, code or session content.
Its other network traffic is the phone connection in (c) — the
relay, or a direct LAN/tailnet link — plus, if you allow
notifications, the push token it gives the relay so Apple can
deliver them.
<div class="-ml-6">(d) Payment:</div> Payments are processed by
Stripe. We never see or store your card details.
</div>
</div>
</div>
@@ -192,11 +63,9 @@ export default function NookPrivacyPolicy() {
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
Device identifiers, install IDs, your email and license records are
used only to operate the trial and the licenses, to run the relay
and the weather service, to answer bug reports, and to deliver
purchases. We do not use them for advertising and we do not sell
them to anyone.
Device identifiers, your email, and license records are used only
to operate the trial, deliver purchases, and enforce the 3-device
license. We do not use them for advertising or sell them to anyone.
</div>
</div>
@@ -205,13 +74,9 @@ export default function NookPrivacyPolicy() {
<span class="-ml-4 pr-2">3.</span> Data Security
</div>
<div class="pb-2 pl-4">
License keys are signed and verified with Ed25519 and stored in
the macOS Keychain. The session content your Mac and phone
exchange is encrypted end-to-end with keys your devices generate
at pairing; our relay holds no key that can read it, though it
can read the routing metadata described in (c). Every internet
connection described on this page uses TLS; the direct
LAN/tailnet paths use the same end-to-end encryption.
License keys are signed and verified with Ed25519. The connection
to our servers uses TLS. Your license key is stored in the macOS
Keychain on your Mac.
</div>
</div>

View File

@@ -0,0 +1,114 @@
/**
* Regression test for the removed remote / SSH claims and the removed agent
* count (FRE-323, FRE-359 verdict B).
*
* FRE-323 took the remote story off the site because it was unverified and
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
* needs a one-time `ssh-copy-id` because the app runs ssh with
* `BatchMode=yes`, and has no manual fallback for minting the per-host
* credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows.
*
* The copy was not thrown away — it is parked in
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
* it is still there so nobody deletes the parked story as stale. It goes back
* on the site only when the one-click install ships and is recorded.
*
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
*
* Deliberate exceptions, not audited here — both were weighed with the Chief
* of Staff at the go/no-go and left in place on purpose:
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
* it" is a limitation disclosure, not a capability claim.
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
* the removed remote story, but tailnet host lookup does exist. Raised on
* FRE-323 rather than widened into this correction.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. Tests and drafts may quote the old copy. */
const AUDITED = [
"src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx",
"src/routes/nook/faq.tsx",
"src/components/nook/FeatureCollage.tsx",
"src/components/nook/FeatureBreakdowns.tsx",
"src/components/nook/IslandExtras.tsx"
];
/** Every sentence FRE-323 ordered off the site. */
const BANNED_REMOTE_CLAIMS = [
"Agents on other machines",
"one click installs the nook-hook",
"Any SSH box",
"Works across every hooked agent, local or remote",
"Remote sessions are ordinary rows",
"One-click SSH setup",
"remote machines over SSH",
"this Mac or remote machines"
];
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
const BANNED_COVERAGE = [
"all thirteen",
"thirteen coding agents",
"13 agents",
"every coding agent",
"Supported agents"
];
/** User-facing copy only: comments are not copy and may name the old wording. */
function copyOf(file: string): string {
return readFileSync(join(ROOT, file), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
}
describe("remote claims stay off the site (FRE-323)", () => {
for (const file of AUDITED) {
it(`${file} does not restore a removed remote sentence`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents no longer offers remote sessions", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).not.toContain("remote sessions report into the same grid");
expect(copy).not.toContain("on this Mac or on remote");
});
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
expect(parked).toContain(phrase);
}
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
"FRE-320"
);
});
});
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
for (const file of AUDITED) {
it(`${file} states no coverage count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents titles the table Agent coverage", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage");
});
});

View File

@@ -0,0 +1,381 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Database } from "bun:sqlite";
import {
VISITOR_ANALYTICS_COLUMNS,
VISITOR_ANALYTICS_DDL,
VISITOR_ANALYTICS_INSERT
} from "./analytics-schema";
import {
NOOK_DOWNLOAD_PATH,
getWeeklyReport,
pickMover,
readCampaigns,
readFunnel,
readTraffic,
renderWeeklyMarkdown,
windowsFor,
type Connections,
type FunnelCounts,
type MetricsConn
} from "./weekly-metrics";
// Same reasoning as the backfill suite: the queries run real SQL, so an
// in-memory SQLite shaped like production is the only honest stand-in. A stub
// that returned canned rows would pass with a column or a label the live table
// does not have.
const NOOK_DDL = [
`CREATE TABLE trials (
fingerprint TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
email TEXT
)`,
`CREATE TABLE licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
device_fingerprint TEXT NOT NULL,
device_name TEXT NOT NULL,
activated_at TEXT NOT NULL,
deactivated_at TEXT
)`
];
function adapter(db: Database): MetricsConn {
return {
async execute(query) {
const args = (query.args ?? []) as never[];
const statement = db.query(query.sql);
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
}
return {
rows: [] as unknown[],
rowsAffected: statement.run(...args).changes
};
}
};
}
let analyticsDb: Database;
let nookDb: Database;
let conns: Connections;
const NOW = new Date("2026-10-29T16:00:00Z");
const row = (
overrides: Record<string, unknown>
): Record<string, unknown> => ({
id: crypto.randomUUID(),
user_id: null,
path: "/",
method: "GET",
referrer: null,
user_agent: "Mozilla/5.0 (Macintosh)",
ip_address: "203.0.113.9",
country: "GB",
device_type: "desktop",
browser: "safari",
os: "macos",
utm_source: null,
utm_medium: null,
utm_campaign: null,
duration_ms: null,
fcp: null,
lcp: null,
cls: null,
fid: null,
inp: null,
ttfb: null,
dom_load: null,
load_complete: null,
...overrides
});
function insertAnalytics(overrides: Record<string, unknown>): void {
const values = row(overrides);
analyticsDb
.prepare(VISITOR_ANALYTICS_INSERT)
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
// the window each row belongs to has to be set after the insert.
if (values.created_at) {
analyticsDb
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
.run(values.created_at as string, values.id as string);
}
}
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
const THIS_WEEK = "2026-10-27 10:00:00";
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
const LAST_WEEK = "2026-10-18 10:00:00";
beforeEach(() => {
analyticsDb = new Database(":memory:");
analyticsDb.run(VISITOR_ANALYTICS_DDL);
nookDb = new Database(":memory:");
for (const ddl of NOOK_DDL) nookDb.run(ddl);
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
});
describe("windowsFor", () => {
it("puts the previous window directly before the current one", () => {
const windows = windowsFor(NOW, 7);
expect(windows.current).toEqual({
start: "2026-10-22 16:00:00",
end: "2026-10-29 16:00:00"
});
expect(windows.previous).toEqual({
start: "2026-10-15 16:00:00",
end: "2026-10-22 16:00:00"
});
});
});
describe("readFunnel", () => {
it("counts each stage inside its window and nothing outside it", async () => {
// Page views count; API calls do not.
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
insertAnalytics({ path: "/", created_at: LAST_WEEK });
// The merged download label, click and served alike.
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
// Checkout page doors, including the legacy /nook/checkout path.
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
insertAnalytics({ path: "/nook/checkout", created_at: THIS_WEEK });
// The licence DB stores ISO-8601 with milliseconds.
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-old", "2026-08-26T19:26:12.791Z");
nookDb
.prepare(
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
nookDb
.prepare(
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
const window = windowsFor(NOW, 7).current;
const funnel = await readFunnel(conns, window);
// "/checkout" and "/nook/checkout" are page views too, so they land in
// both the visit count and the checkout count.
expect(funnel).toEqual({
visit: 4,
download: 2,
trial: 1,
checkout: 2,
purchase: 1,
activation: 1
});
});
it("reports zeros against an empty database", async () => {
const window = windowsFor(NOW, 7).current;
expect(await readFunnel(conns, window)).toEqual({
visit: 0,
download: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
});
});
});
describe("readTraffic", () => {
it("separates loopback and automation from browser traffic", async () => {
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "::1",
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "203.0.113.9",
user_agent: "Mozilla/5.0 (Macintosh) Safari/605.1.15",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", ip_address: "203.0.113.9", created_at: THIS_WEEK });
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
expect(traffic).toEqual({
analyticsRows: 4,
loopbackRows: 2,
allTimeRows: 4,
allTimeAddressedRows: 4,
allTimeLoopbackRows: 2,
downloadRows: 3,
downloadLoopbackRows: 2,
downloadNonBrowserRows: 2
});
});
});
describe("readCampaigns", () => {
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "r_macapps",
utm_campaign: "week2",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", created_at: THIS_WEEK });
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns).toEqual([
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
]);
});
it("caps the list at ten rows", async () => {
for (let i = 0; i < 12; i += 1) {
insertAnalytics({
path: "/",
utm_source: `source-${i}`,
created_at: THIS_WEEK
});
}
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns.length).toBe(10);
});
});
describe("pickMover", () => {
const base: FunnelCounts = {
visit: 0,
download: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
};
it("picks the stage with the largest absolute change", () => {
const mover = pickMover(
{ ...base, visit: 61, download: 156, trial: 2 },
{ ...base, visit: 56 }
);
expect(mover).toEqual({
key: "download",
label: "download click → DMG served (one label)",
now: 156,
before: 0,
delta: 156
});
});
it("breaks ties toward the earlier stage", () => {
const mover = pickMover(
{ ...base, visit: 5, download: 5 },
{ ...base, visit: 0, download: 0 }
);
expect(mover?.key).toBe("visit");
});
it("returns null when nothing moved", () => {
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
});
});
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
it("reports a week in the agreed stage order with the computed caveats", async () => {
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(report.mover?.key).toBe("download");
expect(markdown).toContain(
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
);
expect(markdown).toContain("| visit (page views) | 1 | 0 | +1 |");
expect(markdown.indexOf("visit (page views)")).toBeLessThan(
markdown.indexOf("download click → DMG served")
);
expect(markdown.indexOf("download click → DMG served")).toBeLessThan(
markdown.indexOf("trial started")
);
expect(markdown).toContain("**download click → DMG served (one label): 0 → 2 (+2).**");
expect(markdown).toContain("2 of 3 rows in the window came from a loopback address");
expect(markdown).toContain("Checkout start is not recorded");
expect(markdown).toContain("There is no host dimension");
});
it("says nothing moved rather than inventing a winner", async () => {
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(report.mover).toBeNull();
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
expect(markdown).toContain("_No tagged traffic in the window._");
});
it("rejects a window that is not a positive number of days", async () => {
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
"days must be a positive number"
);
});
});

View File

@@ -0,0 +1,591 @@
/**
* The weekly funnel reading: `marketing/metrics/weekly.md`'s data half.
*
* The funnel the marketing cluster agreed on is
*
* visit → download click → DMG served → trial started → checkout started
* → purchase → activation
*
* and this module reads it from the two live databases, plus the campaign
* dimension the /analytics page already exposes through
* `getAnalyticsSummary.topCampaigns`.
*
* Two properties matter more than the numbers:
*
* - It takes both connections as arguments. `~/server/analytics` imports the
* full server env (Stripe keys, Apple secret, licence key …); the weekly
* job only needs the two databases, and must be runnable from a checkout
* whose `.env` carries nothing else. The SQL here is the same shape as the
* analytics summaries, so the weekly read and the dashboard cannot drift
* into different label sets.
* - Every stage carries what it actually measured. Two of the seven agreed
* stages share one label in production today and a third is only a page-view
* proxy; the report states that next to the number instead of inventing a
* figure (see `renderWeeklyMarkdown`).
*
* No `~/env`, no `~/server/database`: the caller owns the connections, the
* tests own an in-memory SQLite, and the script owns the libsql clients.
*/
import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset";
/** The one asset key the Nook lands under — `downloadPath("thenook")`. */
export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`;
/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */
export interface MetricsConn {
execute(query: {
sql: string;
args?: any[];
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
}
export interface Connections {
/** freno.me Turso DB — VisitorAnalytics. */
analytics: MetricsConn;
/** the-nook Turso DB — trials, licenses, activations. */
nook: MetricsConn;
}
export type StageKey =
| "visit"
| "download"
| "trial"
| "checkout"
| "purchase"
| "activation";
export interface StageDef {
key: StageKey;
label: string;
/** True when the number is a proxy or a merged label, not a clean stage. */
approximate: boolean;
}
/**
* The seven agreed stages. `download` deliberately carries two of them: the
* door and the file route both write `/api/downloads/thenook`, so a click and
* the DMG it fetched are one label in the data (proved in the report's caveats).
*/
export const STAGES: StageDef[] = [
{ key: "visit", label: "visit (page views)", approximate: false },
{
key: "download",
label: "download click → DMG served (one label)",
approximate: true
},
{ key: "trial", label: "trial started", approximate: false },
{
key: "checkout",
label: "checkout page visit",
approximate: true
},
{ key: "purchase", label: "purchase (licence issued)", approximate: false },
{ key: "activation", label: "activation", approximate: false }
];
export interface FunnelCounts {
visit: number;
download: number;
trial: number;
checkout: number;
purchase: number;
activation: number;
}
export interface CampaignRow {
source: string | null;
medium: string | null;
campaign: string | null;
count: number;
}
/** Traffic-shape facts the report has to state next to the funnel. */
export interface WindowTraffic {
analyticsRows: number;
loopbackRows: number;
/** All-time row counts, so "every row ever" is a checked claim, not a guess. */
allTimeRows: number;
allTimeAddressedRows: number;
allTimeLoopbackRows: number;
downloadRows: number;
downloadLoopbackRows: number;
downloadNonBrowserRows: number;
}
export interface Mover {
key: StageKey;
label: string;
now: number;
before: number;
delta: number;
}
export interface WeeklyReport {
window: { start: string; end: string; days: number };
previous: { start: string; end: string };
counts: FunnelCounts;
previousCounts: FunnelCounts;
campaigns: CampaignRow[];
taggedRows: number;
traffic: WindowTraffic;
mover: Mover | null;
generatedAt: string;
}
/** Loopback addresses — every row the live table has ever held. */
const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"];
/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */
export function sqlTime(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export interface Windows {
current: { start: string; end: string };
previous: { start: string; end: string };
}
/**
* The current window is `[now - days, now)` and the previous one sits directly
* before it, so a weekly read always compares like with like. Boundaries are
* passed as SQL parameters rather than `datetime('now', …)` so the file a run
* writes is reproducible and the two queries cannot straddle a second.
*/
export function windowsFor(now: Date, days: number): Windows {
const span = days * 24 * 60 * 60 * 1000;
const end = now;
const start = new Date(end.getTime() - span);
const previousEnd = start;
const previousStart = new Date(previousEnd.getTime() - span);
return {
current: { start: sqlTime(start), end: sqlTime(end) },
previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) }
};
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "bigint") return Number(value);
if (typeof value === "string") return Number(value) || 0;
return 0;
}
async function scalar(
conn: MetricsConn,
sql: string,
args: any[]
): Promise<number> {
const result = await conn.execute({ sql, args });
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return toNumber(Object.values(row)[0]);
}
async function countInWindow(
conn: MetricsConn,
sql: string,
start: string,
end: string
): Promise<number> {
return scalar(conn, sql, [start, end]);
}
/** One window's funnel. Two databases, one call. */
export async function readFunnel(
conns: Connections,
window: { start: string; end: string }
): Promise<FunnelCounts> {
const { analytics, nook } = conns;
const { start, end } = window;
const visit = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND path NOT LIKE '/api/%'`,
start,
end
);
const download = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND path = ?`,
[start, end, NOOK_DOWNLOAD_PATH]
);
const checkout = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND (path LIKE '/checkout%' OR path LIKE '/nook/checkout%')`,
start,
end
);
// The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics
// DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a
// boundary seconds apart cannot include or drop a row on string comparison.
const trial = await scalar(
nook,
`SELECT COUNT(*) AS n FROM trials
WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`,
[start, end]
);
const purchase = await scalar(
nook,
`SELECT COUNT(*) AS n FROM licenses
WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`,
[start, end]
);
const activation = await scalar(
nook,
`SELECT COUNT(*) AS n FROM activations
WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`,
[start, end]
);
return {
visit,
download,
trial,
checkout,
purchase,
activation
};
}
/**
* Traffic shape for one window: how much of it came from this host, and how
* much of the download label was automation. Without these two facts the
* numbers read as demand, which they are not.
*/
export async function readTraffic(
conns: Connections,
window: { start: string; end: string }
): Promise<WindowTraffic> {
const { analytics } = conns;
const { start, end } = window;
const placeholders = LOOPBACK.map(() => "?").join(", ");
const allTimeRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics`,
[]
);
const allTimeAddressedRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE ip_address IS NOT NULL`,
[]
);
const allTimeLoopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE ip_address IN (${placeholders})`,
[...LOOPBACK]
);
const analyticsRows = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?`,
start,
end
);
const loopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND ip_address IN (${placeholders})`,
[start, end, ...LOOPBACK]
);
const downloadRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?`,
[start, end, NOOK_DOWNLOAD_PATH]
);
const downloadLoopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?
AND ip_address IN (${placeholders})`,
[start, end, NOOK_DOWNLOAD_PATH, ...LOOPBACK]
);
// Anything whose UA is not a browser engine: curl, Sparkle's updater,
// link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`.
const downloadNonBrowserRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?
AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`,
[start, end, NOOK_DOWNLOAD_PATH]
);
return {
analyticsRows,
loopbackRows,
allTimeRows,
allTimeAddressedRows,
allTimeLoopbackRows,
downloadRows,
downloadLoopbackRows,
downloadNonBrowserRows
};
}
/**
* The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns`
* groups it: top 10 by volume, untagged rows excluded (the untagged bucket is
* a different question and would otherwise swallow the list).
*/
export async function readCampaigns(
analytics: MetricsConn,
window: { start: string; end: string }
): Promise<CampaignRow[]> {
const result = await analytics.execute({
sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count
FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL)
GROUP BY utm_source, utm_medium, utm_campaign
ORDER BY count DESC
LIMIT 10`,
args: [window.start, window.end]
});
return result.rows.map((raw) => {
const row = raw as Record<string, unknown>;
return {
source: (row.utm_source as string | null) ?? null,
medium: (row.utm_medium as string | null) ?? null,
campaign: (row.utm_campaign as string | null) ?? null,
count: toNumber(row.count)
};
});
}
/**
* "The one thing that moved": the stage with the largest absolute change
* against the previous window. Ties go to the earlier stage, so a week when
* nothing moved returns null rather than a made-up winner.
*/
export function pickMover(
current: FunnelCounts,
previous: FunnelCounts
): Mover | null {
let best: Mover | null = null;
for (const stage of STAGES) {
const delta = current[stage.key] - previous[stage.key];
if (delta === 0) continue;
if (!best || Math.abs(delta) > Math.abs(best.delta)) {
best = {
key: stage.key,
label: stage.label,
now: current[stage.key],
before: previous[stage.key],
delta
};
}
}
return best;
}
export interface ReportOptions {
/** End of the current window; defaults to the wall clock. */
now?: Date;
/** Window length in days; defaults to 7. */
days?: number;
}
/** Read everything `.md` needs, in one call. */
export async function getWeeklyReport(
conns: Connections,
options: ReportOptions = {}
): Promise<WeeklyReport> {
const days = options.days ?? 7;
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`days must be a positive number, got ${days}`);
}
const now = options.now ?? new Date();
const windows = windowsFor(now, days);
const [counts, previousCounts, traffic, campaigns] = await Promise.all([
readFunnel(conns, windows.current),
readFunnel(conns, windows.previous),
readTraffic(conns, windows.current),
readCampaigns(conns.analytics, windows.current)
]);
return {
window: { ...windows.current, days },
previous: windows.previous,
counts,
previousCounts,
campaigns,
taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0),
traffic,
mover: pickMover(counts, previousCounts),
generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
};
}
function cell(value: string | null): string {
return value === null || value === "" ? "—" : value;
}
function signed(value: number): string {
if (value === 0) return "0";
return value > 0 ? `+${value}` : `−${Math.abs(value)}`;
}
/**
* Render the weekly file. Interpretation lives in the CMO's read; this renderer
* only states what the data is, what moved, and what the numbers cannot say.
* The Gaps section is not boilerplate: each bullet is computed from the window
* it describes, so it empties itself if the underlying hole is ever fixed.
*/
export function renderWeeklyMarkdown(report: WeeklyReport): string {
const { counts, previousCounts, traffic } = report;
const lines: string[] = [];
lines.push("# Weekly funnel snapshot");
lines.push("");
lines.push(
`Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` +
`Previous: ${report.previous.start} → ${report.previous.end} UTC.`
);
lines.push("");
lines.push(
"Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " +
"weekly, never hand-edited. The analysis is the CMO's."
);
lines.push("");
lines.push("## Funnel");
lines.push("");
lines.push("| Stage | This week | Last week | Change |");
lines.push("|---|---:|---:|---:|");
for (const stage of STAGES) {
const now = counts[stage.key];
const before = previousCounts[stage.key];
const label = stage.approximate ? `${stage.label} †` : stage.label;
lines.push(`| ${label} | ${now} | ${before} | ${signed(now - before)} |`);
}
lines.push("");
lines.push("## Campaigns");
lines.push("");
if (report.campaigns.length === 0) {
lines.push("_No tagged traffic in the window._");
} else {
lines.push(
"Top 10 by volume, untagged excluded — the same grouping " +
"`getAnalyticsSummary.topCampaigns` returns."
);
lines.push("");
lines.push("| Source | Medium | Campaign | Rows |");
lines.push("|---|---|---|---:|");
for (const row of report.campaigns) {
lines.push(
`| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |`
);
}
}
lines.push("");
lines.push(
`${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` +
`campaign tags; the rest are untagged and therefore unattributed.`
);
lines.push("");
lines.push("## The one thing that moved");
lines.push("");
if (!report.mover) {
lines.push("_Nothing moved: every stage held its previous-week value._");
} else {
const mover = report.mover;
lines.push(
`**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**`
);
}
lines.push("");
lines.push("## Before acting on these numbers");
lines.push("");
if (traffic.analyticsRows > 0 && traffic.loopbackRows > 0) {
lines.push(
`- **${traffic.loopbackRows} of ${traffic.analyticsRows} rows in the window came from a loopback address** ` +
`(127.0.0.1 / ::1)` +
(traffic.allTimeAddressedRows > 0 &&
traffic.allTimeAddressedRows === traffic.allTimeLoopbackRows
? `; every one of the ${traffic.allTimeAddressedRows} addressed rows the table ` +
`has ever held is loopback (${traffic.allTimeRows - traffic.allTimeAddressedRows} carry no address)`
: `; across the table's history, ${traffic.allTimeLoopbackRows} of ` +
`${traffic.allTimeAddressedRows} addressed rows are loopback`) +
`. Either this host is the only audience, or the app sits behind a local proxy ` +
`and \`getRequestIP\` records the proxy, not the visitor. Either way the address ` +
`identifies nobody — "unique visitors" collapses to one host and per-channel ` +
`reach cannot be read from it.`
);
}
if (traffic.downloadRows > 0) {
lines.push(
`- **The download stage is one label, not two.** The landing-door route and the file ` +
`route that serves the DMG both write \`${NOOK_DOWNLOAD_PATH}\`, so ` +
`"download click → DMG served" cannot be split: one served click writes two rows, ` +
`a click that fails to resolve writes one, and a Sparkle update writes one. ` +
`Of this window's ${traffic.downloadRows} download rows, ` +
`${traffic.downloadLoopbackRows} are loopback and ` +
`${traffic.downloadNonBrowserRows} are not browser user agents (curl, Sparkle, ` +
`link-preview crawlers).`
);
}
lines.push(
`- **Checkout start is not recorded.** \`POST /api/the-nook/checkout\` — the call that ` +
`actually creates the Stripe session — writes nothing to analytics; the only trace is ` +
`the /checkout page view reported above, which is a page, not a started checkout.`
);
lines.push(
`- **There is no host dimension.** \`VisitorAnalytics\` records a path, not the site, so ` +
`the Nook's landing page (\`/\` on nook.freno.me) is the same row as freno.me's root. ` +
`"visit" above is site-wide page views, not Nook visits.`
);
lines.push(
`- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature ` +
`Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.`
);
lines.push("");
lines.push(
"† Proxy or merged label — see *Before acting on these numbers* above."
);
lines.push("");
lines.push("## Sources");
lines.push("");
lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.");
lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.");
lines.push(`- Generated ${report.generatedAt}.`);
lines.push("");
return lines.join("\n");
}