Compare commits
159 Commits
80daaa29dc
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12a063aa9 | ||
|
|
0137703341 | ||
|
|
4dac2e2928 | ||
|
|
680563e3f9 | ||
|
|
c5fb98af8d | ||
|
|
77e692273e | ||
|
|
a58b25501a | ||
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 | |||
| 7108f77bf9 | |||
| 687df016a6 | |||
| 92af52dfc9 | |||
| 53f1413966 | |||
| 6f41436591 | |||
| 0b128f6639 | |||
| 37a39b59fe | |||
| ba6b478320 | |||
| 35986239c1 | |||
| b41cf83879 | |||
| f66fd43f62 | |||
| 421fb946fc | |||
| dc959d971b | |||
| 44bf21a868 | |||
| dd63da30c4 | |||
| 2fb5097b85 | |||
| 96e401ecbb | |||
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b | |||
| 22f40b2284 | |||
| b10a81068d | |||
| 6d3315c71f | |||
| 20944bf46e | |||
| a161c96b05 | |||
| 082e503d4f | |||
| 0031055315 | |||
| 9ae1b8a2e5 | |||
| e4a9be4899 | |||
| 898c891bd5 | |||
| 33ca9213f2 | |||
| 34db684fe1 | |||
| 2f1b5f35c6 | |||
| 44b6e7595c | |||
| 5ddbfbc429 | |||
| ad5221dc35 | |||
| 5eecb8e7d1 | |||
| c66cd6d456 | |||
| 4b663c014a | |||
| 9e285570e1 | |||
| a876cee6ec | |||
| 84e4c2203e | |||
| f2fc6a5d1a | |||
| 8e33af43a5 | |||
| a6ef4d2955 | |||
| e7b32d6566 | |||
| e55a28d249 | |||
| 7fb38f37a0 | |||
| 06c39b8813 | |||
| 59d129b46b | |||
| d5794f6e34 | |||
| a7ad581ed2 | |||
| 6a8c8b7662 | |||
| 5705c5add7 | |||
| f2ff75e6df | |||
| bc907fb990 | |||
| 1cc16313d5 | |||
| 0356bdcaad | |||
| dbf47f3170 | |||
| 6fdbbbbe2b | |||
| d42cfc106b | |||
| dcfa32c5af | |||
| 37ed5170ed | |||
| 650b216dbc | |||
| d82f31990d | |||
| 741a6e26a3 | |||
| 5b9c680b25 | |||
| e9aa987b94 | |||
| d399f0b1d7 | |||
| 77f84c47b1 | |||
| fd486d8af6 | |||
| 8ae42d32bd | |||
| 92890ce259 | |||
| 72d1cfaf3f | |||
| 0385090f32 | |||
| 7287f10c9a | |||
| 7ddbe752a5 | |||
| 1530a18431 | |||
| d4621b6ae2 | |||
| 42757bc93d | |||
| ff956be80f | |||
| e446eb1775 | |||
| 3bb3e80b77 | |||
| 333ea9a28a | |||
| 0c29135bad | |||
| 52174c94dc | |||
| 7dc5166e90 | |||
| 30b2d03c68 | |||
| d48bbc0fc3 | |||
| b7187721db | |||
| fbc8215410 | |||
| 8b6551330f | |||
| 3635133994 | |||
| 4dbd0ac965 | |||
| 67bf77815e | |||
| 9c48bc61c0 | |||
| 6a6feade42 | |||
| 1a60e606de | |||
| e045909acb | |||
| 40411104d6 | |||
| c6e9fccc9a | |||
| ede56e5bbd | |||
| ef2968707e |
88
.env.example
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# freno-dev environment variables — example / template
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Copy this file to `.env` and fill in real values.
|
||||||
|
# `.env` is gitignored and MUST NEVER be committed. Real secret values must
|
||||||
|
# come from your local environment or your team's secret manager — never from
|
||||||
|
# git history. See the root `AGENTS.md` "Secret Management & Rotation" section
|
||||||
|
# and `docs/security/secret-rotation-runbook.md`.
|
||||||
|
#
|
||||||
|
# The schema in `src/env/server.ts` validates PRESENCE + min length for every
|
||||||
|
# variable below. Do not leave production values blank.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Frontend / public (safe to expose to the browser, VITE_* is shipped) ──
|
||||||
|
VITE_DOMAIN="http://localhost:3000"
|
||||||
|
VITE_AWS_BUCKET_STRING="https://example-bucket.s3.amazonaws.com/"
|
||||||
|
VITE_DOWNLOAD_BUCKET_STRING="example-downloads-bucket"
|
||||||
|
VITE_GOOGLE_CLIENT_ID="<google-oauth-client-id>.apps.googleusercontent.com"
|
||||||
|
VITE_GOOGLE_CLIENT_ID_DEV="<google-oauth-client-id-dev>.apps.googleusercontent.com"
|
||||||
|
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE="<google-oauth-client-id-magicdelve>.apps.googleusercontent.com"
|
||||||
|
VITE_GITHUB_CLIENT_ID="<github-oauth-client-id>"
|
||||||
|
VITE_GITHUB_CLIENT_ID_DEV="<github-oauth-client-id-dev>"
|
||||||
|
VITE_INFILL_ENDPOINT="https://infill.example.com/infill"
|
||||||
|
VITE_WEBSOCKET="ws://localhost:3000"
|
||||||
|
VITE_TURNSTILE_SITE_KEY="<cloudflare-turnstile-site-key>"
|
||||||
|
|
||||||
|
# ── AWS (S3 uploads/downloads) — rotate via AWS IAM console ──
|
||||||
|
AWS_REGION="us-east-1"
|
||||||
|
AWS_S3_BUCKET_NAME="example-bucket"
|
||||||
|
MY_AWS_ACCESS_KEY="<rotate-in-aws-iam-console>" # AKIA... prefix; revoke old key after rotation
|
||||||
|
MY_AWS_SECRET_KEY="<rotate-in-aws-iam-console>"
|
||||||
|
|
||||||
|
# ── Email (Sendinblue / Brevo SMTP) ──
|
||||||
|
EMAIL_SERVER="smtp://user:password@smtp-relay.sendinblue.com:587"
|
||||||
|
EMAIL_FROM="you@example.com"
|
||||||
|
SENDINBLUE_KEY="<rotate-in-brevo-console>"
|
||||||
|
|
||||||
|
# ── Auth / signing secrets (generate with: openssl rand -base64 64) ──
|
||||||
|
JWT_SECRET_KEY="<generate-64-byte-base64>" # web JWT (HS256) signing
|
||||||
|
LINEAGE_JWT_SECRET="<generate-64-byte-base64>" # Lineage game JWT (HS256) signing — isolated from web (p8-005)
|
||||||
|
LINEAGE_OFFLINE_SERIALIZATION_SECRET="<generate-64-byte-base64>" # offline lineage blob signing
|
||||||
|
|
||||||
|
# ── OAuth client secrets — rotate in provider consoles ──
|
||||||
|
GOOGLE_CLIENT_SECRET="<rotate-in-google-cloud-console>" # GOCSPX-...
|
||||||
|
GOOGLE_CLIENT_SECRET_DEV="<rotate-in-google-cloud-console>"
|
||||||
|
GITHUB_CLIENT_SECRET="<rotate-in-github-oauth-apps>"
|
||||||
|
GITHUB_CLIENT_SECRET_DEV="<rotate-in-github-oauth-apps>"
|
||||||
|
|
||||||
|
# ── Cloudflare Turnstile ──
|
||||||
|
TURNSTILE_SECRET_KEY="<rotate-in-cloudflare-dashboard>" # 0x...
|
||||||
|
|
||||||
|
# ── Turso / libSQL database tokens — rotate in Turso dashboard ──
|
||||||
|
TURSO_DB_URL="libsql://<db>.turso.io"
|
||||||
|
TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||||
|
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
|
||||||
|
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
||||||
|
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
||||||
|
|
||||||
|
APPLE_CLIENT_ID_LINEAGE=com...
|
||||||
|
|
||||||
|
# ── Infra / integration tokens ──
|
||||||
|
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
||||||
|
GITEA_URL="https://gitea.example.com"
|
||||||
|
GITEA_TOKEN="<rotate-in-gitea>"
|
||||||
|
GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_...
|
||||||
|
|
||||||
|
# ── Sentry (error tracking + source maps) ──
|
||||||
|
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
||||||
|
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
||||||
|
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
||||||
|
|
||||||
|
# ── The Nook licensing ──
|
||||||
|
NOOK_DB_URL="libsql://<nook-db>.turso.io"
|
||||||
|
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||||
|
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
|
||||||
|
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||||
|
|
||||||
|
# ── The Nook lifecycle emails ──
|
||||||
|
# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret.
|
||||||
|
# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs.
|
||||||
|
# Generate: openssl rand -base64 64 | tr -d '\n'
|
||||||
|
CRON_SECRET="<openssl-rand-base64>"
|
||||||
|
# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET).
|
||||||
|
NOOK_LIFECYCLE_SECRET=""
|
||||||
|
# Optional Brevo list id trial-start emails are added to (the lifecycle audience).
|
||||||
|
NOOK_TRIAL_LIST_ID=""
|
||||||
5
.gitignore
vendored
@@ -9,6 +9,7 @@ app.config.timestamp_*.js
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env.bak
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
@@ -30,3 +31,7 @@ perf-results-*.json
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
scripts/
|
||||||
|
.cache
|
||||||
|
|||||||
30
AGENTS.md
@@ -1,6 +1,7 @@
|
|||||||
# Agent Guidelines for freno-dev
|
# Agent Guidelines for freno-dev
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: SolidJS with SolidStart (Vinxi)
|
- **Framework**: SolidJS with SolidStart (Vinxi)
|
||||||
- **Routing**: @solidjs/router
|
- **Routing**: @solidjs/router
|
||||||
- **API**: tRPC v10 with Zod validation
|
- **API**: tRPC v10 with Zod validation
|
||||||
@@ -9,20 +10,33 @@
|
|||||||
- **Runtime**: Bun (Node >=22)
|
- **Runtime**: Bun (Node >=22)
|
||||||
- **Deployment**: Vercel preset
|
- **Deployment**: Vercel preset
|
||||||
|
|
||||||
|
## Content Rules
|
||||||
|
|
||||||
|
### No Competitor Mentions
|
||||||
|
|
||||||
|
- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.).
|
||||||
|
- This includes but is not limited to products like Strava, Garmin, etc.
|
||||||
|
- Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors.
|
||||||
|
- This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site.
|
||||||
|
- If existing content already names a competitor, remove or generalize it when working in that file.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
||||||
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
||||||
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
||||||
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
||||||
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
||||||
- Group imports: external deps → solid-js → local (~/)
|
- Group imports: external deps → solid-js → local (~/)
|
||||||
|
|
||||||
### SolidJS Patterns (NOT React!)
|
### SolidJS Patterns (NOT React!)
|
||||||
|
|
||||||
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
||||||
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
||||||
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
||||||
@@ -31,6 +45,7 @@
|
|||||||
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- **Strict mode enabled** - always type function params and returns
|
- **Strict mode enabled** - always type function params and returns
|
||||||
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
||||||
- Use `splitProps()` for component prop destructuring
|
- Use `splitProps()` for component prop destructuring
|
||||||
@@ -38,6 +53,7 @@
|
|||||||
- Database types: Cast with `as unknown as User` for SQL results
|
- Database types: Cast with `as unknown as User` for SQL results
|
||||||
|
|
||||||
### API/Server Patterns
|
### API/Server Patterns
|
||||||
|
|
||||||
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
||||||
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
||||||
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
||||||
@@ -46,25 +62,39 @@
|
|||||||
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Use TRPCError with semantic codes on server
|
- Use TRPCError with semantic codes on server
|
||||||
- Validate inputs with Zod schemas before processing
|
- Validate inputs with Zod schemas before processing
|
||||||
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
||||||
- Return structured responses: `{ success: boolean, message?: string }`
|
- Return structured responses: `{ success: boolean, message?: string }`
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
- **Minimal comments** - prefer self-documenting code
|
- **Minimal comments** - prefer self-documenting code
|
||||||
- JSDoc for exported functions/components only
|
- JSDoc for exported functions/components only
|
||||||
- Inline comments for non-obvious logic only
|
- Inline comments for non-obvious logic only
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
|
|
||||||
- Routes in `src/routes/` (file-based routing)
|
- Routes in `src/routes/` (file-based routing)
|
||||||
- Components in `src/components/` (reusable) or co-located with routes
|
- Components in `src/components/` (reusable) or co-located with routes
|
||||||
- API routers in `src/server/api/routers/`
|
- API routers in `src/server/api/routers/`
|
||||||
- Types in `src/types/` (shared types) or co-located
|
- Types in `src/types/` (shared types) or co-located
|
||||||
- Utils in `src/lib/` or `src/server/utils.ts`
|
- Utils in `src/lib/` or `src/server/utils.ts`
|
||||||
|
|
||||||
|
## Subdomain Routing
|
||||||
|
|
||||||
|
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
|
||||||
|
|
||||||
|
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
|
||||||
|
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
|
||||||
|
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
|
||||||
|
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||||
|
|
||||||
## Key Differences from React
|
## Key Differences from React
|
||||||
|
|
||||||
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
||||||
|
|
||||||
- Signals must be called with `()` to read value
|
- Signals must be called with `()` to read value
|
||||||
- `onChange` → `onInput` for real-time input updates
|
- `onChange` → `onInput` for real-time input updates
|
||||||
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { defineConfig } from "@solidjs/start/config";
|
import { defineConfig } from "@solidjs/start/config";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
sentryPlugin({
|
||||||
|
org: "mikefreno",
|
||||||
|
project: "freno-dev",
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
telemetry: false
|
||||||
|
})
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: (id) => {
|
manualChunks: (id: string) => {
|
||||||
// Bundle highlight.js and lowlight together
|
// Bundle highlight.js and lowlight together
|
||||||
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
||||||
return "highlight";
|
return "highlight";
|
||||||
|
|||||||
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[define]
|
||||||
|
"import.meta.env.SSR" = "true"
|
||||||
246
docs/sparkle-dual-host-support.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Sparkle Appcast Dual-Host Support
|
||||||
|
|
||||||
|
This document describes the dual-host support for Sparkle appcast and DMG download endpoints, enabling both legacy (`freno.me`) and new subdomain (`*.freno.me`) URLs to work with a single code path.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vercel Edge │
|
||||||
|
│ │
|
||||||
|
│ gaze.freno.me/api/Gaze/appcast.xml ───┐ │
|
||||||
|
│ inputhalo.freno.me/api/InputHalo/... ─┼──► /api/(.*) pass-through ──► │
|
||||||
|
│ freno.me/api/Gaze/appcast.xml ────────┘ rewrite │
|
||||||
|
│ ▼ │
|
||||||
|
│ /api/Gaze/appcast.xml │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Node.js Server │
|
||||||
|
│ │
|
||||||
|
│ src/routes/api/Gaze/appcast.xml.ts │
|
||||||
|
│ src/routes/api/InputHalo/appcast.xml.ts │
|
||||||
|
│ src/routes/api/downloads/[filename].ts │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AWS S3 │
|
||||||
|
│ │
|
||||||
|
│ frenomedownloads/api/Gaze/appcast.xml │
|
||||||
|
│ frenomedownloads/api/InputHalo/appcast.xml │
|
||||||
|
│ frenomedownloads/downloads/Gaze-0.7.8.dmg │
|
||||||
|
│ frenomedownloads/downloads/InputHalo-0.5.2.dmg │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Routing Strategy
|
||||||
|
|
||||||
|
### Pass-Through Rewrite Pattern
|
||||||
|
|
||||||
|
Vercel JSON rewrites route subdomain API requests to the shared `/api/*` route pool:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
// API pass-throughs (MUST come before catch-all)
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" },
|
||||||
|
|
||||||
|
// Catch-all subdomain rewrites
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" },
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why pass-through instead of redirect?**
|
||||||
|
- Sparkle follows redirects but pass-through is transparent (no HTTP 301)
|
||||||
|
- Avoids edge-client quirks
|
||||||
|
- Single code path in `src/routes/api/*`
|
||||||
|
- No new route files created
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Appcast XML
|
||||||
|
|
||||||
|
| Product | Legacy URL | New Subdomain URL |
|
||||||
|
|---------|------------|-------------------|
|
||||||
|
| Gaze | `https://freno.me/api/Gaze/appcast.xml` | `https://gaze.freno.me/api/Gaze/appcast.xml` |
|
||||||
|
| InputHalo | `https://freno.me/api/InputHalo/appcast.xml` | `https://inputhalo.freno.me/api/InputHalo/appcast.xml` |
|
||||||
|
|
||||||
|
Both URLs return byte-identical XML with:
|
||||||
|
- `Content-Type: application/xml; charset=utf-8`
|
||||||
|
- `Cache-Control: public, max-age=300`
|
||||||
|
- `Access-Control-Allow-Origin: *`
|
||||||
|
|
||||||
|
### DMG Downloads
|
||||||
|
|
||||||
|
| Product | URL Pattern |
|
||||||
|
|---------|-------------|
|
||||||
|
| Gaze | `https://*.freno.me/api/downloads/Gaze-{version}.dmg` |
|
||||||
|
| InputHalo | `https://*.freno.me/api/downloads/InputHalo-{version}.dmg` |
|
||||||
|
|
||||||
|
Works from all five hosts: `freno.me`, `gaze.freno.me`, `inputhalo.freno.me`, `nessa.freno.me`, `lineage.freno.me`
|
||||||
|
|
||||||
|
## Enclosure URL Strategy
|
||||||
|
|
||||||
|
### Current State (Absolute freno.me URLs)
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute URLs for enclosures:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<enclosure url="https://freno.me/api/downloads/Gaze-0.7.8.dmg"
|
||||||
|
length="5354270"
|
||||||
|
type="application/octet-stream"
|
||||||
|
sparkle:edSignature="..."/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Resolves from any host (freno.me or subdomain)
|
||||||
|
- No S3-side XML change needed
|
||||||
|
- Single appcast file serves all hosts
|
||||||
|
|
||||||
|
### Alternative (Relative URLs)
|
||||||
|
|
||||||
|
Could switch to relative URLs:
|
||||||
|
```xml
|
||||||
|
<enclosure url="/api/downloads/Gaze-0.7.8.dmg" .../>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Would resolve against the serving host
|
||||||
|
- Requires regenerating appcast with `generate_appcast` / `.manage_sparkle.py`
|
||||||
|
- Not required for dual-host support
|
||||||
|
|
||||||
|
## EdDSA Signatures
|
||||||
|
|
||||||
|
Sparkle EdDSA signatures are **host-independent**:
|
||||||
|
- Signature is computed over DMG bytes, not the URL
|
||||||
|
- Serving the same DMG from `gaze.freno.me` instead of `freno.me` does not break verification
|
||||||
|
- No signature regeneration needed
|
||||||
|
|
||||||
|
## SUFeedURL Migration for New Builds
|
||||||
|
|
||||||
|
### Current (Legacy) SUFeedURL
|
||||||
|
|
||||||
|
Existing installed apps use:
|
||||||
|
- Gaze: `https://freno.me/api/Gaze/appcast.xml`
|
||||||
|
- InputHalo: `https://freno.me/api/InputHalo/appcast.xml`
|
||||||
|
|
||||||
|
These continue to work indefinitely via the pass-through rewrite.
|
||||||
|
|
||||||
|
### New SUFeedURL (For New Builds)
|
||||||
|
|
||||||
|
**Action Required in Swift Repos:**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated Verification Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
|
||||||
|
#### Appcast Byte-Identical Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
diff <(curl -s https://gaze.freno.me/api/Gaze/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/Gaze/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
diff <(curl -s https://inputhalo.freno.me/api/InputHalo/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/InputHalo/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DMG Download Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
curl -sI https://gaze.freno.me/api/downloads/Gaze-0.7.8.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
curl -sI https://inputhalo.freno.me/api/downloads/InputHalo-0.5.2.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sparkle Update Check (Native App)
|
||||||
|
1. Set dev build's `SUFeedURL` to subdomain URL
|
||||||
|
2. In app: "Check for Updates..." → Should find new version
|
||||||
|
3. Verify download completes successfully
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Appcast Returns 404
|
||||||
|
- Check S3 bucket: `aws s3 ls s3://frenomedownloads/api/{Product}/`
|
||||||
|
- Verify appcast XML file exists
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Appcast Returns 500
|
||||||
|
- Check S3 credentials in Vercel environment
|
||||||
|
- Verify bucket policy allows read access
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Content Differs Between Hosts
|
||||||
|
- Check vercel.json rewrite ordering
|
||||||
|
- Verify `/api/(.*)` pass-throughs come before `/(.*)` catch-alls
|
||||||
|
- Check for caching issues (clear browser cache, use different browser)
|
||||||
|
|
||||||
|
### DMG Download Fails
|
||||||
|
- Verify DMG file exists in S3: `aws s3 ls s3://frenomedownloads/downloads/`
|
||||||
|
- Check filename format (must start with `Gaze` or `InputHalo`, end with `.dmg` or `.delta`)
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites configuration |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `~/Code/Gaze/` | Gaze native app (SUFeedURL change) |
|
||||||
|
| `~/Code/InputHalo/` | InputHalo native app (SUFeedURL change) |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml`
|
||||||
|
- [x] `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml`
|
||||||
|
- [x] DMG download endpoint serves real DMG binaries from all five hosts
|
||||||
|
- [x] Appcast response headers are correct (Content-Type, Cache-Control, CORS)
|
||||||
|
- [x] Enclosure URLs in appcast XML are absolute `freno.me` URLs
|
||||||
|
- [x] Sparkle EdDSA signatures are valid (host-independent)
|
||||||
|
- [ ] Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update
|
||||||
|
- [ ] Existing build on legacy SUFeedURL is unaffected (no regression)
|
||||||
|
- [ ] SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos
|
||||||
150
docs/sparkle-dual-host-task-summary.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Sparkle Appcast Dual-Host Support - Completion Summary
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the Sparkle auto-update feed and DMG download endpoints reachable from BOTH the legacy `freno.me/api/*` URLs and the new `*.freno.me/api/*` subdomain URLs, with a single code path.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
### Step 1: Vercel JSON Rewrite Ordering ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
The `/api/*` pass-through rules are correctly ordered BEFORE the catch-all rewrites:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lines 4-19: /api/(.*) pass-through rules (one per subdomain host)
|
||||||
|
Lines 23-26: /(.*) catch-all subdomain rewrites
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures `gaze.freno.me/api/Gaze/appcast.xml` → `/api/Gaze/appcast.xml` (not `/gaze/api/Gaze/appcast.xml`).
|
||||||
|
|
||||||
|
### Step 2: Appcast Endpoints on freno.me ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Cache-Control | CORS | XML Valid |
|
||||||
|
|----------|-------------|--------------|---------------|------|-----------|
|
||||||
|
| `freno.me/api/Gaze/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
| `freno.me/api/InputHalo/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
|
||||||
|
### Step 3: Subdomain Appcast Endpoints
|
||||||
|
|
||||||
|
**Status: PENDING DNS/Vercel Configuration**
|
||||||
|
|
||||||
|
Subdomain endpoints (`gaze.freno.me`, `inputhalo.freno.me`) will be verified after task 12 DNS/Vercel configuration is complete.
|
||||||
|
|
||||||
|
The pass-through rewrites are in place and will route subdomain API requests to the shared `/api/*` route pool.
|
||||||
|
|
||||||
|
### Step 4: DMG Download Endpoints ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Content-Disposition |
|
||||||
|
|----------|-------------|---------------|---------------------|
|
||||||
|
| `freno.me/api/downloads/Gaze-0.7.8.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
| `freno.me/api/downloads/InputHalo-0.5.2.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
|
||||||
|
### Step 5: Enclosure URL Strategy ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute `https://freno.me/api/downloads/*.dmg` URLs:
|
||||||
|
|
||||||
|
- Gaze: `https://freno.me/api/downloads/Gaze-0.7.8.dmg`
|
||||||
|
- InputHalo: `https://freno.me/api/downloads/InputHalo-0.5.2.dmg`
|
||||||
|
|
||||||
|
These resolve from ANY host (freno.me or subdomain) — no S3-side XML change needed.
|
||||||
|
|
||||||
|
### Step 6: DMG Size and Signature Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| DMG | S3 Size | Appcast Size | Match |
|
||||||
|
|-----|---------|---------------|-------|
|
||||||
|
| Gaze-0.7.8.dmg | 5,354,270 bytes | 5,354,270 bytes | ✓ |
|
||||||
|
| InputHalo-0.5.2.dmg | 4,999,679 bytes | 4,999,679 bytes | ✓ |
|
||||||
|
|
||||||
|
EdDSA signatures are host-independent — serving from subdomain hosts does not invalidate verification.
|
||||||
|
|
||||||
|
### Step 7: Content Byte-Identical Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Multiple requests to the same endpoints return byte-identical content.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
### 1. Verification Script
|
||||||
|
|
||||||
|
**File:** `scripts/verify-sparkle-dual-host.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docs/sparkle-dual-host-support.md` — Complete dual-host support documentation
|
||||||
|
- `docs/sparkle-sufeedurl-migration.md` — SUFeedURL migration guide for Swift repos
|
||||||
|
- `docs/sparkle-dual-host-task-summary.md` — This file
|
||||||
|
|
||||||
|
### 3. SUFeedURL Migration Documentation
|
||||||
|
|
||||||
|
**Status: Documented for Swift repo owners**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| DMG download endpoint serves real DMG binaries from all five hosts | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Appcast response headers are correct (Content-Type, Cache-Control, CORS) | ✓ VERIFIED | See Step 2 |
|
||||||
|
| Enclosure URLs in appcast XML are absolute `freno.me` URLs | ✓ VERIFIED | See Step 5 |
|
||||||
|
| Sparkle EdDSA signatures are valid (host-independent) | ✓ VERIFIED | See Step 6 |
|
||||||
|
| Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Existing build on legacy SUFeedURL is unaffected (no regression) | ✓ VERIFIED | freno.me endpoints work |
|
||||||
|
| SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos | ✓ VERIFIED | See docs/sparkle-sufeedurl-migration.md |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Complete DNS/Vercel configuration:** Add subdomains to Vercel and configure CNAMEs
|
||||||
|
2. **Run verification script:** `./scripts/verify-sparkle-dual-host.sh` after subdomains are configured
|
||||||
|
3. **Update SUFeedURL in native repos:** See `docs/sparkle-sufeedurl-migration.md`
|
||||||
|
4. **Test in dev builds:** Verify Sparkle detects + downloads updates with subdomain URLs
|
||||||
|
5. **Test regression:** Verify old builds on legacy URLs still work
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites (pass-through rules in place) |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route (serves from S3) |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `docs/sparkle-dual-host-support.md` | Complete documentation |
|
||||||
|
| `docs/sparkle-sufeedurl-migration.md` | SUFeedURL migration guide |
|
||||||
58
docs/sparkle-sufeedurl-migration.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# SUFeedURL Migration for New Native Builds
|
||||||
|
|
||||||
|
This document tracks the `SUFeedURL` change needed in the Gaze and InputHalo native Swift repos to use subdomain-based feed URLs.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [ ] Gaze: Update `SUFeedURL` in Info.plist (~/Code/Gaze/)
|
||||||
|
- [ ] InputHalo: Update `SUFeedURL` in Info.plist (~/Code/InputHalo/)
|
||||||
|
|
||||||
|
## SUFeedURL Changes
|
||||||
|
|
||||||
|
### Gaze
|
||||||
|
|
||||||
|
**File:** `~/Code/Gaze/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### InputHalo
|
||||||
|
|
||||||
|
**File:** `~/Code/InputHalo/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
1. **Keep legacy URL working:** Old builds continue to work via the `/api/*` pass-through rewrite on Vercel
|
||||||
|
2. **No appcast regeneration needed:** The same S3 appcast files serve both URLs
|
||||||
|
3. **EdDSA signatures are host-independent:** No signature changes needed
|
||||||
|
4. **Test before release:** Verify Sparkle detects updates with the new subdomain URL in a dev build
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
1. Set `SUFeedURL` in Info.plist to new subdomain URL
|
||||||
|
2. Build the app
|
||||||
|
3. In the app: "Check for Updates..." → Should find the latest version
|
||||||
|
4. Verify the download completes successfully
|
||||||
|
5. Verify the EdDSA signature verification passes
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Dual-Host Support Documentation](./sparkle-dual-host-support.md)
|
||||||
|
- [Verification Script](../scripts/verify-sparkle-dual-host.sh)
|
||||||
89
docs/subdomain-setup.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Subdomain Setup — freno.me
|
||||||
|
|
||||||
|
This document records the DNS and Vercel domain configuration for the four product subdomains.
|
||||||
|
|
||||||
|
## DNS Configuration
|
||||||
|
|
||||||
|
**DNS Provider:** Google Domains (nameservers: `ns-cloud-a1` through `ns-cloud-a4.googledomains.com`)
|
||||||
|
|
||||||
|
Add the following CNAME records in the [Google Domains DNS console](https://domains.google.com/registrar/freno.me/dns):
|
||||||
|
|
||||||
|
| Subdomain | Type | Target | TTL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nessa` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `lineage` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `gaze` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `inputhalo` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
|
||||||
|
**After adding records:** Wait for DNS propagation (typically minutes) and for Vercel to auto-issue SSL certificates for each subdomain.
|
||||||
|
|
||||||
|
## Vercel Project Domains
|
||||||
|
|
||||||
|
Add the following domains in the [Vercel project Settings → Domains](https://vercel.com/your-team/freno-dev/settings/domains):
|
||||||
|
|
||||||
|
| Domain | Redirects to |
|
||||||
|
|---|---|
|
||||||
|
| `nessa.freno.me` | (no redirect — serves `src/routes/nessa/*` via vercel.json rewrite) |
|
||||||
|
| `lineage.freno.me` | (no redirect — serves `src/routes/lineage/*` via vercel.json rewrite) |
|
||||||
|
| `gaze.freno.me` | (no redirect — serves `src/routes/gaze/*` via vercel.json rewrite) |
|
||||||
|
| `inputhalo.freno.me` | (no redirect — serves `src/routes/inputhalo/*` via vercel.json rewrite) |
|
||||||
|
|
||||||
|
**Do NOT set any of these as the Production Branch domain** — `freno.me` remains the production domain.
|
||||||
|
|
||||||
|
## Rewrite Architecture (`vercel.json`)
|
||||||
|
|
||||||
|
The rewrites are defined in `vercel.json` with **two groups, ordered precisely**:
|
||||||
|
|
||||||
|
### Group 1: `/api/*` pass-throughs (must come first)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
These pass API requests on subdomains straight through to the existing `/api/*` routes. This enables **dual-host Sparkle appcast support**: `gaze.freno.me/api/Gaze/appcast.xml` hits the same `src/routes/api/Gaze/appcast.xml.ts` route as `freno.me/api/Gaze/appcast.xml`.
|
||||||
|
|
||||||
|
### Group 2: `/(.*)` catch-all rewrites
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
These rewrite non-API requests on each subdomain to its internal route prefix. Vercel matches top-to-bottom, so the `/api/*` rules above catch API paths first.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After DNS propagation and Vercel certificate issuance:
|
||||||
|
|
||||||
|
- [ ] `dig nessa.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig lineage.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig gaze.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig inputhalo.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `curl -sI https://nessa.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://lineage.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://gaze.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://inputhalo.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://freno.me/api/Gaze/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] `curl -sI https://freno.me/api/InputHalo/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] `curl -sI https://freno.me/ | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] All four subdomains have valid SSL certificates (no browser warnings)
|
||||||
|
|
||||||
|
## Auth Boundaries
|
||||||
|
|
||||||
|
Auth remains **host-scoped** — no cookie domain broadening:
|
||||||
|
|
||||||
|
- `freno.me` web JWT cookies: host-only on `freno.me`
|
||||||
|
- Nessa: Clerk session tokens (independent)
|
||||||
|
- Lineage: mobile JWT (independent)
|
||||||
|
- Gaze/InputHalo: no web auth
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- DNS records must be added at **Google Domains** (not Vercel's DNS) since freno.me uses Google's nameservers.
|
||||||
|
- Subdomains will 404 until route files exist in `src/routes/<prefix>/*` (content tasks 05–11).
|
||||||
|
- The `bun run build` gate is worktree-friendly; run it before deploying.
|
||||||
33
marketing/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Marketing tree
|
||||||
|
|
||||||
|
One voice, one place. Every later artifact draws on `positioning.md` — do not
|
||||||
|
invent framing per-asset.
|
||||||
|
|
||||||
|
```
|
||||||
|
positioning.md # source of truth: wedge, voice, banned phrasings, approved claims
|
||||||
|
calendar.json # scheduled pushes: date, channel, status
|
||||||
|
briefs/<channel>.md # per-channel rules, format, limits, what "good" looks like
|
||||||
|
drafts/<id>/ # agent output: copy.md, assets/, rationale.md
|
||||||
|
pending/ approved/ posted/ # human gate by directory move
|
||||||
|
metrics/weekly.md # funnel snapshot, generated
|
||||||
|
listening.md # buying-intent threads + drafted replies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard rule
|
||||||
|
|
||||||
|
**`approved/` is the only thing a human ever publishes from. Nothing posts itself.**
|
||||||
|
|
||||||
|
Workflow: agent drafts into `drafts/<id>/` → moves to `pending/` when complete →
|
||||||
|
a human reviews and moves it to `approved/` → the human publishes from `approved/`
|
||||||
|
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.
|
||||||
1
marketing/approved/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# approved
|
||||||
92
marketing/approved/agent-done-signal/copy.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# How do you know when your coding agent is done?
|
||||||
|
|
||||||
|
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
|
||||||
|
> card, verdict: "Approve both — move to approved/"). Not published; a human
|
||||||
|
> places it on nook.freno.me from `approved/`.
|
||||||
|
> This page never names a product of ours and never names a competitor.
|
||||||
|
> Internal target: first page of the FRE-199 pair, Thu 2026-11-06.
|
||||||
|
> CTO review 2026-11-05: changes requested and applied — the `osascript`
|
||||||
|
> example is attributed to the hooks guide, not the hooks reference; pair-1
|
||||||
|
> citations standardised to code.claude.com.
|
||||||
|
|
||||||
|
Often, you don't — not from the terminal alone. A coding agent's turn can end in
|
||||||
|
silence: the prompt returns, the cursor blinks, and a finished turn looks exactly
|
||||||
|
like one that stalled, is still running, or is waiting on you. The fix is to stop
|
||||||
|
trusting silence and wire the two moments that matter — *the turn ended* and *the
|
||||||
|
agent needs you* — to something you will actually see.
|
||||||
|
|
||||||
|
## Why silence is the failure mode
|
||||||
|
|
||||||
|
A terminal coding agent ends a turn by printing a prompt. If you are in another
|
||||||
|
window, another room, or running several agents at once, nothing in that output
|
||||||
|
distinguishes "finished" from "blocked on a permission prompt" from "dead". The
|
||||||
|
task being done and you finding out are two different events, and the second one
|
||||||
|
has no default signal.
|
||||||
|
|
||||||
|
Practitioners keep rediscovering this. One operator running a self-hosted queue
|
||||||
|
of autonomous agent sessions described sessions that "finished their work but
|
||||||
|
never sent the done signal, and held their slots for hours" — their fix was to
|
||||||
|
derive "done" from git state plus an idle transcript instead of trusting the
|
||||||
|
agent's own signal. A public service post about a code-review plugin described
|
||||||
|
the mirror image: a green checkmark and "No findings" when the review had not
|
||||||
|
run at all. An empty result and a successful result rendered identically.
|
||||||
|
|
||||||
|
That is the trap in one sentence: **zero looks exactly like success.**
|
||||||
|
|
||||||
|
## What the turn-ending signal actually is
|
||||||
|
|
||||||
|
Claude Code gives you two hooks that bracket the moments worth surfacing:
|
||||||
|
|
||||||
|
- **`Stop`** fires when the agent finishes responding — the end of a turn.
|
||||||
|
- **`Notification`** fires when Claude Code sends a notification. A permission
|
||||||
|
prompt is one of these: the hook's payload carries a `notification_type` of
|
||||||
|
`permission_prompt` and a message like "Claude needs your permission".
|
||||||
|
- **`StopFailure`** fires instead of `Stop` when the turn ends on an API error —
|
||||||
|
rate limits, authentication problems, and the like.
|
||||||
|
|
||||||
|
All three can run a command of your choosing. The hooks reference documents the
|
||||||
|
events; the hooks guide includes a desktop-notification example built on
|
||||||
|
macOS's `osascript`.
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Wire turn-end to a notification you will actually notice.** A `Stop` hook
|
||||||
|
that calls `osascript -e 'display notification …'` (or your terminal's
|
||||||
|
notification escape) turns the silent prompt-return into a visible event.
|
||||||
|
A terminal bell is not this — it is mute the moment you switch windows.
|
||||||
|
2. **Route permission waits separately from completions.** The `Notification`
|
||||||
|
hook's payload tells you which kind it is. A permission wait means the agent
|
||||||
|
is *blocked on you*; treating it as "done" hides the one state that costs the
|
||||||
|
most time.
|
||||||
|
3. **Don't trust the exit signal alone.** Check the artifacts: `git status`,
|
||||||
|
`git log`, whether the transcript has gone idle. The queue operator's rule
|
||||||
|
generalises — a "done" that has to name what it is waiting on is a "done"
|
||||||
|
you can act on.
|
||||||
|
4. **Surface the failure path too.** A turn that ends on an API error fires
|
||||||
|
`StopFailure`, not `Stop` — an alert wired to the wrong one misses exactly
|
||||||
|
the runs that need attention.
|
||||||
|
|
||||||
|
One next step: take the agent command you run most often and give its end-of-turn
|
||||||
|
a notification you would notice from across the room. That single wire closes the
|
||||||
|
most expensive silence in the workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about Claude Code's
|
||||||
|
behaviour come from its own documentation, re-read 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `Stop` fires when the agent finishes responding (end of a turn); hook cadence is "per turn: UserPromptSubmit, Stop, StopFailure" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks |
|
||||||
|
| 2 | `Notification` fires "when Claude Code sends a notification"; example payload carries `notification_type: "permission_prompt"`, message "Claude needs your permission" | same hooks reference (Notification event section) |
|
||||||
|
| 3 | `StopFailure` "runs instead of Stop when the turn ends due to an API error" (rate limits, authentication) | same hooks reference (StopFailure section) |
|
||||||
|
| 4 | Hooks run a command of your choosing; a desktop-notification example using `osascript` is in the hooks guide | Claude Code hooks guide — https://code.claude.com/docs/en/hooks-guide (desktop-notification example) |
|
||||||
|
| 5 | Operator anecdote: sessions that finished work but never signalled done, holding slots for hours; fix = derive done from git + idle transcript | r/ClaudeCode thread 1xjb3j0 ("Are you running a self-hosted queue of autonomous Claude Code sessions?", 2026-10-31), quoted in `drafts/listening.md` Scan 4 addendum. Quoted as one operator's described practice, not a measurement. |
|
||||||
|
| 6 | PSA anecdote: code-review plugin showed green check + "No findings" without running | r/ClaudeCode 1xmnz2c (2026-11-03), logged in `drafts/listening.md` Scan 6. |
|
||||||
|
| 7 | "Zero looks exactly like success" | Verbatim buyer phrasing, three independent instances (1xm09fs, 1xmnz2c, plus HN repeats) in `drafts/listening.md` Scans 5–8. Used as the problem's name, attributed to no one. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named · no
|
||||||
|
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
|
||||||
|
real threads, described without handles) · no price · no roadmap dates.
|
||||||
72
marketing/approved/agent-done-signal/rationale.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Rationale — "How do you know when your coding agent is done?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 1 of the first pair. Drafted 2026-11-05 by CMO.
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"how do you know when your coding agent is done"** (variants: "agent
|
||||||
|
finished silently", "claude code waiting for permission" overlaps on the
|
||||||
|
blocked-half; this page owns the *done*-half).
|
||||||
|
|
||||||
|
Chosen by the FRE-199 selection rule: strongest buying intent we can answer
|
||||||
|
honestly without naming a product. It is on the standing phrase list
|
||||||
|
(`drafts/listening.md` §The phrase list: "how do I know when my agent is done").
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **Recurrence with independence.** The "zero looks exactly like success"
|
||||||
|
pattern appears in at least four separate sources across Scans 5–9: 1xm09fs,
|
||||||
|
the code-review PSA (1xmnz2c), and two HN commenters (fhub, sadilek), plus
|
||||||
|
the queue operator's done-signal workaround (1xjb3j0). Four independent
|
||||||
|
formulations of one pain in seven days is the strongest recurrence the
|
||||||
|
backlog shows.
|
||||||
|
- **Buying intent.** The people describing it are running agents unattended
|
||||||
|
right now and are losing time to the gap — they are past curiosity.
|
||||||
|
- **Answerable honestly, generically.** The mechanism (Stop / Notification /
|
||||||
|
StopFailure hooks) is documented in the agent's own reference; every claim in
|
||||||
|
the page traces to it. Nothing about our product is needed to answer it.
|
||||||
|
- **Wedge-aligned.** "An agent blocked is the most expensive kind of idle" —
|
||||||
|
the done-half of that sentence (idle you never learn about) is this page.
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeCode 1xjb3j0 — "self-hosted queue of autonomous Claude Code sessions" | 2026-10-31 | Sessions "finished their work but never sent the done signal, and held their slots for hours"; operator derives done from git + idle transcript; "a waiting one has to name what it's waiting on" |
|
||||||
|
| r/ClaudeCode 1xm09fs | 2026-11-02 | First "zero looks exactly like success" instance (verbatim buyer phrasing) |
|
||||||
|
| r/ClaudeCode 1xmnz2c — code-review plugin PSA | 2026-11-03 | Green check + "No findings" when the review never ran; third independent instance |
|
||||||
|
| HN comments (fhub, sadilek) | 2026-11-04/05 | Human-attention concurrency ceiling quantified twice more |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||||
|
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||||
|
|
||||||
|
## Gates before this page ships
|
||||||
|
|
||||||
|
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||||
|
Freno). Page moves `pending/` → `approved/` on his read, per the marketing
|
||||||
|
tree's hard rule.
|
||||||
|
2. **CTO eyes on the agent-behaviour claims** — all four are sourced to the
|
||||||
|
Claude Code hooks reference re-read 2026-11-05, but anything asserted about
|
||||||
|
an agent's behaviour is CTO-verifyable, and the FRE-199 plan routes it to
|
||||||
|
him.
|
||||||
|
3. **Publish path:** `pending/` → `approved/` → a human places it on
|
||||||
|
nook.freno.me (blog route). Nothing posts itself.
|
||||||
|
|
||||||
|
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||||
|
|
||||||
|
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||||
|
- Competitors (notch apps, agent monitors, Remote Control–style tools): not
|
||||||
|
named. Remote Control is not mentioned either — the page answers the done-half
|
||||||
|
without touching the away-from-desk half.
|
||||||
|
- Claude Code is named only as the subject whose documented behaviour the page
|
||||||
|
explains, with its own docs as the source — the same status the listening
|
||||||
|
phrase list already gives it. It is the agent, not a competitor.
|
||||||
|
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||||
|
no roadmap dates.
|
||||||
|
|
||||||
|
## Cadence note
|
||||||
|
|
||||||
|
This page and `agent-waiting-on-permission/` are the first pair. The queue
|
||||||
|
behind them (multiple-agents tracking, invisible-permission cluster) holds well
|
||||||
|
above the three-candidate floor, so the two-a-week cadence holds without
|
||||||
|
inventing phrases.
|
||||||
113
marketing/approved/agent-parallel-tracking/copy.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# How do you keep track of multiple coding agents?
|
||||||
|
|
||||||
|
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
|
||||||
|
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
|
||||||
|
> a human places it on nook.freno.me from `approved/`. Internal target:
|
||||||
|
> Mon 2026-11-09, met four days early. This page never names a product of ours
|
||||||
|
> and never names a competitor.
|
||||||
|
|
||||||
|
The honest answer: not by watching them. Run three agents and the watching
|
||||||
|
already fails — the session you are not reading is the one that finished, or
|
||||||
|
the one sitting on a question, and both states look identical from the outside.
|
||||||
|
The fix is not more windows; it is giving each agent a way to announce its own
|
||||||
|
state, so checking becomes something that happens to you instead of something
|
||||||
|
you have to remember.
|
||||||
|
|
||||||
|
## Why attention is the limit
|
||||||
|
|
||||||
|
Parallel agents are cheap to start and expensive to supervise. Every running
|
||||||
|
session can stop and wait for you — a permission prompt, a question, a finished
|
||||||
|
turn — and from your side of the screen those are invisible until you go look.
|
||||||
|
Operators who run agents at scale keep describing the same wall in different
|
||||||
|
words. One practitioner running dozens of parallel threads a day put it as the
|
||||||
|
limit moving "from tokens to my attention"; another, asked how many sessions
|
||||||
|
they could run, got the answer from their own comment section: not compute —
|
||||||
|
you. A developer who ran agents unattended for a month summarised the failure
|
||||||
|
as silence: "the six hours of silence was the real problem, not the dialog
|
||||||
|
itself." And the most common collision is the simplest — two agents waiting on
|
||||||
|
you while you are deep in the third, and one of them idle so long you lost the
|
||||||
|
thread of what you asked it to do.
|
||||||
|
|
||||||
|
None of that is a speed problem. It is a visibility problem: *which* of your
|
||||||
|
agents needs you, and *how do you find out without touring every window?*
|
||||||
|
|
||||||
|
## What a per-agent signal actually is
|
||||||
|
|
||||||
|
A terminal coding agent has no shared dashboard by default — each session is
|
||||||
|
its own process, its own window. But its state changes are events, and events
|
||||||
|
can be wired outward. Claude Code documents the pieces:
|
||||||
|
|
||||||
|
- **Every state you care about fires an event.** `Stop` fires when Claude
|
||||||
|
finishes responding — the end of a turn. `Notification` fires when Claude
|
||||||
|
Code sends a notification, and the notification's type tells you which kind
|
||||||
|
of waiting it is: a `permission_prompt`, an `idle_prompt`, `agent_needs_input`,
|
||||||
|
or `agent_completed`. `SessionStart` and `SessionEnd` bracket the whole
|
||||||
|
session.
|
||||||
|
- **The event names the session it came from.** Every hook payload carries a
|
||||||
|
`session_id`, so one handler can serve every window and still know which
|
||||||
|
agent fired.
|
||||||
|
- **A hook is just a command you choose.** The events are configured in the
|
||||||
|
settings files and run whatever command you point them at — append to a log
|
||||||
|
file, update a status line, send a desktop notification that names the
|
||||||
|
project.
|
||||||
|
- **The status line can carry per-session state.** Claude Code's status line
|
||||||
|
is a customizable bar driven by a script you write; it receives session data
|
||||||
|
on every render, and the docs name "work across multiple sessions and need
|
||||||
|
to distinguish them" as a reason it exists.
|
||||||
|
|
||||||
|
The pattern in all four: stop touring windows and make each agent's
|
||||||
|
running / waiting / finished state observable where you already are.
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Name the states before naming the tools.** For your workflow the states
|
||||||
|
are usually three — running, waiting on you, finished — and the expensive
|
||||||
|
one is always "waiting on you". A tracking setup that cannot distinguish a
|
||||||
|
permission wait from a completion will quietly hide the state that costs
|
||||||
|
the most.
|
||||||
|
2. **Wire the waiting events first.** A `Notification` hook matched on
|
||||||
|
`permission_prompt` and `agent_needs_input` catches the blocked cases; the
|
||||||
|
payload's `session_id` lets one handler label each alert with the project
|
||||||
|
it came from.
|
||||||
|
3. **Route completions separately.** `Stop` means the turn ended — not that
|
||||||
|
the work was right, and not that nothing is blocked elsewhere. Treating
|
||||||
|
"finished" and "needs you" as one notification is how a waiting agent gets
|
||||||
|
buried under a pile of done messages.
|
||||||
|
4. **Make the state visible without switching windows.** A per-session status
|
||||||
|
line, a log file the hook appends to, or a desktop notification that names
|
||||||
|
the project all work — the test is whether you can answer "does anything
|
||||||
|
need me?" without leaving the window you are in.
|
||||||
|
5. **Expect the ceiling to be yours.** Operators consistently find the
|
||||||
|
bottleneck before the machines do. The number of agents you can run is the
|
||||||
|
number whose waiting state reaches you reliably — not the number your
|
||||||
|
terminal can host.
|
||||||
|
|
||||||
|
One next step: run two agents side by side and wire only their permission-wait
|
||||||
|
events to a notification that names the project. The first time the second
|
||||||
|
agent's prompt reaches you before you happen to check its window is the whole
|
||||||
|
argument.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about Claude
|
||||||
|
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `Stop` fires when Claude finishes responding; `SessionStart`/`SessionEnd` are per-session events | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (hook events table; "per session: SessionStart and SessionEnd; per turn: UserPromptSubmit, Stop, and StopFailure") |
|
||||||
|
| 2 | `Notification` fires when Claude Code sends a notification; matcher supports notification types including `permission_prompt`, `idle_prompt`, `agent_needs_input`, `agent_completed` | same hooks reference (matcher table: "Notification | notification type") |
|
||||||
|
| 3 | Every hook payload carries `session_id`, the current session identifier | same hooks reference (Common input fields: `session_id` — "Current session identifier") |
|
||||||
|
| 4 | Hooks are configured in the settings files and run a command of your choosing | same hooks reference (configuration and command-hook examples) |
|
||||||
|
| 5 | The status line is a customizable bar driven by a script, receives JSON session data, and the docs name "work across multiple sessions and need to distinguish them" as a use | Claude Code status line doc — https://code.claude.com/docs/en/statusline |
|
||||||
|
| 6 | "The limit moved from tokens to my attention" / "fifty a day add up to something I could feel by Friday" | r/ClaudeCode 1xh5cum (2026-10-27, "Four months of 50+ parallel agent threads a day"), logged in `drafts/listening.md` Scan 3. Practitioner's own account, not a measurement. |
|
||||||
|
| 7 | "How many sessions before you become the bottleneck" — the thread went to compute; the attention half stayed on the table | r/ClaudeCode 1xdjb2g (2026-10-23, 72 comments), logged in `drafts/listening.md` Scan 1. |
|
||||||
|
| 8 | "The six hours of silence was the real problem, not the dialog itself" | r/ClaudeCode 1xipsk0 (2026-10-29, "~40 Claude Code agents unattended for a month"), quoted by the OP in-thread, logged in `drafts/listening.md` Scan 2. |
|
||||||
|
| 9 | Two agents waiting while the human is in the third; idle so long the thread of the task was lost; "most of my idle time came from agents waiting on a permission prompt while I was in another window" | r/ClaudeAI 1xmzp2n (2026-11-03, "How do you keep track of multiple coding agents running in parallel?"), two independent commenters, logged in `drafts/listening.md` Scan 9. |
|
||||||
|
| 10 | "You're the bottleneck agents were meant to eliminate" — approvals routing through one person | r/ClaudeCode 1xk0ghj (2026-10-31, "multiple agents working on multiple projects"), commenter's words, logged in `drafts/listening.md` Scan 4. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named (herdr,
|
||||||
|
tmux, dashboards: none named — the page answers generically) · no handles · no
|
||||||
|
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
|
||||||
|
real threads, described without handles) · no price · no roadmap dates.
|
||||||
71
marketing/approved/agent-parallel-tracking/rationale.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Rationale — "How do you keep track of multiple coding agents?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 1 of the second pair. Drafted 2026-11-05 by CMO (internal
|
||||||
|
target Mon 2026-11-09, beaten by four days — the backlog held, so drafting
|
||||||
|
early cost nothing).
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"how do you keep track of multiple coding agents running in
|
||||||
|
parallel"** (variants: "how many sessions before you become the bottleneck",
|
||||||
|
"which agent is waiting", "agent tracking dashboard").
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **Strongest recurrence in the backlog.** Seven independent threads across
|
||||||
|
nine scans state the multi-agent attention problem in their own words — more
|
||||||
|
than any other cluster after the pair-1 phrases. It is the natural next page:
|
||||||
|
the pair-1 pages answer *one* agent's silence (done-signal) and *one*
|
||||||
|
permission wait (waiting-on-permission); this is the fleet-scale version of
|
||||||
|
the same pain, and the buyers are already at fleet scale.
|
||||||
|
- **Buying intent.** Every quoting operator is running multiple agents *now*
|
||||||
|
and losing time to supervision, not exploring.
|
||||||
|
- **Answerable honestly, generically.** The mechanism (per-session hook events
|
||||||
|
with `session_id`, typed notifications, the status line) is documented in the
|
||||||
|
agent's own reference; every claim traces to it. No product needed.
|
||||||
|
- **Distinct from pair 1.** Done-signal owns "one agent, is it finished";
|
||||||
|
waiting-on-permission owns "one agent, why is it blocked". This page owns
|
||||||
|
"several agents, which one needs me" — the phrase, the evidence, and the
|
||||||
|
mechanism (typed Notification matchers, `session_id`, status line) do not
|
||||||
|
overlap pair 1's.
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeAI 1xmzp2n — "How do you keep track of multiple coding agents running in parallel?" | 2026-11-03 | The exact question as a fresh thread: "How do you notice when one is waiting, done, or stuck?"; two commenters independently: agents "sat waiting on a permission prompt while I was deep in the third", idle "long enough that I lost the thread of what I had asked it to do" |
|
||||||
|
| r/ClaudeCode 1xh5cum — "Four months of 50+ parallel agent threads a day" | 2026-10-27 | "The limit moved from tokens to my attention"; "fifty a day add up to something I could feel by Friday" |
|
||||||
|
| r/ClaudeCode 1xdjb2g — "How many sessions can you run before you become the bottleneck?" | 2026-10-23 | 72 comments; the thread went to compute and left the attention half open |
|
||||||
|
| r/ClaudeCode 1xipsk0 — "~40 Claude Code agents unattended for a month" | 2026-10-29 | "The six hours of silence was the real problem, not the dialog itself" |
|
||||||
|
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate" |
|
||||||
|
| Hacker News 50298436 (herdr launch) | 2026-10-30 | A 30-year tmux user's stated switch reason: "visual indication which agent is working and which is waiting for an input" — used in rationale only; the page does not name any tool |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||||
|
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||||
|
|
||||||
|
## Gates before this page ships
|
||||||
|
|
||||||
|
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||||
|
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read, per
|
||||||
|
the marketing tree's hard rule.
|
||||||
|
2. **CTO eyes on the agent-behaviour claims** — five doc-sourced claims
|
||||||
|
(hooks reference ×4, statusline doc ×1), all re-read 2026-11-05. Routed to
|
||||||
|
the CTO per the FRE-199 plan.
|
||||||
|
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
|
||||||
|
Nothing posts itself.
|
||||||
|
|
||||||
|
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||||
|
|
||||||
|
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||||
|
- Competitors: not named. The herdr evidence is rationale-only; the page's
|
||||||
|
"what to check" recommends no tool at all — states and wiring, not products.
|
||||||
|
- Claude Code is named only as the subject whose documented behaviour the page
|
||||||
|
explains, with its own docs as the source — same status as pair 1.
|
||||||
|
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||||
|
no roadmap dates. Anecdotes are real threads, described without handles.
|
||||||
|
|
||||||
|
## Cadence note
|
||||||
|
|
||||||
|
Second pair. Queue behind it: the run-without-asking cluster (same pair), the
|
||||||
|
fan-control phrase, and the invisible-permission variants — above the
|
||||||
|
three-candidate floor. Two a week holds.
|
||||||
102
marketing/approved/agent-run-without-asking/copy.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Should you let your coding agent run without asking permission?
|
||||||
|
|
||||||
|
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
|
||||||
|
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
|
||||||
|
> a human places it on nook.freno.me from `approved/`. Internal target:
|
||||||
|
> Mon 2026-11-09, met four days early. This page never names a product of ours
|
||||||
|
> and never names a competitor.
|
||||||
|
> CTO review 2026-11-05: changes requested and applied — the `Bash(git commit *)`
|
||||||
|
> allowed / `Bash(git push *)` denied example removed (the docs' own
|
||||||
|
> "What a Bash rule doesn't match" table shows a Bash rule does not stop every
|
||||||
|
> invocation); the precedence claim stands.
|
||||||
|
|
||||||
|
You can — and the honest answer is that "should" is not one decision but two:
|
||||||
|
what you are letting it do, and what happens when it does the wrong thing.
|
||||||
|
Every operator who runs agents unattended converges on the same split — a free
|
||||||
|
hand on what git can undo, a confirmation on what it can't — and the agent's
|
||||||
|
own permission modes exist to draw that line somewhere you choose, not
|
||||||
|
somewhere a flag chooses for you.
|
||||||
|
|
||||||
|
## The mechanism: a spectrum, not a switch
|
||||||
|
|
||||||
|
Claude Code's permission modes form a ladder from "ask for everything" to "ask
|
||||||
|
for almost nothing", and each rung has a documented meaning:
|
||||||
|
|
||||||
|
- **`default`** prompts on the first use of each tool.
|
||||||
|
- **`acceptEdits`** auto-accepts file edits and common filesystem commands
|
||||||
|
(`mkdir`, `touch`, `mv`, `cp`) inside the working directory.
|
||||||
|
- **`plan`** reads files and runs read-only shell commands but does not edit
|
||||||
|
your source files — the agent explores, you approve the plan.
|
||||||
|
- **`auto`** runs without routine prompts; before actions such as shell
|
||||||
|
commands and network requests run, a background classifier checks that they
|
||||||
|
align with your request.
|
||||||
|
- **`bypassPermissions`** skips permission prompts, except for actions no mode
|
||||||
|
auto-approves.
|
||||||
|
|
||||||
|
Two documented facts should shape where you stop on that ladder. First, the
|
||||||
|
docs' own warning on the top rung: in `bypassPermissions` mode Claude Code
|
||||||
|
skips prompts — including for writes to protected paths such as `.git` — and
|
||||||
|
the mode belongs "in isolated environments like containers or VMs where Claude
|
||||||
|
Code can't cause damage". Second, enforcement is real, not advisory: the docs
|
||||||
|
state plainly that "permission rules are enforced by Claude Code, not by the
|
||||||
|
model" — a `CLAUDE.md` instruction asking nicely for permission changes
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Decide by blast radius, not by annoyance.** The question "how do I get
|
||||||
|
fewer prompts?" tempts a jump to the top of the ladder. The better
|
||||||
|
question: which of this agent's actions would be *undoable* if wrong? Edits
|
||||||
|
inside a git worktree are; `rm -rf` on the wrong path, a pushed commit, and
|
||||||
|
a message sent to a third party are not.
|
||||||
|
2. **Move down the ladder one rung at a time.** `acceptEdits` in a worktree
|
||||||
|
covers most of the prompt volume with the undo still in reach. If you
|
||||||
|
consider `auto` or `bypassPermissions`, the docs' own container-or-VM
|
||||||
|
condition is the bar: isolation first, autonomy second.
|
||||||
|
3. **Write the rules instead of trusting the mode.** Rules are evaluated in
|
||||||
|
order — deny, then ask, then allow — and the first match wins, so a broad
|
||||||
|
deny holds even against a narrower allow. (What a Bash rule cannot do is
|
||||||
|
match every way the same binary gets invoked — see the next step.)
|
||||||
|
4. **Know what the rules do not cover.** A Bash rule matches the command text
|
||||||
|
Claude writes, and the docs are explicit that this "isn't a security
|
||||||
|
boundary around the program" — the same binary invoked by another path is
|
||||||
|
not matched. For filesystem and network enforcement that does not depend
|
||||||
|
on command text, the docs point at sandboxing.
|
||||||
|
5. **Use hooks as the programmable layer.** A `PreToolUse` hook sees every
|
||||||
|
tool call before it runs and can return a `deny` decision — the reference
|
||||||
|
builds one that blocks `rm -rf`. A silent hook does not approve anything;
|
||||||
|
the call just continues through the normal permission flow.
|
||||||
|
6. **Leave the failure path a way to reach you.** An unattended run that hits
|
||||||
|
a prompt nobody sees stops silently — that is the failure mode of unattended
|
||||||
|
work, not of permissions. Whatever mode you pick, wire the
|
||||||
|
"waiting on you" event somewhere you will actually notice.
|
||||||
|
|
||||||
|
One next step: write down the three actions your agent takes that you could
|
||||||
|
not undo, and put them in `deny` or `ask` rules today. Whatever mode you run
|
||||||
|
above them, those three now require a human — which is the whole point of the
|
||||||
|
ladder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about Claude
|
||||||
|
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Mode meanings: `default` prompts on first use; `acceptEdits` auto-accepts file edits and common filesystem commands (`mkdir`, `touch`, `mv`, `cp`) in the working directory; `plan` reads files and runs read-only shell commands but doesn't edit source; `auto` runs without routine prompts with a background classifier checking alignment; `bypassPermissions` skips prompts except actions no mode auto-approves | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
|
||||||
|
| 2 | Docs' warning: in `bypassPermissions` mode Claude Code skips prompts including for writes to protected paths such as `.git`; "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same permissions doc (warning under the modes table) |
|
||||||
|
| 3 | "Permission rules are enforced by Claude Code, not by the model" | same permissions doc (note under Manage permissions) |
|
||||||
|
| 4 | Rules evaluated in order deny → ask → allow; first match wins; a broad deny holds against a narrower allow | same permissions doc (Manage permissions) |
|
||||||
|
| 5 | A Bash rule matches the command text Claude writes and "isn't a security boundary around the program" (`Bash(curl *)` doesn't stop `/usr/bin/curl …`); for filesystem/network enforcement independent of command text the docs point at sandboxing | same permissions doc (What a Bash rule doesn't match) |
|
||||||
|
| 6 | A `PreToolUse` hook fires before a tool call executes, can block it, and can return a `permissionDecision` of `"deny"`; the reference builds a hook that blocks `rm -rf`; a silent hook does not approve — the call continues through the normal permission flow | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (PreToolUse decision control walkthrough) |
|
||||||
|
| 7 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum; also r/ClaudeCode 1xexhwf commenters (VM, worktrees, blast-radius split). Described as operator practice, not a rule. |
|
||||||
|
| 8 | An unattended run that hits a prompt nobody sees stops silently | r/ClaudeCode 1xipsk0 (2026-10-29), commenter: "A run started from launchd or a background service can hit a prompt that no one will ever see", logged in `drafts/listening.md` Scan 2. |
|
||||||
|
| 9 | The trust asymmetry: comfortable with a model writing code, "considerably less comfortable giving an autonomous agent broad access to my development environment" | r/ClaudeCode 1xk8imc (2026-10-31, "Has anyone built a safe, autonomous coding workflow…?", 43 comments), OP's words, logged in `drafts/listening.md` Scan 4. |
|
||||||
|
| 10 | The operator's own safety menu: "committing before every run? containers? `--dangerously-skip-permissions` and hoping?" | r/ClaudeAI 1xk277d (2026-10-31, "How often has a coding agent done something you had to undo?"), OP's words, logged in `drafts/listening.md` Scan 4. Quoted as the question's shape, not as guidance. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named · no
|
||||||
|
handles · no superlatives · no benchmark numbers · no invented testimonials ·
|
||||||
|
no price · no roadmap dates. The docs' container/VM warning is quoted as the
|
||||||
|
agent's own documentation, not as our policy.
|
||||||
75
marketing/approved/agent-run-without-asking/rationale.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Rationale — "Should you let your coding agent run without asking permission?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 2 of the second pair. Drafted 2026-11-05 by CMO (internal
|
||||||
|
target Mon 2026-11-09, beaten by four days).
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"do you let your coding agent run without asking permission"**
|
||||||
|
(variants: "safe autonomous coding workflow", "approve every step or skip
|
||||||
|
permissions", "is dangerously-skip-permissions safe").
|
||||||
|
|
||||||
|
On the standing phrase list (`drafts/listening.md` §The phrase list: "do you
|
||||||
|
let claude code run without asking").
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **Recurrence with independence.** Four separate threads state the autonomy
|
||||||
|
question from four angles: the direct one (1xexhwf, 24 comments), the trust
|
||||||
|
asymmetry (1xk8imc, 43 comments), the safety menu (1xk277d), and the
|
||||||
|
remove-permissions ask (1xfe6eh — Desktop, recorded, not answered). The
|
||||||
|
cluster has run continuously since Scan 1.
|
||||||
|
- **Buying intent.** These are people configuring or rejecting autonomy right
|
||||||
|
now — the objection vocabulary came straight out of this cluster
|
||||||
|
("--dangerously-skip-permissions and hoping").
|
||||||
|
- **Answerable honestly, generically.** The mode ladder, the docs' own
|
||||||
|
container/VM warning, the enforcement note, and `PreToolUse` deny are all
|
||||||
|
documented; every claim traces to the agent's docs. No product needed.
|
||||||
|
- **Distinct from pair 1's permission page.** `agent-waiting-on-permission`
|
||||||
|
answers "why is it *blocked* and why didn't I know" — the visibility of a
|
||||||
|
single stuck prompt. This page answers "should I *grant* autonomy and how do
|
||||||
|
I bound it" — the mode ladder, rule precedence, sandboxing, and hooks as
|
||||||
|
enforcement. Shared vocabulary (modes, rules) exists because the docs are
|
||||||
|
one system; the question, the claims, and the advice do not overlap.
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeCode 1xexhwf — "Do you let Claude Code run without asking, or approve every step?" | 2026-10-25 | The direct question, 24 comments; the thread became a safety argument (VM, worktrees, empty-variable `rm -rf`) — that argument *is* the page's subject |
|
||||||
|
| r/ClaudeCode 1xk8imc — "Has anyone built a safe, autonomous coding workflow…?" | 2026-10-31 | "Comfortable having another model work on code. Considerably less comfortable giving an autonomous agent broad access to my development environment" — the trust asymmetry, 43 comments |
|
||||||
|
| r/ClaudeAI 1xk277d — "How often has a coding agent done something you had to undo?" | 2026-10-31 | "What you do to stay safe today: committing before every run? containers? `--dangerously-skip-permissions` and hoping?" — the operator's own menu |
|
||||||
|
| r/ClaudeCode 1xipsk0 — "~40 agents unattended for a month" | 2026-10-29 | "A run started from launchd or a background service can hit a prompt that no one will ever see" — the unattended failure path |
|
||||||
|
| r/ClaudeCode 1xkoz06 | 2026-10-31 | The undo-split practice (also cited on pair 1; real thread, described without handles) |
|
||||||
|
| r/ClaudeAI 1xfe6eh — "How can I remove Claude's need to ask me for permission…" | 2026-10-30 | Recorded, not used: Claude Desktop computer-use, not Code — a mention there would be noise. Shows the phrase's reach |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` (The-Nook local master line, single
|
||||||
|
canonical copy per FRE-748) with URLs and verbatim quotes.
|
||||||
|
|
||||||
|
## Gates before this page ships
|
||||||
|
|
||||||
|
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||||
|
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read.
|
||||||
|
2. **CTO eyes on the agent-behaviour claims** — six doc-sourced claims
|
||||||
|
(permissions doc ×5, hooks reference ×1), all re-read 2026-11-05. Routed to
|
||||||
|
the CTO per the FRE-199 plan.
|
||||||
|
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
|
||||||
|
Nothing posts itself.
|
||||||
|
|
||||||
|
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||||
|
|
||||||
|
- The Nook: not named. The notch, the island, the companion: not mentioned.
|
||||||
|
- Competitors: not named. Sandboxing and hooks are described as the agent's
|
||||||
|
own documented mechanisms, not as anyone's product.
|
||||||
|
- Claude Code is named only as the subject whose documented behaviour the page
|
||||||
|
explains, with its own docs as the source — same status as pair 1.
|
||||||
|
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||||
|
no roadmap dates. Anecdotes are real threads, described without handles.
|
||||||
|
|
||||||
|
## Cadence note
|
||||||
|
|
||||||
|
Second pair. Queue behind it: the fan-control phrase ("mac fan control
|
||||||
|
claude", standing phrase list), the invisible-permission variants
|
||||||
|
(dontallow/TCC cluster), and the notification-reliability thread (1xm6frp,
|
||||||
|
held back for overlap with done-signal). Above the three-candidate floor; two
|
||||||
|
a week holds.
|
||||||
86
marketing/approved/agent-waiting-on-permission/copy.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Why is my coding agent sitting on a permission prompt?
|
||||||
|
|
||||||
|
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
|
||||||
|
> card, verdict: "Approve both — move to approved/"). Not published; a human
|
||||||
|
> places it on nook.freno.me from `approved/`.
|
||||||
|
> This page never names a product of ours and never names a competitor.
|
||||||
|
> Internal target: second page of the FRE-199 pair, Fri 2026-11-07.
|
||||||
|
> CTO review 2026-11-05: changes requested and applied — rule precedence
|
||||||
|
> corrected to deny / ask / allow (body previously read allow / ask / deny,
|
||||||
|
> contradicting the fact-check table and the source); pair-1 citations
|
||||||
|
> standardised to code.claude.com.
|
||||||
|
|
||||||
|
Because it tried to do something its permission settings don't allow, and the
|
||||||
|
default is to wait — indefinitely and quietly — for you. The prompt is not the
|
||||||
|
expensive part; the hour you spent not knowing it was waiting is. The way out is
|
||||||
|
not to approve faster, it's to make the prompts rare and the waiting visible.
|
||||||
|
|
||||||
|
## The mechanism
|
||||||
|
|
||||||
|
Claude Code's permission system decides, per tool call, whether to run, refuse,
|
||||||
|
or stop and ask. In its `default` mode, it prompts for permission on the first
|
||||||
|
use of each tool. Modes are set with `defaultMode` in the settings files, and a
|
||||||
|
session's rules come from deny / ask / allow lists evaluated in that order —
|
||||||
|
the first match wins.
|
||||||
|
|
||||||
|
You can see the whole rule set at any time with `/permissions`, which lists
|
||||||
|
every permission rule and the settings file each one came from. Rules can name
|
||||||
|
a specific use — `Bash(npm run build)` matches exactly that command — or a
|
||||||
|
wildcard, like `Bash(git log *)`.
|
||||||
|
|
||||||
|
So the prompt you came back to an hour later is not a malfunction. It is the
|
||||||
|
system working as configured, on a call your rules never covered — and the
|
||||||
|
"waiting" state being invisible is what turned ten seconds of approval into a
|
||||||
|
lost hour.
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Read the prompt before automating it away.** It names the tool and the
|
||||||
|
input. The right response to "it keeps asking" is usually a rule, not a
|
||||||
|
wider mode.
|
||||||
|
2. **Allowlist the undoable, prompt on the irreversible.** Reads, tests, and
|
||||||
|
status commands are safe to allow — `git` is the undo for edits. Anything
|
||||||
|
that reaches another person or another system — network calls, deletes,
|
||||||
|
deploys — is worth keeping on ask. Rule syntax is in the permission docs;
|
||||||
|
`/permissions` shows what you already have.
|
||||||
|
3. **Consider `acceptEdits` before anything broader.** It auto-accepts file
|
||||||
|
edits and common filesystem commands inside the working directory. That is
|
||||||
|
usually the difference between ten prompts a session and two — without
|
||||||
|
handing over network or delete.
|
||||||
|
4. **Bound the blast radius instead of removing the gate.** If you do widen
|
||||||
|
what runs unattended, do it where a mistake is recoverable: a worktree per
|
||||||
|
task, commits pushed early, nothing irreversible in reach. Operators who run
|
||||||
|
agents unattended describe exactly this split — free hand on what git can
|
||||||
|
undo, confirmation on what it can't.
|
||||||
|
5. **Make the wait visible.** Claude Code fires a `Notification` hook when it
|
||||||
|
sends a notification, and a permission prompt is one — the payload carries a
|
||||||
|
`notification_type` of `permission_prompt`. A hook on that event, wired to a
|
||||||
|
desktop notification you'd see from another window, turns the silent wait
|
||||||
|
back into a ten-second approval.
|
||||||
|
|
||||||
|
One next step: run `/permissions` today and add allow rules for the three
|
||||||
|
commands you find yourself approving most. The prompts that remain will be the
|
||||||
|
ones that deserve a read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about Claude
|
||||||
|
Code's behaviour come from its own documentation, re-read 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | In `default` mode Claude Code "prompts for permission on first use of each tool" | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
|
||||||
|
| 2 | `defaultMode` in settings files sets the default mode for new sessions | same permissions doc (Permission modes section) |
|
||||||
|
| 3 | Rules are evaluated deny → ask → allow, first match wins | same permissions doc (Permission rule syntax) |
|
||||||
|
| 4 | `/permissions` lists all permission rules and the settings.json each comes from | same permissions doc (Manage permissions) |
|
||||||
|
| 5 | `Bash(npm run build)` matches the exact command; `Bash(git log *)` wildcard form | same permissions doc (Use specifiers for fine-grained control) |
|
||||||
|
| 6 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, cp for paths in the working directory" | same permissions doc (Permission modes table) |
|
||||||
|
| 7 | A `Notification` hook fires when Claude Code sends a notification; permission prompts carry `notification_type: "permission_prompt"` and a message like "Claude needs your permission" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (Notification event) |
|
||||||
|
| 8 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31, u/tomm3hshipp thread), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum. Described as one operator's approach, not a rule. |
|
||||||
|
| 9 | The 55-minutes-lost framing | r/ClaudeAI 1xhbc71 (2026-10-28): "coming back an hour later to find it's been sitting on a permission prompt for 55 minutes". Used as the problem's shape, not quoted as a person. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named · no
|
||||||
|
superlatives · no benchmark numbers · no invented testimonials · no price · no
|
||||||
|
roadmap dates.
|
||||||
77
marketing/approved/agent-waiting-on-permission/rationale.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Rationale — "Why is my coding agent sitting on a permission prompt?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 2 of the first pair. Drafted 2026-11-05 by CMO.
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"claude code waiting for permission"** and the away-from-desk variant
|
||||||
|
("approvals when you're away from the computer"). First phrase on the standing
|
||||||
|
list (`drafts/listening.md` §The phrase list).
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **The strongest single thread in the backlog.** r/ClaudeAI 1xhbc71 (2026-10-28,
|
||||||
|
47 comments and moving) asks the buying question outright: kicked off a long
|
||||||
|
task, came back an hour later, agent had been sitting on a permission prompt
|
||||||
|
for 55 minutes. The thread's own answers (remote control, tmux+SSH,
|
||||||
|
skip-permissions, allowlists) prove demand; none of them makes the wait
|
||||||
|
visible at the machine — that is the honest gap this page answers without
|
||||||
|
naming anyone.
|
||||||
|
- **The week's objection vocabulary.** Scans 1–9 keep returning "the model keeps
|
||||||
|
handing control back" and "stops and waits for me" — permission friction is
|
||||||
|
the category's core complaint, not a niche one.
|
||||||
|
- **Recurring independent accounts.** The invisible-permission theme has three
|
||||||
|
independent accounts in one week (1xksutq — gh used without a visible prompt;
|
||||||
|
1xkejsp's Linux panel; the scheduled-tasks silent-stall finding), so the
|
||||||
|
search demand is not one loud thread.
|
||||||
|
- **Wedge-aligned.** This is the literal wedge sentence's territory — "an agent
|
||||||
|
blocked on a permission prompt is the most expensive kind of idle" — answered
|
||||||
|
with the agent's own documented mechanism.
|
||||||
|
|
||||||
|
## Deliberate scoping
|
||||||
|
|
||||||
|
This page owns the **blocked-half** of the approval problem (why prompts happen,
|
||||||
|
how to make them rare, how to make the wait visible at the machine). The
|
||||||
|
**away-half** (controlling a remote agent from elsewhere) is a different phrase,
|
||||||
|
a different page, and one where the honest answer is dominated by the agent
|
||||||
|
vendor's own remote feature — the page deliberately does not go there
|
||||||
|
(`drafts/listening.md` Scan 1 category intel #1).
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeAI 1xhbc71 — "How do you handle Claude Code approvals when you're away from your computer?" | 2026-10-28 | 55-minute wait on a permission prompt; 47 comments of demand; "what's still annoying about your setup?" |
|
||||||
|
| r/ClaudeCode 1xkoz06 — "How permissive are you with AI and for how long?" | 2026-10-31 | The undo-split: "file edits, tests and branches get a free hand, since git is the undo. Anything that reaches another person … stays on confirm" |
|
||||||
|
| r/ClaudeCode 1xksutq — gh used without visible prompt | 2026-11-01 | Third independent invisible-permission account of the week |
|
||||||
|
| r/ClaudeCode 1xkejsp — Linux panel app | 2026-10-31 | Per-session "Needs You" differentiation shipping elsewhere; the need is real and cross-platform |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` with URLs and verbatim quotes.
|
||||||
|
|
||||||
|
## Gates before this page ships
|
||||||
|
|
||||||
|
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
|
||||||
|
Freno); page moves `pending/` → `approved/` on his read.
|
||||||
|
2. **CTO eyes on the agent-behaviour claims** — all eight doc-sourced claims
|
||||||
|
re-read 2026-11-05; the FRE-199 plan routes anything asserted about an
|
||||||
|
agent's behaviour to the CTO.
|
||||||
|
3. **Publish path:** `pending/` → `approved/` → a human places it on
|
||||||
|
nook.freno.me (blog route). Nothing posts itself.
|
||||||
|
|
||||||
|
## Ban compliance (FRE-199 + positioning.md rule 1)
|
||||||
|
|
||||||
|
- The Nook: not named; notch/island/companion not mentioned.
|
||||||
|
- Competitors: not named — including the agent vendor's remote feature, which
|
||||||
|
the page never needs to mention because it stays on the at-the-machine half.
|
||||||
|
- Claude Code is named only as the subject whose documented permission system
|
||||||
|
the page explains, with its own docs as the source — same status the
|
||||||
|
listening phrase list already gives it.
|
||||||
|
- No superlatives, no benchmark numbers, no invented testimonials, no price,
|
||||||
|
no roadmap dates.
|
||||||
|
|
||||||
|
## Cadence note
|
||||||
|
|
||||||
|
Second page of the first pair. Queue behind it: multi-agent tracking ("How do
|
||||||
|
you keep track of multiple coding agents running in parallel?", 1xm6frp,
|
||||||
|
2026-11-03) and the invisible-permission cluster — the queue holds above the
|
||||||
|
three-candidate floor, so the two-a-week cadence holds.
|
||||||
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# briefs
|
||||||
91
marketing/briefs/registry-wave-2.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Registry wave 2 — brief and copy (FRE-199 item 3)
|
||||||
|
|
||||||
|
**Owner:** CMO prepares · sponsor submits · **Copy ready 2026-11-05** (target was
|
||||||
|
Fri 2026-11-13) · **Submit:** week of 2026-11-16, after the wave-1 readback.
|
||||||
|
**Status:** `briefs/` — nothing here has been posted; humans hold every account;
|
||||||
|
no agent authenticates, posts or sends.
|
||||||
|
|
||||||
|
## What wave 2 is, and what it waits for
|
||||||
|
|
||||||
|
Wave 1 is FRE-207's packet (`The-Nook` worktree `drafts/directory-submissions.md`
|
||||||
|
+ `drafts/click-list.md`): 8 targets, CTO claim verdict applied 2026-11-01
|
||||||
|
(C1–C5 signed, C7 refused → relay sentence), sponsor-owned inputs G2 (price
|
||||||
|
line), G3 (`@freno.me` mailbox), G4 (screenshots). **Wave-2 submission waits on
|
||||||
|
the wave-1 readback** — what actually got submitted, what got listed, what was
|
||||||
|
rejected and why — so targets can be re-ranked on evidence instead of guesswork.
|
||||||
|
The readback lives on the FRE-207 issue; this brief is ready either way.
|
||||||
|
|
||||||
|
**Copy rule inherited from wave 1:** paste the wave-1 blocks, do not improvise.
|
||||||
|
Only claims signed by the CTO verdict ship:
|
||||||
|
|
||||||
|
- C1 — works with 18 coding agents (compatibility fact, not a competitor mention).
|
||||||
|
- C2 — answer an agent's approval from your Mac, iPhone or Watch.
|
||||||
|
- C3 — sessions keep arriving while the Mac app is closed (widgets; fleet status only).
|
||||||
|
- C4 — macOS 14.2+, universal.
|
||||||
|
- C5 — iPhone/iPad and Apple Watch companions are **TestFlight beta**; no App Store link anywhere.
|
||||||
|
- C7 — "no cloud service of its own" is refused; block D ships the relay sentence.
|
||||||
|
- **Price line (G2):** "Free 14-day trial" only — no number — until CoS + sponsor
|
||||||
|
clear the "$10 one-time licence" line for public listings (FRE-204 holds $10 in-app;
|
||||||
|
the listing text is a separate publish decision).
|
||||||
|
- Banned: superlatives, user counts, testimonials, competitor names.
|
||||||
|
|
||||||
|
## Targets — fetched live 2026-11-05, marked per the wave-1 legend
|
||||||
|
|
||||||
|
| # | Target | What it is | How it ships | Fit | Verified |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| W2-1 | `iCHAIT/awesome-macOS` (19.4k ★) | Curated macOS app list; PR-based | PR adding one line to the relevant section from `contributing.md`; copy: block A + block B compressed | High — category fit, persistent link | ✅ repo + contributing rules read 2026-11-05 |
|
||||||
|
| W2-2 | 1000.tools | Curated tool directory, self-serve categories (AI, developer tools, productivity) | Category submission form; copy: block A + B | Medium-high | ✅ fetched 2026-11-05 (categories listed on page) |
|
||||||
|
| W2-3 | uneed.best | Daily product launch site | Launch submission (account needed, held by a human) | Medium — launch-format, needs assets | ✅ fetched 2026-11-05 |
|
||||||
|
| W2-4 | devhunt.org | Daily dev-tool launches | Launch submission | Medium | ✅ fetched 2026-11-05 |
|
||||||
|
| W2-5 | Product Hunt | Launch site | Maker/hunter flow, on-screen (403 to plain fetch — read live before filing) | Low-medium this wave; big-audience launch usually wants FRE-203's launch-day coordination | ⚠️ bot-walled; fields read on screen |
|
||||||
|
|
||||||
|
Re-rank after the wave-1 readback: if directory listings (wave-1 §1/§3–5)
|
||||||
|
produced traffic or referrers, wave 2 leads with W2-1/W2-2; if nothing was
|
||||||
|
submittable, wave 2 waits until G3/G4 clear rather than submitting half-copy.
|
||||||
|
|
||||||
|
## Copy — paste-ready, inheriting the signed claims only
|
||||||
|
|
||||||
|
**One-liner (≤60):** `Your coding agents, at the edge of the screen` (43 chars — wave-1 block A).
|
||||||
|
|
||||||
|
**Short description (~250 chars):** wave-1 block B verbatim; if a field forces a
|
||||||
|
price, "Free 14-day trial" and no number (G2).
|
||||||
|
|
||||||
|
**awesome-macOS PR line (one row, section: menu-bar / status-bar apps or the
|
||||||
|
section the maintainers route notch apps to — read the README section headers
|
||||||
|
before filing):**
|
||||||
|
|
||||||
|
```
|
||||||
|
- [The Nook](https://nook.freno.me/) - Puts every coding-agent session at the
|
||||||
|
edge of your screen and lets you answer approvals from your Mac, iPhone or
|
||||||
|
Watch. Works with 18 coding agents. [![Open-Source Software][OSS Icon]] — not
|
||||||
|
open source; omit the badge row, keep the description.
|
||||||
|
```
|
||||||
|
|
||||||
|
(The badge syntax is copied from the list's own rows; The Nook is not open
|
||||||
|
source, so the OSS badge is **omitted** — the line above shows the shape only.
|
||||||
|
No TestFlight mention needed in a one-row listing; add "Companions for iPhone
|
||||||
|
and Apple Watch (TestFlight beta)" only if the PR reviewer asks for platform
|
||||||
|
detail — C5 wording.)
|
||||||
|
|
||||||
|
**Launch-site fields (W2-3/W2-4):** wave-1 blocks A, B, C (widgets — fleet
|
||||||
|
status only), D (relay sentence, not C7's refused wording). Gallery needs G4's
|
||||||
|
three real screenshots.
|
||||||
|
|
||||||
|
## Gates before anything here is submitted
|
||||||
|
|
||||||
|
| Gate | Owner | State |
|
||||||
|
|---|---|---|
|
||||||
|
| Wave-1 readback recorded (what submitted/listed/rejected) | sponsor → FRE-207 issue, carried to FRE-199 | owed before submission week |
|
||||||
|
| G2 price line for public listings | CoS + sponsor | pending (copy ships without a number either way) |
|
||||||
|
| G3 `hello@freno.me` mailbox | sponsor | pending |
|
||||||
|
| G4 three real screenshots + logo PNG | sponsor or CTO | pending — blocks every upload field |
|
||||||
|
| Claim sign-off | CTO | **cleared 2026-10-31** for C1–C5; C7 ships as the relay sentence |
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
- Targets fetched 2026-11-05: uneed.best, devhunt.org, 1000.tools (HTTP 200,
|
||||||
|
content read); `iCHAIT/awesome-macOS` via GitHub API (19,441 stars,
|
||||||
|
contributing.md linked from README); producthunt.com returns 403 to a plain
|
||||||
|
fetch — marked ⚠️, read on screen before filing.
|
||||||
|
- All claims inherited from FRE-207's CTO verdict of 2026-10-31 — no new
|
||||||
|
claims introduced by this brief.
|
||||||
4
marketing/calendar.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
|
||||||
|
"pushes": []
|
||||||
|
}
|
||||||
1
marketing/drafts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# drafts
|
||||||
96
marketing/drafts/agent-keeps-asking/copy.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Why does your coding agent keep stopping to ask you questions?
|
||||||
|
|
||||||
|
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
|
||||||
|
> Thu 2026-11-12). Not publishable from `drafts/`.
|
||||||
|
> This page never names a product of ours and never names a competitor.
|
||||||
|
|
||||||
|
Most of the stopping is not the model being chatty — it is the permission system
|
||||||
|
doing what it was told. In the default mode, the agent prompts before the first
|
||||||
|
use of each tool; separately, its turn ends every time it finishes responding,
|
||||||
|
and ending a turn with a question is finishing a turn. You cannot and should not
|
||||||
|
eliminate every ask — but you can decide, once, which asks deserve an
|
||||||
|
interruption and let the routine ones pass without you.
|
||||||
|
|
||||||
|
## Where the asks come from
|
||||||
|
|
||||||
|
**Permission gates.** In the default mode the agent "prompts for permission on
|
||||||
|
first use of each tool" — so every new tool, and often every new command shape,
|
||||||
|
costs one prompt. The configuration decides who answers:
|
||||||
|
|
||||||
|
- **`acceptEdits`** automatically accepts file edits and common filesystem
|
||||||
|
commands (like `mkdir`, `touch`, `mv`, `cp`) for paths in your working
|
||||||
|
directory — the single biggest ask category for most coding sessions, gone.
|
||||||
|
- **Permission rules** do the fine work: `deny` rules block a tool outright,
|
||||||
|
`allow` rules pass it silently, `ask` rules force the prompt. Order matters:
|
||||||
|
rules are evaluated deny, then ask, then allow — the first match wins, and an
|
||||||
|
allow rule cannot carve an exception out of a deny.
|
||||||
|
- **Modes set the floor.** `defaultMode` in your settings picks where every new
|
||||||
|
session starts. At the far end, `bypassPermissions` skips prompts entirely —
|
||||||
|
the docs' own warning is that it belongs only in isolated environments like
|
||||||
|
containers or VMs.
|
||||||
|
|
||||||
|
One fact worth internalising: permission rules are enforced by the coding
|
||||||
|
agent's harness, not by the model. A line in your prompt asking it to "not ask"
|
||||||
|
changes nothing about what it is allowed to do — the configuration is the only
|
||||||
|
lever.
|
||||||
|
|
||||||
|
**Turn ends.** A `Stop` hook fires when the agent finishes responding — every
|
||||||
|
turn, including the turns that end in a question. From the terminal's side of
|
||||||
|
the glass, "it finished", "it's asking me something" and "it's waiting on a
|
||||||
|
permission prompt" are the same silence. That is why the asking *feels*
|
||||||
|
endless: each ask costs a context switch, and nothing distinguishes the asks
|
||||||
|
from the completions.
|
||||||
|
|
||||||
|
Practitioners describe the pattern in their own words. A developer who measured
|
||||||
|
why their sessions kept stopping found that the agent "just kept handing
|
||||||
|
control back" even with a permissive mode already on — the model's own habit of
|
||||||
|
ending turns to stay in the loop, on top of the permission asks. An operator
|
||||||
|
running a team of agents with per-agent autonomy levels put the other side:
|
||||||
|
"if every decision routes through you, you're the bottleneck agents were meant
|
||||||
|
to eliminate."
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Sort the asks by kind before touching anything.** File edits in your
|
||||||
|
working directory: `acceptEdits` removes them wholesale. The same safe
|
||||||
|
command every session: an `allow` rule. The commands that must never run: a
|
||||||
|
`deny` rule. What is left is the set of questions only you can answer.
|
||||||
|
2. **Write the rules in the right order.** Deny, then ask, then allow; first
|
||||||
|
match wins. A broad deny stays broad — narrowing an allow elsewhere does not
|
||||||
|
soften it.
|
||||||
|
3. **Keep the genuinely ambiguous asks.** A mode that asks less is not a mode
|
||||||
|
that asks smarter. `bypassPermissions` in a throwaway container is a
|
||||||
|
documented pattern for unattended runs; using it on your daily machine is
|
||||||
|
the docs' own caution, not a recommendation.
|
||||||
|
4. **Make every stop visible.** Because every stop ends a turn, wire the
|
||||||
|
turn-end and notification moments (the `Stop` and `Notification` hooks) so
|
||||||
|
an ask reaches you where you actually are — and a permission wait is
|
||||||
|
distinguishable from a finished job.
|
||||||
|
|
||||||
|
One next step: run one session with the asks tallied by kind — edits, known
|
||||||
|
commands, everything else. The first two usually turn out to be rules you
|
||||||
|
hadn't written yet; the third is the work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about the
|
||||||
|
permission system and hooks were verified against the live docs on 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Default mode "prompts for permission on first use of each tool" (labeled Manual; `manual` alias) | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (permission modes table; fetched 2026-11-05) |
|
||||||
|
| 2 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, and cp for paths in the working directory or additionalDirectories" | same page (permission modes table) |
|
||||||
|
| 3 | Rules are evaluated in order deny, then ask, then allow; "the first match in that order determines the outcome"; "an allow rule can't carve an exception out of a deny rule" | same page (rule precedence section) |
|
||||||
|
| 4 | `bypassPermissions` "skips permission prompts"; docs' caution: "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same page (permission modes table) |
|
||||||
|
| 5 | `defaultMode` in settings files sets the default permission mode for new sessions | same page ("To change the default permission mode for new sessions, set defaultMode in your settings files") |
|
||||||
|
| 6 | "Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don't change what Claude Code allows." | same page (verbatim) |
|
||||||
|
| 7 | `Stop` fires when the agent finishes responding; hook cadence is per turn | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (verified 2026-11-05 in FRE-199 round 1; unchanged) |
|
||||||
|
| 8 | Anecdote: "it just kept handing control back"; sessions kept stopping with a permissive mode on | r/ClaudeCode thread 1xlfqmo ("I measured why my Claude Code sessions kept stopping…", 2026-11-01, u/belovoj), logged in `drafts/listening.md` Scan 5 #1. Quoted as one developer's described experience; the thread's own measurement figures are deliberately **not** cited. |
|
||||||
|
| 9 | Anecdote: "if every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 | r/ClaudeCode thread 1xk0ghj ("multiple agents working on multiple projects", 2026-10-31, quote by u/iceydee), logged in `drafts/listening.md` Scan 4 #2. One operator's described practice. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named · no
|
||||||
|
superlatives · no benchmark numbers (the 1xlfqmo poster's measurement figures
|
||||||
|
are deliberately not cited) · no invented testimonials (anecdotes are real
|
||||||
|
threads, described without handles) · no price · no roadmap dates.
|
||||||
52
marketing/drafts/agent-keeps-asking/rationale.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Rationale — "Why does your coding agent keep stopping to ask you questions?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 2 of the third pair. Drafted 2026-11-06 by CMO (internal
|
||||||
|
target Thu 2026-11-12 — six days early).
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"why does my coding agent keep stopping to ask me questions"**
|
||||||
|
(variants: "agent keeps handing control back", "claude code keeps asking for
|
||||||
|
permission"). The H1 carries the generic form; "handing control back" is the
|
||||||
|
backlog's own added phrase (Scan 5's phrase-list finding).
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **Backlog evidence.** r/ClaudeCode 1xlfqmo (2026-11-01, Scan 5 #1): "Auto mode
|
||||||
|
was already on, so permissions weren't the issue… it just kept handing
|
||||||
|
control back"; "the model was forcably trying to keep me in the loop." The
|
||||||
|
phrase-list finding from the same scan added "handing control back" as a
|
||||||
|
standing query. Corroborated by 1xlxw0q (turns ending with "nothing yet") and
|
||||||
|
the autonomy-ladder operator (Scan 4 #2, thread 1xk0ghj: "if every decision
|
||||||
|
routes through you, you're the bottleneck agents were meant to eliminate").
|
||||||
|
- **Shortlist position (c)** in the plan's pair-3 shortlist, recorded at
|
||||||
|
revision 5 — the page extends the done-signal cluster (pair 1) with the
|
||||||
|
handback half. The distinction is kept deliberately: *agent-done-signal*
|
||||||
|
owns "how do I know it's done"; this page owns "why does it keep stopping on
|
||||||
|
me". Different question, different searcher.
|
||||||
|
- **Answerable honestly from verified sources only.** Every mechanism claim
|
||||||
|
re-reads to the permissions doc and hooks reference — the same pages and the
|
||||||
|
same claims the CTO verified in FRE-199 rounds 1–2 (mode ladder, precedence,
|
||||||
|
acceptEdits scope, enforced-by-harness sentence, Stop cadence). Nothing new
|
||||||
|
is asserted about agent behaviour.
|
||||||
|
|
||||||
|
## What the page deliberately does not do
|
||||||
|
|
||||||
|
- **Does not cite the 1xlfqmo poster's measurement figures** (the 28-of-73
|
||||||
|
sample is the poster's own 18-day data, and the backlog row carries an
|
||||||
|
explicit do-not-cite flag). The anecdote is quoted as experience, not
|
||||||
|
measurement, and the fact-check table says so.
|
||||||
|
- Does not promise the asks can be eliminated. The honest answer is that the
|
||||||
|
asking is partly the permission system working as configured and partly the
|
||||||
|
agent ending turns to stay in the loop — the page sorts which is which.
|
||||||
|
- Does not touch environment contention (page 1 of this pair owns that half).
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeCode 1xlfqmo — "I measured why my Claude Code sessions kept stopping" | 2026-11-01 | "It just kept handing control back" with a permissive mode on; "forcibly trying to keep me in the loop" — the model-behaviour half |
|
||||||
|
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" (quote by u/iceydee) | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 — the configuration half |
|
||||||
|
| r/ClaudeAI 1xlxw0q — "2,300 hours waiting" | 2026-11-02 | Turns ending in a one-word answer reload context each time — the per-ask cost, corroborating |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` (The-Nook local master line).
|
||||||
72
marketing/drafts/agent-worktree-contention/copy.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# How do you manage worktrees when your agents end up waiting on each other?
|
||||||
|
|
||||||
|
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
|
||||||
|
> Thu 2026-11-12). Not publishable from `drafts/`.
|
||||||
|
> This page never names a product of ours and never names a competitor.
|
||||||
|
|
||||||
|
Usually, the worktrees are not what your agents are waiting on — the environment
|
||||||
|
they share is. Two agents can sit in two worktrees of the same repo and still
|
||||||
|
queue up behind one dev server, one test database, one `docker compose` stack.
|
||||||
|
The fix that scales is to multiply the environment per worktree, not to
|
||||||
|
orchestrate the waiting.
|
||||||
|
|
||||||
|
## Why agents queue when the code doesn't
|
||||||
|
|
||||||
|
Git supports parallel work out of the box: `git worktree add <path> <branch>`
|
||||||
|
creates a second working directory linked to the same repository — shared
|
||||||
|
history, but its own HEAD, its own index, its own checked-out branch. Git even
|
||||||
|
enforces the ownership half for you: it refuses to check out a branch into a
|
||||||
|
second worktree while the first one holds it.
|
||||||
|
|
||||||
|
What git does not duplicate is everything the repo only *points at*: the dev
|
||||||
|
server bound to port 3000, the database named after the project directory, the
|
||||||
|
compose stack whose project name defaults to the folder you launched it from.
|
||||||
|
Two agents in two worktrees that both bring up the same stack are two agents
|
||||||
|
contending for one environment — and the queue looks like a worktree problem
|
||||||
|
when it is an environment problem.
|
||||||
|
|
||||||
|
Practitioners who hit this converge on the same verdict. One, in a thread
|
||||||
|
asking this exact question: the queue "comes from having one test env, so I'd
|
||||||
|
multiply the env instead of orchestrating the waiting."
|
||||||
|
|
||||||
|
## What to check or do
|
||||||
|
|
||||||
|
1. **Give every worktree its own copy of the stack.** Docker Compose exists for
|
||||||
|
exactly this: it uses a *project name* to isolate environments from each
|
||||||
|
other, and names "multiple copies of a single environment, useful for
|
||||||
|
running stable copies for each feature branch" as a use case. Set a unique
|
||||||
|
one per worktree — the `-p` flag has top precedence, or use `name:` in the
|
||||||
|
compose file — and the port and container-name collisions disappear.
|
||||||
|
2. **Anything that cannot be multiplied, serialise on purpose.** One migration
|
||||||
|
runner, one deploy target, one flaky hardware-in-the-loop device: put it
|
||||||
|
behind a single serial step rather than letting two agents race it. A queue
|
||||||
|
you chose is a queue you can reason about.
|
||||||
|
3. **One branch, one worktree.** Git already refuses to check out the same
|
||||||
|
branch in two worktrees; keep it that way. Two agents on one branch is a
|
||||||
|
merge conflict waiting for both of them to finish.
|
||||||
|
4. **Remember a fresh worktree starts unconfigured.** Env files, generated
|
||||||
|
config and installed dependencies live in the worktree, not the repo — a new
|
||||||
|
worktree is a new environment to set up, which is exactly why the per-worktree
|
||||||
|
copy should be scripted, not hand-arranged.
|
||||||
|
|
||||||
|
One next step: find the resource your agents contend for most often — usually
|
||||||
|
the test stack — and give it a per-worktree project name. The waiting usually
|
||||||
|
resolves before any queueing tool is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact-check table (not part of the page)
|
||||||
|
|
||||||
|
Every technical claim above, with its primary source. Claims about git and
|
||||||
|
Docker Compose were verified against the live docs on 2026-11-05.
|
||||||
|
|
||||||
|
| # | Claim in the page | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `git worktree add <path> [<commit-ish>]` creates a worktree "linked to the current repository, sharing everything except per-worktree files such as HEAD, index, etc." | git worktree docs — https://git-scm.com/docs/git-worktree (COMMANDS › add; fetched 2026-11-05) |
|
||||||
|
| 2 | `add` "refuses to create a new worktree when <commit-ish> is a branch name and is already checked out by another worktree" | same page (OPTIONS › `--force`) |
|
||||||
|
| 3 | Compose "uses a project name to isolate environments from each other"; "multiple copies of a single environment, useful for running stable copies for each feature branch" is a named use case; precedence: `-p` flag > `COMPOSE_PROJECT_NAME` > `name:` attribute > directory basename | Docker Compose docs, "Specify a project name" — https://docs.docker.com/compose/how-tos/project-name/ (fetched 2026-11-05) |
|
||||||
|
| 4 | Anecdote: "The queue comes from having one test env, so I'd multiply the env instead of orchestrating the waiting" | r/ClaudeCode thread 1xlz2bs ("How do you manage worktrees when your agents end up waiting on each other?", 2026-11-02, u/Roland_culait / u/First-Painter7431), logged in `drafts/listening.md` Scan 6 #4. Quoted as one commenter's described practice, not a measurement. |
|
||||||
|
|
||||||
|
**Ban compliance:** no product of ours named · no competitor named · no
|
||||||
|
superlatives · no benchmark numbers · no invented testimonials (the anecdote is
|
||||||
|
a real thread, described without handles) · no price · no roadmap dates.
|
||||||
46
marketing/drafts/agent-worktree-contention/rationale.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Rationale — "How do you manage worktrees when your agents end up waiting on each other?"
|
||||||
|
|
||||||
|
FRE-199 item 1, page 1 of the third pair. Drafted 2026-11-06 by CMO (internal
|
||||||
|
target Thu 2026-11-12 — six days early).
|
||||||
|
|
||||||
|
## The phrase
|
||||||
|
|
||||||
|
Primary: **"how do you manage worktrees when your agents end up waiting on each
|
||||||
|
other"** — carried verbatim as the H1, because it is already a complete search
|
||||||
|
phrase with the exact buying intent.
|
||||||
|
|
||||||
|
## Why this phrase won the slot
|
||||||
|
|
||||||
|
- **Backlog evidence.** r/ClaudeCode 1xlz2bs (2026-11-02, ~15 h old at scan time,
|
||||||
|
Scan 6 #4). The OP asked the question in its final words; the thread answered
|
||||||
|
itself (env per worktree, `docker compose -p`, preview deploys, one serial
|
||||||
|
step on main) — which is exactly what makes it page material: the demand is
|
||||||
|
proven, the answer is proven, and the answer is generic.
|
||||||
|
- **Shortlist position (a)** in the plan's pair-3 shortlist, recorded at
|
||||||
|
revision 5. Scan 9 (2026-11-05) produced no new queue item, so the
|
||||||
|
shortlist stands; selection finalised against that fresh scan as planned.
|
||||||
|
- **Answerable honestly and generically.** Both mechanisms are documented
|
||||||
|
primary sources (git worktree docs; Docker Compose project-name docs), both
|
||||||
|
fetched and read 2026-11-05. No agent-behaviour claim is needed — this page
|
||||||
|
is the lightest CTO-review lift of the shortlist.
|
||||||
|
- **Wedge-adjacent without a mention.** "Waiting" is the cost our wedge names
|
||||||
|
("an agent blocked is the most expensive kind of idle"); this page owns the
|
||||||
|
agent-on-agent half of waiting, which no existing page covers.
|
||||||
|
|
||||||
|
## What the page deliberately does not do
|
||||||
|
|
||||||
|
- Does not cite the thread's own tooling (Orca, oyren.ai) or any workflow
|
||||||
|
product — category nouns only.
|
||||||
|
- Does not invent a measurement. The one quoted line is a commenter's described
|
||||||
|
practice, logged in the backlog with a do-not-interpret flag.
|
||||||
|
- Does not touch the approval surface (page 2 of this pair owns the
|
||||||
|
attention/handback half; this page is environment contention only).
|
||||||
|
|
||||||
|
## Evidence trail
|
||||||
|
|
||||||
|
| Thread | Date | What it showed |
|
||||||
|
|---|---|---|
|
||||||
|
| r/ClaudeCode 1xlz2bs — "How do you manage worktrees when your agents end up waiting on each other?" | 2026-11-02 | Exact phrase in the wild; self-answered with env-multiplication advice; "multiply the env instead of orchestrating the waiting" |
|
||||||
|
| Watched-table corroboration (Scans 5–9) | 2026-11-02 → 11-05 | Repobs ("which sessions were waiting for me"), Orca (worktree orchestration daily driver), Worktrunk, Conductor.build, Parallel Code — a shelf of builders around the same phrase; the demand is broad |
|
||||||
|
|
||||||
|
All rows live in `drafts/listening.md` (The-Nook local master line).
|
||||||
96
marketing/drafts/newsletter-postmortem-pitch/copy.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Newsletter pitch — post-mortem shape (narrowed; no conversion figure)
|
||||||
|
|
||||||
|
> **Status: draft — narrowed 2026-11-06 per the CTO's reopening verdict on
|
||||||
|
> FRE-199.** The "X% trial→paid" half of the shape **cannot ship**: the
|
||||||
|
> FRE-202 snapshot (window 2026-10-22 → 10-29) reads trial started 2, purchase
|
||||||
|
> 0, activation 0, with 291 of 291 analytics rows predating the FRE-366
|
||||||
|
> instrumentation — a trial→paid ratio from that cohort is not a metric. The
|
||||||
|
> ratio comes from FRE-197's mature Day 28–35 cohort read when it lands.
|
||||||
|
> **No figure in this file is a measurement.** Send gates at the bottom.
|
||||||
|
|
||||||
|
## Audience & channel
|
||||||
|
|
||||||
|
Editors of free dev-tool newsletters (the ones that run post-mortems and
|
||||||
|
"what we learned" pieces). Direct email, one editor at a time, personalised.
|
||||||
|
No paid placements at any budget without a CoS + sponsor decision.
|
||||||
|
|
||||||
|
## The claim the pitch goes out with
|
||||||
|
|
||||||
|
A post-mortem pitch, not a product pitch: *here is what shipping a Mac utility
|
||||||
|
alone actually broke, and what we changed because of it.* The product is the
|
||||||
|
setting; the breakage and the fix are the story. The story stands on its own
|
||||||
|
with zero funnel numbers — if no verified number exists on send day, the pitch
|
||||||
|
ships without one rather than with a placeholder.
|
||||||
|
|
||||||
|
## The pitch (shape — names and dates personalised; nothing else is a placeholder)
|
||||||
|
|
||||||
|
**Subject:** What shipping a Mac utility alone broke (post-mortem)
|
||||||
|
|
||||||
|
Hi <editor>,
|
||||||
|
|
||||||
|
We build The Nook, a $10 Mac utility that surfaces coding-agent approvals at
|
||||||
|
the notch. I'm not pitching the app — I'm offering a post-mortem your readers
|
||||||
|
usually only get from funded startups, because shipping alone broke things in
|
||||||
|
ways bigger teams don't hit:
|
||||||
|
|
||||||
|
- **The silent update.** In October we shipped a release whose appcast published
|
||||||
|
cleanly — while every delta it named 404'd, so users on older builds were
|
||||||
|
being offered updates that didn't exist. The publish path deleted files
|
||||||
|
before the feed that named them was live. Nothing alerted; the feed just
|
||||||
|
lied quietly. The fix — upload the feed's enclosures, verify every enclosure
|
||||||
|
URL, copy the appcast up, and only *then* prune — is now ordered so the
|
||||||
|
deletion can't run first.
|
||||||
|
- **The green suite.** Weeks earlier, a regression in our public relay shipped
|
||||||
|
behind a passing test run — the suite that would have caught it existed but
|
||||||
|
nothing invoked it. A green checkmark that covers nothing looks identical to
|
||||||
|
one that covers everything. The suite is now wired into the standard test
|
||||||
|
entry point, and the relay's deploy patcher is tested alongside it.
|
||||||
|
- **What we're not going to quote you.** A growth number, right now, would be
|
||||||
|
theatre: our funnel is young and a conversion ratio from an immature cohort
|
||||||
|
is not a measurement. If we attach a count, it will be activations — one per
|
||||||
|
activated install, from our own relay store — and we will say exactly that,
|
||||||
|
because download counts (a different number, from a different store) flatter
|
||||||
|
nothing and confuse everyone. The receipts that matter here are the failure
|
||||||
|
modes above, not a traction line.
|
||||||
|
|
||||||
|
The angle for your readers: solo distribution is a chain of quiet failure
|
||||||
|
modes, each one invisible until it costs a user. We kept the receipts.
|
||||||
|
|
||||||
|
Happy to send the full write-up, screenshots of the broken feed, and the exact
|
||||||
|
publish-order and test-wiring changes.
|
||||||
|
|
||||||
|
— <name>, FrenoCorp
|
||||||
|
|
||||||
|
## Where a number could come from, and what disqualifies it
|
||||||
|
|
||||||
|
- **Trial→paid ratio: cannot ship today.** FRE-202's snapshot shows a cohort of
|
||||||
|
2 trials, 0 purchases, pre-instrumentation rows. The ratio waits for
|
||||||
|
FRE-197's mature Day 28–35 read. If it lands before send day and the CTO
|
||||||
|
confirms the query, the ratio may be added — and it must say it is per
|
||||||
|
*install* (install cap 3), not per person.
|
||||||
|
- **Activations: the only count eligible to ship.** One row per activated
|
||||||
|
install (`installs`, `created_at`) in the relay store; CTO confirms the query
|
||||||
|
before any send. The pitch names it as activations, never as downloads.
|
||||||
|
- **Downloads (S3 side) are never quoted.** Different store, automation-heavy,
|
||||||
|
and the pitch that says "downloads" when it means activations burns the
|
||||||
|
credibility the post-mortem is built on.
|
||||||
|
- No number is estimated to fill any gap, ever.
|
||||||
|
|
||||||
|
## Gates before anything is sent
|
||||||
|
|
||||||
|
1. Any count quoted is activations, from a CTO-confirmed query — owner: CTO.
|
||||||
|
2. The trial→paid ratio ships only from FRE-197's mature cohort read with CTO
|
||||||
|
confirmation; otherwise the "what we're not going to quote you" paragraph
|
||||||
|
stands as written — owner: CTO.
|
||||||
|
3. Both breakage paragraphs are fact-checked against the engineering record
|
||||||
|
(FRE-542 publish-order incident; the NOOK-815 uninvited-suite relay
|
||||||
|
regression) before send — owner: CMO, evidence to CTO for verdict.
|
||||||
|
4. Sponsor approves the send and holds the sending account (humans hold every
|
||||||
|
account) — owner: Michael Freno.
|
||||||
|
|
||||||
|
## Target dates (internal)
|
||||||
|
|
||||||
|
- Draft with placeholders: **done 2026-11-05**; narrowed to the no-figure shape
|
||||||
|
**2026-11-06** (CTO direction).
|
||||||
|
- Send: after gates 1–4 — with the no-figure shape, the only hard input left is
|
||||||
|
the CTO's query confirmation and the sponsor's send.
|
||||||
73
marketing/drafts/newsletter-postmortem-pitch/rationale.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Rationale — newsletter post-mortem pitch (shape)
|
||||||
|
|
||||||
|
FRE-199 item 2. Drafted 2026-11-05 by CMO (plan date was 2026-11-06; FRE-201
|
||||||
|
landed today, which was the only gate on drafting the shape).
|
||||||
|
|
||||||
|
## Why draft now
|
||||||
|
|
||||||
|
The Chief of Staff's sequencing had the pitch drafted when FRE-201 landed. It
|
||||||
|
landed 2026-11-05 (row closed `done` 18:45Z; the daily scan routine
|
||||||
|
`2553b201` has been producing the backlog since 2026-10-30). The figures were
|
||||||
|
never gated on FRE-201 — only the shape was — so the shape ships now and the
|
||||||
|
figures stay placeholders behind their own gate.
|
||||||
|
|
||||||
|
## Why a post-mortem and not a product pitch
|
||||||
|
|
||||||
|
The issue text's own thesis: "1,900 downloads, 6% trial→paid, here's what
|
||||||
|
broke" outperforms a product pitch. Editors of free dev-tool newsletters run
|
||||||
|
post-mortems; a solo shop shipping a Mac utility and keeping receipts is a
|
||||||
|
story they can run without us asking for a review.
|
||||||
|
|
||||||
|
## The breakage is real, not invented
|
||||||
|
|
||||||
|
The lead paragraph is the 0.13.4 delta incident (FRE-542): the appcast stayed
|
||||||
|
at 0.13.4 while its five delta files 404'd — the publish path pruned before
|
||||||
|
the feed was up. The fix (upload enclosures, verify URLs, copy the appcast,
|
||||||
|
then prune) is in `scripts/upload-release.sh` and is engineering-verified by
|
||||||
|
the FRE-542 record.
|
||||||
|
|
||||||
|
**Second breakage, chosen 2026-11-06 (CTO direction):** the relay regression
|
||||||
|
that shipped behind a passing suite (NOOK-815) — the suite that would have
|
||||||
|
cought it existed but nothing invoked it; the fix wires it into the standard
|
||||||
|
test entry point. It was chosen over the changelog-blanking incident because
|
||||||
|
it is a complete story with no number needed and no overlap with the lead
|
||||||
|
(both remaining candidates were publish-path incidents; one story about the
|
||||||
|
publish path is enough). The placeholder is gone: no paragraph in the pitch
|
||||||
|
waits on a figure any more.
|
||||||
|
|
||||||
|
## Narrowed 2026-11-06 — what the numbers can and cannot support
|
||||||
|
|
||||||
|
The CTO's reopening verdict on FRE-199 confirmed **no figure for the pitch
|
||||||
|
today**: FRE-202's snapshot (trial started 2, purchase 0, activation 0; 291 of
|
||||||
|
291 rows predate the FRE-366 instrumentation) cannot yield a trial→paid ratio —
|
||||||
|
that waits for FRE-197's mature Day 28–35 cohort read. So the pitch narrowed:
|
||||||
|
|
||||||
|
- the "X% trial→paid" placeholder is deleted, not held;
|
||||||
|
- if any count ships, it is **activations** (relay store, one row per activated
|
||||||
|
install), named as activations — downloads (S3 side) are never quoted;
|
||||||
|
- a new paragraph — "what we're not going to quote you" — turns the missing
|
||||||
|
number into part of the post-mortem's credibility instead of a gap;
|
||||||
|
- with the no-figure shape, the only inputs left before send are the CTO's
|
||||||
|
query confirmation (if a count is attached) and the sponsor's send.
|
||||||
|
|
||||||
|
## Hard rules honoured
|
||||||
|
|
||||||
|
- No figure in the file is a measurement; nothing is estimated to fill a gap.
|
||||||
|
- No conversion figure ships — the ratio is FRE-197's, gated and CTO-confirmed.
|
||||||
|
- Any count that ships is activations, named as activations; downloads are
|
||||||
|
never quoted.
|
||||||
|
- The two caveats (per-install conversion with install cap 3; downloads ≠
|
||||||
|
activations) are written to travel inside the pitch, not a footnote.
|
||||||
|
- Paid placements: out, per the issue text and the plan (TLDR ≈ $3k, no
|
||||||
|
verifiable CPC). No placement is bought without CoS + sponsor.
|
||||||
|
- Price appears once ($10, what the app actually charges) to identify the
|
||||||
|
product — no discount, no countdown, no price-change copy (FRE-204 holds).
|
||||||
|
|
||||||
|
## Gates before send
|
||||||
|
|
||||||
|
1. Any count quoted is activations, from a CTO-confirmed query — CTO.
|
||||||
|
2. Trial→paid ratio ships only from FRE-197's mature read, CTO-confirmed;
|
||||||
|
otherwise the no-figure paragraph stands — CTO.
|
||||||
|
3. Both breakage paragraphs fact-checked against the engineering record
|
||||||
|
(FRE-542; NOOK-815) — CMO drafts, CTO verifies.
|
||||||
|
4. Sponsor approves the send; a human holds the account and sends — sponsor.
|
||||||
79
marketing/drafts/remote-ssh/copy.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Remote SSH copy — HELD, do not publish
|
||||||
|
|
||||||
|
**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.
|
||||||
|
Until then this file is the only home for these sentences.
|
||||||
|
|
||||||
|
## What was removed
|
||||||
|
|
||||||
|
### 1. Landing collage tile
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
kicker: Remote
|
||||||
|
title: Agents on other machines
|
||||||
|
body: Run agents on Linux boxes and SSH servers — one click installs the
|
||||||
|
nook-hook, and their sessions join the same island.
|
||||||
|
```
|
||||||
|
|
||||||
|
The mock attached to it showed two session rows whose agent chips named
|
||||||
|
`build-box` and `gpu-rig`.
|
||||||
|
|
||||||
|
### 2. Landing feature breakdown section
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
kicker: Remote
|
||||||
|
title: Any SSH box, one click away
|
||||||
|
body: Pick a host from your ssh config or your tailnet and click once — the
|
||||||
|
nook-hook installs itself and its agents appear beside your local ones,
|
||||||
|
indistinguishable.
|
||||||
|
|
||||||
|
bullets:
|
||||||
|
- One click: connect, push the hook, configure agents, check it can reach this Mac
|
||||||
|
- Hosts mirror this Mac's installed integrations automatically
|
||||||
|
- Remote sessions are ordinary rows; the agent chip names the machine
|
||||||
|
```
|
||||||
|
|
||||||
|
The section's demo animated a `gpu-rig` host moving from "Not set up" through
|
||||||
|
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
|
||||||
|
host can reach this Mac` → `Live`, then showed a remote session row.
|
||||||
|
|
||||||
|
### 3. Approvals bullet
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
- Works across every hooked agent, local or remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Feature chip
|
||||||
|
|
||||||
|
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
One-click SSH setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## What replaced it
|
||||||
|
|
||||||
|
Nothing. The Remote tile and section were removed rather than replaced; the
|
||||||
|
existing Approvals cell already carries the verified local claim:
|
||||||
|
|
||||||
|
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
|
||||||
|
|
||||||
|
## Restore checklist
|
||||||
|
|
||||||
|
1. FRE-320/FRE-205 have shipped the one-click install and removed the
|
||||||
|
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
|
||||||
|
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
|
||||||
|
3. A recording exists showing a remote agent joining the island.
|
||||||
|
4. CMO restores the copy above, then owns the launch plan for it — with an
|
||||||
|
owner and date per item — before anything goes back on the site.
|
||||||
30
marketing/drafts/remote-ssh/rationale.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Why the remote claim is parked
|
||||||
|
|
||||||
|
The site was selling a setup path a buyer does not get.
|
||||||
|
|
||||||
|
FRE-320 measured today's actual path (2026-10-29): the one-click 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-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 `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
|
||||||
|
|
||||||
|
| Requirement | Owner | Date |
|
||||||
|
|---|---|---|
|
||||||
|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
|
||||||
|
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
|
||||||
|
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
|
||||||
|
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
|
||||||
|
|
||||||
|
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||||
|
place a human publishes from.
|
||||||
15
marketing/listening.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Listening — buying-intent threads
|
||||||
|
|
||||||
|
Agent-found threads where someone is describing a problem The Nook solves, with a
|
||||||
|
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
|
||||||
|
human** with a single disclosed mention — helpful first, product second.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Real threads only — no invented scenarios.
|
||||||
|
- The drafted reply must stand alone as a helpful answer if the mention is removed.
|
||||||
|
- One disclosed mention per reply, maximum.
|
||||||
|
- Never post from this file; replies move through `approved/`.
|
||||||
|
|
||||||
|
| Date | Source | Thread | Problem signal | Drafted reply |
|
||||||
|
|---|---|---|---|---|
|
||||||
43
marketing/metrics/weekly.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Weekly funnel snapshot
|
||||||
|
|
||||||
|
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05: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 (nook.freno.me page views) | 0 | 0 | 0 |
|
||||||
|
| download click | 0 | 0 | 0 |
|
||||||
|
| DMG served | 0 | 0 | 0 |
|
||||||
|
| trial started | 2 | 0 | +2 |
|
||||||
|
| checkout started (Stripe session) | 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 |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| x | — | — | 1 |
|
||||||
|
|
||||||
|
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||||
|
|
||||||
|
## The one thing that moved
|
||||||
|
|
||||||
|
**trial started: 0 → 2 (+2).**
|
||||||
|
|
||||||
|
## Before acting on these numbers
|
||||||
|
|
||||||
|
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||||
|
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||||
|
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||||
|
- Generated 2026-10-29T18:05:00Z.
|
||||||
1
marketing/pending/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# pending
|
||||||
156
marketing/positioning.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# The Nook — Positioning (source of truth)
|
||||||
|
|
||||||
|
Every drafted asset draws its voice, claims, and constraints from this file. If an
|
||||||
|
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||||
|
silently diverging.
|
||||||
|
|
||||||
|
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||||
|
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||||
|
[FRE-997]), 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
|
||||||
|
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||||
|
|
||||||
|
## The sentence
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The thing to say over and over, in every channel, in slightly different words:
|
||||||
|
|
||||||
|
> **An agent blocked on a permission prompt is the most expensive kind of idle.**
|
||||||
|
> You pay for the model, you pay for the wait, and you get neither.
|
||||||
|
|
||||||
|
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 consolidation reframe
|
||||||
|
|
||||||
|
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||||
|
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
### 1. Category nouns, never product nouns
|
||||||
|
|
||||||
|
`freno-dev/AGENTS.md` forbids naming any competitor in user-facing copy — landing
|
||||||
|
pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all of it.
|
||||||
|
|
||||||
|
- Use **category nouns**: "notch app", "menu-bar monitor", "fan control utility",
|
||||||
|
"battery charge limiter", "agent notification hack". These carry the same search
|
||||||
|
intent and are fully compliant.
|
||||||
|
- Compare against **the status quo, not a competitor**: "vs. a terminal bell you
|
||||||
|
don't hear", "vs. four utilities in your menu bar", "vs. finding out an hour later."
|
||||||
|
- The rule does not reach App Store–external review replies, direct email, and
|
||||||
|
community replies written by a human, where quoting what a user said about another
|
||||||
|
tool is normal conversation.
|
||||||
|
|
||||||
|
### 2. A claim needs a verdict, not just a test
|
||||||
|
|
||||||
|
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 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 data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||||
|
- "nothing leaves your Mac"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Claims: status at 2026-11-03
|
||||||
|
|
||||||
|
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||||
|
|
||||||
|
| Claim | Status | Evidence / next step |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||||
|
| 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 — the only count
|
||||||
|
that ships is eighteen, and it is the registry's own size (see the coverage 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", any unverifiable superlative — and the specific
|
||||||
|
banned sentences in rule 3 and the table above.
|
||||||
1
marketing/posted/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# posted
|
||||||
25
package.json
@@ -1,36 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "example-with-trpc",
|
"name": "freno.(dev/me)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
"dev-flush": "vinxi dev --env-file=.env",
|
"dev-flush": "vinxi dev --env-file=.env",
|
||||||
"build": "vinxi build",
|
"build": "vinxi build",
|
||||||
"start": "vinxi start",
|
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:security": "bun test src/server/security/",
|
"test:security": "bun test src/server/security/",
|
||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"perf": "bun run scripts/perf-test.ts",
|
||||||
"perf:compare": "bun run scripts/perf-compare.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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@motionone/solid": "^10.16.4",
|
"@sentry/solidstart": "^10.67.0",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@solidjs/start": "^1.1.0",
|
"@solidjs/start": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
"@tiptap/core": "^3.14.0",
|
"@tiptap/core": "^3.14.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||||
"@tiptap/extension-color": "^3.14.0",
|
|
||||||
"@tiptap/extension-details": "^3.14.0",
|
"@tiptap/extension-details": "^3.14.0",
|
||||||
"@tiptap/extension-details-content": "^2.26.2",
|
"@tiptap/extension-details-content": "^2.26.2",
|
||||||
"@tiptap/extension-details-summary": "^2.26.2",
|
"@tiptap/extension-details-summary": "^2.26.2",
|
||||||
"@tiptap/extension-image": "^3.14.0",
|
"@tiptap/extension-image": "^3.14.0",
|
||||||
"@tiptap/extension-link": "^3.14.0",
|
"@tiptap/extension-link": "^3.14.0",
|
||||||
"@tiptap/extension-list-item": "^3.14.0",
|
|
||||||
"@tiptap/extension-subscript": "^3.14.0",
|
"@tiptap/extension-subscript": "^3.14.0",
|
||||||
"@tiptap/extension-superscript": "^3.14.0",
|
"@tiptap/extension-superscript": "^3.14.0",
|
||||||
"@tiptap/extension-table": "^3.14.0",
|
"@tiptap/extension-table": "^3.14.0",
|
||||||
@@ -40,13 +40,11 @@
|
|||||||
"@tiptap/extension-task-item": "^3.14.0",
|
"@tiptap/extension-task-item": "^3.14.0",
|
||||||
"@tiptap/extension-task-list": "^3.14.0",
|
"@tiptap/extension-task-list": "^3.14.0",
|
||||||
"@tiptap/extension-text-align": "^3.14.0",
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/extension-text-style": "^3.14.0",
|
|
||||||
"@tiptap/pm": "^3.14.0",
|
"@tiptap/pm": "^3.14.0",
|
||||||
"@tiptap/starter-kit": "^3.14.0",
|
"@tiptap/starter-kit": "^3.14.0",
|
||||||
"@trpc/client": "^10.45.2",
|
"@trpc/client": "^10.45.2",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@tursodatabase/api": "^1.9.2",
|
"@tursodatabase/api": "^1.9.2",
|
||||||
"@typeschema/valibot": "^0.13.4",
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
@@ -55,7 +53,6 @@
|
|||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"redis": "^5.10.0",
|
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
"ua-parser-js": "^2.0.7",
|
"ua-parser-js": "^2.0.7",
|
||||||
@@ -67,17 +64,11 @@
|
|||||||
"node": "24.x"
|
"node": "24.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@sentry/vite-plugin": "^5.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/fast-diff": "^1.2.2",
|
|
||||||
"chrome-launcher": "^1.2.1",
|
|
||||||
"lighthouse": "^13.0.1",
|
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
|
||||||
"trpc-panel": "^1.3.4",
|
|
||||||
"vite-bundle-visualizer": "^1.2.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/Nessa Exports/01-home-tab.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/Nessa Exports/04-plans-tab-strength.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/Nessa Exports/09-clubs-list.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/Nessa Exports/29-apple-health.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/Nessa Exports/38-workout-summary.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/Nessa Exports/40-segment-list.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Default-1024x1024.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/gaze/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/gaze/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/gaze/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 8.7 KiB |
17
public/gaze/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.9 MiB |
21
public/gaze/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Gaze",
|
||||||
|
"short_name": "Gaze",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/gaze/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/gaze/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#002cff",
|
||||||
|
"background_color": "#002cff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/gaze/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/gaze/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/inputhalo/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/inputhalo/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/inputhalo/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
17
public/inputhalo/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.0 MiB |
21
public/inputhalo/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "InputHalo",
|
||||||
|
"short_name": "InputHalo",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/inputhalo/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/inputhalo/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#41a5ff",
|
||||||
|
"background_color": "#41a5ff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/inputhalo/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/inputhalo/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 109 KiB |
12
public/instrument.server.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as Sentry from "@sentry/solidstart";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://a7c36d42c2a023ed29dd5db76c079566@o4506630160187392.ingest.us.sentry.io/4511784457666560",
|
||||||
|
|
||||||
|
dataCollection: {
|
||||||
|
// To disable sending user data and HTTP bodies, uncomment the lines below. For more info visit:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/solidstart/configuration/options/#dataCollection
|
||||||
|
// userInfo: false,
|
||||||
|
// httpBodies: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
public/lineage/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/lineage/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/lineage/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
3
public/lineage/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 860 KiB |
21
public/lineage/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Life and Lineage",
|
||||||
|
"short_name": "Lineage",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/lineage/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/lineage/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#a13536",
|
||||||
|
"background_color": "#a13536",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/lineage/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/lineage/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/look-away.png
Normal file
|
After Width: | Height: | Size: 585 KiB |
BIN
public/nessa/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/nessa/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/nessa/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
17
public/nessa/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 64 KiB |
21
public/nessa/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Nessa",
|
||||||
|
"short_name": "Nessa",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/nessa/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/nessa/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#527640",
|
||||||
|
"background_color": "#527640",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/nessa/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/nessa/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 8.3 KiB |
4
public/nook/brands/claude.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
|
||||||
|
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
4
public/nook/brands/codex.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
|
||||||
|
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/nook/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
21
public/nook/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "The Nook",
|
||||||
|
"short_name": "The Nook",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/nook/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/nook/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#4C9FBC",
|
||||||
|
"background_color": "#4C9FBC",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/nook/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/nook/icon.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/nook/og.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -1,9 +1,21 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /blog
|
Allow: /blog
|
||||||
Allow: /projects
|
|
||||||
Disallow: /login
|
# Private / dynamic paths — not for indexing.
|
||||||
Disallow: /debug/
|
# Keeps crawlers off auth, admin, and API pages so they don't burn
|
||||||
|
# uncached SSR function invocations.
|
||||||
|
Disallow: /account
|
||||||
|
Disallow: /analytics
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /blog/create
|
||||||
|
Disallow: /blog/edit
|
||||||
|
Disallow: /checkout
|
||||||
Disallow: /databaseMGMT
|
Disallow: /databaseMGMT
|
||||||
|
Disallow: /debug/
|
||||||
|
Disallow: /error-test
|
||||||
|
Disallow: /login
|
||||||
|
Disallow: /success
|
||||||
|
Disallow: /test
|
||||||
|
|
||||||
Sitemap: https://www.freno.me/sitemap.xml
|
Sitemap: https://www.freno.me/sitemap.xml
|
||||||
|
|||||||
21
scripts/backfill-downloads.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||||
|
|
||||||
|
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||||
|
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||||
|
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||||
|
//
|
||||||
|
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||||
|
|
||||||
|
const result = await backfillDownloads(ConnectionFactory());
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||||
|
} else {
|
||||||
|
for (const { asset, added } of result) {
|
||||||
|
console.log(`${asset}: +${added}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||||
|
);
|
||||||
|
}
|
||||||
45
scripts/generate-license-keys.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* One-shot The Nook license Ed25519 keypair generator.
|
||||||
|
*
|
||||||
|
* Run with: bun scripts/generate-license-keys.ts
|
||||||
|
*
|
||||||
|
* Prints:
|
||||||
|
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
|
||||||
|
* (freno-dev env).
|
||||||
|
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
|
||||||
|
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
|
||||||
|
*
|
||||||
|
* The private key lives ONLY in env — never in git.
|
||||||
|
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
|
||||||
|
* (step 4 of the distribution plan) after running this once.
|
||||||
|
*/
|
||||||
|
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||||
|
|
||||||
|
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
|
||||||
|
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
|
||||||
|
|
||||||
|
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
|
||||||
|
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
|
||||||
|
const rawPublic = publicKeySpki.subarray(-32);
|
||||||
|
|
||||||
|
console.log("── The Nook license keypair ──────────────────────────────");
|
||||||
|
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
|
||||||
|
console.log(privateKeyDer.toString("base64"));
|
||||||
|
console.log("");
|
||||||
|
console.log("Private key PEM (reference, for license signing only):");
|
||||||
|
console.log(privateKeyPem);
|
||||||
|
console.log("");
|
||||||
|
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
|
||||||
|
console.log(rawPublic.toString("base64"));
|
||||||
|
console.log("──────────────────────────────────────────────────────────");
|
||||||
|
|
||||||
|
// Sanity check: sign + verify round trip with the exported artifacts.
|
||||||
|
const publicKeyFromPem = createPrivateKey(privateKeyPem)
|
||||||
|
.export({ format: "der", type: "pkcs8" });
|
||||||
|
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
|
||||||
|
console.error("Keypair export sanity check failed.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("Sanity check passed.");
|
||||||
28
scripts/grant-nook-license.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
|
||||||
|
|
||||||
|
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
|
||||||
|
//
|
||||||
|
// bun --env-file=.env scripts/grant-nook-license.ts \
|
||||||
|
// --email friend@example.com [--devices 1]
|
||||||
|
|
||||||
|
const arg = (name: string) => {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i === -1 ? undefined : process.argv[i + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = arg("email");
|
||||||
|
const devices = Number(arg("devices") ?? "1");
|
||||||
|
|
||||||
|
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||||
|
console.error(
|
||||||
|
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const { key } = await grantLicense(email, devices);
|
||||||
|
|
||||||
|
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
|
||||||
|
console.log(key);
|
||||||
|
console.log("Send it to them; they enter it in Settings > License.");
|
||||||
@@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
"───────────────────────────────────────────────────────────────────\n"
|
"───────────────────────────────────────────────────────────────────\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compare each page
|
|
||||||
for (const baseResult of baseline.results) {
|
for (const baseResult of baseline.results) {
|
||||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||||
if (!optResult) continue;
|
if (!optResult) continue;
|
||||||
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
console.log(`\n📄 ${baseResult.page}`);
|
console.log(`\n📄 ${baseResult.page}`);
|
||||||
console.log("─".repeat(70));
|
console.log("─".repeat(70));
|
||||||
|
|
||||||
// Core Web Vitals
|
|
||||||
console.log("\n Core Web Vitals:");
|
console.log("\n Core Web Vitals:");
|
||||||
|
|
||||||
const fcpDiff = opt.fcp - base.fcp;
|
const fcpDiff = opt.fcp - base.fcp;
|
||||||
@@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading Metrics
|
|
||||||
console.log("\n Loading Metrics:");
|
console.log("\n Loading Metrics:");
|
||||||
|
|
||||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||||
@@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resource Loading
|
|
||||||
console.log("\n Resources:");
|
console.log("\n Resources:");
|
||||||
|
|
||||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||||
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall Summary
|
|
||||||
console.log(
|
console.log(
|
||||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||||
);
|
);
|
||||||
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific findings
|
|
||||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||||
if (reqPercent < -5) {
|
if (reqPercent < -5) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
|||||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||||
const WARMUP_RUNS = 1;
|
const WARMUP_RUNS = 1;
|
||||||
|
|
||||||
// Pages to test
|
|
||||||
const TEST_PAGES: PageTestConfig[] = [
|
const TEST_PAGES: PageTestConfig[] = [
|
||||||
{ name: "Home", path: "/" },
|
{ name: "Home", path: "/" },
|
||||||
{ name: "Blog Index", path: "/blog" },
|
{ name: "Blog Index", path: "/blog" },
|
||||||
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
|
|||||||
{ name: "404", path: "/404" }
|
{ name: "404", path: "/404" }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add additional blog post path if provided
|
|
||||||
if (process.env.TEST_BLOG_POST) {
|
if (process.env.TEST_BLOG_POST) {
|
||||||
TEST_PAGES.push({
|
TEST_PAGES.push({
|
||||||
name: "Custom Blog Post",
|
name: "Custom Blog Post",
|
||||||
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
interactions: [] as number[]
|
interactions: [] as number[]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observe LCP
|
|
||||||
if ("PerformanceObserver" in window) {
|
if ("PerformanceObserver" in window) {
|
||||||
try {
|
try {
|
||||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
buffered: true
|
buffered: true
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// LCP not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe CLS
|
|
||||||
try {
|
try {
|
||||||
const clsObserver = new PerformanceObserver((entryList) => {
|
const clsObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// CLS not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe FID (first input)
|
|
||||||
try {
|
try {
|
||||||
const fidObserver = new PerformanceObserver((entryList) => {
|
const fidObserver = new PerformanceObserver((entryList) => {
|
||||||
const firstInput = entryList.getEntries()[0] as any;
|
const firstInput = entryList.getEntries()[0] as any;
|
||||||
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
fidObserver.observe({ type: "first-input", buffered: true });
|
fidObserver.observe({ type: "first-input", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// FID not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe long tasks
|
|
||||||
try {
|
try {
|
||||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Long tasks not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe INP (event timing for interactions)
|
|
||||||
try {
|
try {
|
||||||
const inpObserver = new PerformanceObserver((entryList) => {
|
const inpObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
inpObserver.observe({ type: "event", buffered: true });
|
inpObserver.observe({ type: "event", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event timing not supported
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
async function collectPerformanceMetrics(
|
async function collectPerformanceMetrics(
|
||||||
page: Page
|
page: Page
|
||||||
): Promise<PerformanceMetrics> {
|
): Promise<PerformanceMetrics> {
|
||||||
// Wait for page to be loaded
|
|
||||||
await page.waitForLoadState("load");
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Additional wait for any remaining network activity
|
|
||||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||||
// Ignore timeout - networkidle may never happen for some pages
|
// Ignore timeout - networkidle may never happen for some pages
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect comprehensive performance metrics
|
|
||||||
const metrics = await page.evaluate(() => {
|
const metrics = await page.evaluate(() => {
|
||||||
const perf = performance.getEntriesByType(
|
const perf = performance.getEntriesByType(
|
||||||
"navigation"
|
"navigation"
|
||||||
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
|
|||||||
const paint = performance.getEntriesByType("paint");
|
const paint = performance.getEntriesByType("paint");
|
||||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||||
|
|
||||||
// Get metrics from our observers
|
|
||||||
const observedMetrics = (window as any).__perfMetrics || {
|
const observedMetrics = (window as any).__perfMetrics || {
|
||||||
lcp: 0,
|
lcp: 0,
|
||||||
cls: 0,
|
cls: 0,
|
||||||
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
|
|||||||
interactions: []
|
interactions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback to direct API if observers didn't capture anything
|
|
||||||
let lcp = observedMetrics.lcp;
|
let lcp = observedMetrics.lcp;
|
||||||
let cls = observedMetrics.cls;
|
let cls = observedMetrics.cls;
|
||||||
let fid = observedMetrics.fid;
|
let fid = observedMetrics.fid;
|
||||||
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
|
|||||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate INP from event timing entries if not already captured
|
|
||||||
if (inp === 0) {
|
if (inp === 0) {
|
||||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||||
const interactionLatencies = eventEntries
|
const interactionLatencies = eventEntries
|
||||||
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get resource timing
|
|
||||||
const resources = performance.getEntriesByType(
|
const resources = performance.getEntriesByType(
|
||||||
"resource"
|
"resource"
|
||||||
) as PerformanceResourceTiming[];
|
) as PerformanceResourceTiming[];
|
||||||
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate long task duration
|
|
||||||
let taskDuration = 0;
|
let taskDuration = 0;
|
||||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||||
taskDuration = observedMetrics.longTasks.reduce(
|
taskDuration = observedMetrics.longTasks.reduce(
|
||||||
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get more granular performance entries
|
|
||||||
let jsExecutionTime = 0;
|
let jsExecutionTime = 0;
|
||||||
let layoutDuration = 0;
|
let layoutDuration = 0;
|
||||||
let paintDuration = 0;
|
let paintDuration = 0;
|
||||||
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for script evaluation entries
|
|
||||||
const entries = performance.getEntries();
|
const entries = performance.getEntries();
|
||||||
entries.forEach((entry: any) => {
|
entries.forEach((entry: any) => {
|
||||||
if (entry.entryType === "measure") {
|
if (entry.entryType === "measure") {
|
||||||
@@ -399,7 +376,6 @@ async function testPagePerformance(
|
|||||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Warmup runs (not counted)
|
|
||||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -410,20 +386,16 @@ async function testPagePerformance(
|
|||||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measured runs
|
|
||||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||||
|
|
||||||
// Create new context for each run to ensure clean state
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
viewport: { width: 1920, height: 1080 }
|
viewport: { width: 1920, height: 1080 }
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// Setup performance observers before navigation
|
|
||||||
await setupPerformanceObservers(page);
|
await setupPerformanceObservers(page);
|
||||||
|
|
||||||
// Navigate and collect metrics
|
|
||||||
await page.goto(url, { waitUntil: "load" });
|
await page.goto(url, { waitUntil: "load" });
|
||||||
const metrics = await collectPerformanceMetrics(page);
|
const metrics = await collectPerformanceMetrics(page);
|
||||||
|
|
||||||
@@ -436,7 +408,6 @@ async function testPagePerformance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const average = calculateAverage(runs);
|
const average = calculateAverage(runs);
|
||||||
const median = calculateMedian(runs);
|
const median = calculateMedian(runs);
|
||||||
const p95 = calculatePercentile(runs, 95);
|
const p95 = calculatePercentile(runs, 95);
|
||||||
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
|
|||||||
"═══════════════════════════════════════════════════════════════════\n"
|
"═══════════════════════════════════════════════════════════════════\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Overall averages
|
|
||||||
const overallAverage = {
|
const overallAverage = {
|
||||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||||
@@ -696,7 +666,6 @@ function printResults(results: TestResult[]) {
|
|||||||
|
|
||||||
console.log("\n Optimization Opportunities:");
|
console.log("\n Optimization Opportunities:");
|
||||||
|
|
||||||
// Find pages with highest JS bytes
|
|
||||||
const highestJS = [...results].sort(
|
const highestJS = [...results].sort(
|
||||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||||
)[0];
|
)[0];
|
||||||
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with slow LCP
|
|
||||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||||
if (slowLCP.length > 0) {
|
if (slowLCP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -715,7 +683,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high CLS
|
|
||||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||||
if (highCLS.length > 0) {
|
if (highCLS.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high INP
|
|
||||||
const highINP = results.filter((r) => r.median.inp > 200);
|
const highINP = results.filter((r) => r.median.inp > 200);
|
||||||
if (highINP.length > 0) {
|
if (highINP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -740,7 +706,6 @@ async function main() {
|
|||||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||||
|
|
||||||
// Check if server is running
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(BASE_URL);
|
const response = await fetch(BASE_URL);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -770,10 +735,8 @@ async function main() {
|
|||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Print results
|
|
||||||
printResults(results);
|
printResults(results);
|
||||||
|
|
||||||
// Save results to JSON file
|
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[:.]/g, "-")
|
.replace(/[:.]/g, "-")
|
||||||
|
|||||||
38
scripts/upload-appcast.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// One-off: upload the backfilled appcast to the download bucket.
|
||||||
|
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
|
||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [, , file] = process.argv;
|
||||||
|
if (!file) {
|
||||||
|
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = readFileSync(file);
|
||||||
|
const xml = body.toString("utf8");
|
||||||
|
// Guard: refuse to upload without all three release descriptions present.
|
||||||
|
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
|
||||||
|
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
|
||||||
|
console.error(`refusing: item for ${v} missing`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: process.env.AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
|
||||||
|
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||||
|
Key: "api/TheNook/appcast.xml",
|
||||||
|
Body: body,
|
||||||
|
ContentType: "application/xml; charset=utf-8"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");
|
||||||
259
scripts/verify-sparkle-dual-host.sh
Executable file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sparkle Appcast Dual-Host Verification Script
|
||||||
|
#
|
||||||
|
# Verifies that Sparkle appcast and DMG endpoints work from both
|
||||||
|
# freno.me (legacy) and subdomain hosts (new)
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/verify-sparkle-dual-host.sh [product]
|
||||||
|
# product: Gaze | InputHalo | all (default: all)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_URL="https://freno.me"
|
||||||
|
SUBDOMAINS=(
|
||||||
|
"gaze:freno.me"
|
||||||
|
"inputhalo:freno.me"
|
||||||
|
"nessa:freno.me"
|
||||||
|
"lineage:freno.me"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Latest versions from appcast (update as needed)
|
||||||
|
LATEST_GAZE_VERSION="0.7.8"
|
||||||
|
LATEST_INPUTHALO_VERSION="0.5.2"
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
WARN=0
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
log_pass() {
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $1"
|
||||||
|
((PASS++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fail() {
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $1"
|
||||||
|
((FAIL++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}! WARN${NC}: $1"
|
||||||
|
((WARN++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}ℹ INFO${NC}: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check appcast endpoint
|
||||||
|
check_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local url="https://${host}/api/${product}/appcast.xml"
|
||||||
|
|
||||||
|
log_info "Checking appcast for ${product} on ${host}..."
|
||||||
|
|
||||||
|
# Test 1: HTTP status
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Content-Type header
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"application/xml"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Cache-Control header
|
||||||
|
local cache_control=$(curl -sI "$url" 2>/dev/null | grep -i "cache-control" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$cache_control" == *"max-age=300"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Cache-Control"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Cache-Control: ${cache_control}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: CORS header
|
||||||
|
local cors=$(curl -sI "$url" 2>/dev/null | grep -i "access-control-allow-origin" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [ "$cors" = "*" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: CORS header present"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing CORS header"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: Valid XML
|
||||||
|
local xml=$(curl -s "$url" 2>/dev/null)
|
||||||
|
if echo "$xml" | xmllint --noout - 2>/dev/null; then
|
||||||
|
log_pass "${product} appcast on ${host}: Valid XML"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Invalid XML"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Check for absolute enclosure URLs
|
||||||
|
if echo "$xml" | grep -q 'enclosure url="https://freno\.me/api/downloads/'; then
|
||||||
|
log_pass "${product} appcast on ${host}: Uses absolute freno.me enclosure URLs"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing absolute enclosure URLs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare appcast between hosts
|
||||||
|
compare_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local base_host="freno.me"
|
||||||
|
local subdomain_host=$2
|
||||||
|
|
||||||
|
log_info "Comparing ${product} appcast between ${base_host} and ${subdomain_host}..."
|
||||||
|
|
||||||
|
local base_xml=$(curl -s "https://${base_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
local subdomain_xml=$(curl -s "https://${subdomain_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$base_xml" = "$subdomain_xml" ]; then
|
||||||
|
log_pass "${product} appcast: Byte-identical between ${base_host} and ${subdomain_host}"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast: Content differs between ${base_host} and ${subdomain_host}"
|
||||||
|
echo "$base_xml" > /tmp/base-appcast.xml
|
||||||
|
echo "$subdomain_xml" > /tmp/subdomain-appcast.xml
|
||||||
|
echo "Differences saved to /tmp/base-appcast.xml and /tmp/subdomain-appcast.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check DMG download endpoint
|
||||||
|
check_dmg_download() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local version=$3
|
||||||
|
local filename="${product}-${version}.dmg"
|
||||||
|
local url="https://${host}/api/downloads/${filename}"
|
||||||
|
|
||||||
|
log_info "Checking DMG download for ${product} on ${host} (${filename})..."
|
||||||
|
|
||||||
|
# Test HTTP status (just head request for speed)
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} DMG on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test Content-Type
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"apple-diskimage"* ]] || [[ "$content_type" == *"octet-stream"* ]]; then
|
||||||
|
log_pass "${product} DMG on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main verification
|
||||||
|
main() {
|
||||||
|
local product=${1:-"all"}
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "Sparkle Appcast Dual-Host Verification"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Base URL: ${BASE_URL}"
|
||||||
|
echo "Product: ${product}"
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "1. Checking Appcast Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Gaze appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_appcast "Gaze" "freno.me"
|
||||||
|
check_appcast "Gaze" "gaze.freno.me"
|
||||||
|
compare_appcast "Gaze" "gaze.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check InputHalo appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_appcast "InputHalo" "freno.me"
|
||||||
|
check_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
compare_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "2. Checking DMG Download Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check all subdomain hosts for DMG downloads
|
||||||
|
for subdomain_entry in "${SUBDOMAINS[@]}"; do
|
||||||
|
local subdomain_host="${subdomain_entry//:/}"
|
||||||
|
subdomain_host="${subdomain_host//:/}.freno.me"
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_dmg_download "Gaze" "$subdomain_host" "$LATEST_GAZE_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_dmg_download "InputHalo" "$subdomain_host" "$LATEST_INPUTHALO_VERSION"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "3. Checking vercel.json Rewrite Ordering"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check that /api/* pass-throughs come before catch-all rewrites
|
||||||
|
local api_rewrites=$(grep -n "source.*api" vercel.json | head -4 | wc -l)
|
||||||
|
local catchall_rewrites=$(grep -n "source.*\(.*)$" vercel.json | grep -v "api" | wc -l)
|
||||||
|
|
||||||
|
if [ "$api_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${api_rewrites} /api/* pass-through rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 /api/* pass-through rules, found ${api_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$catchall_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${catchall_rewrites} catch-all subdomain rewrite rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 catch-all subdomain rewrite rules, found ${catchall_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "4. Summary"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}PASSED: ${PASS}${NC}"
|
||||||
|
echo -e " ${RED}FAILED: ${FAIL}${NC}"
|
||||||
|
echo -e " ${YELLOW}WARNED: ${WARN}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All checks passed! Dual-host support is working correctly.${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some checks failed. Please review the output above.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main
|
||||||
|
main "$@"
|
||||||