SLOPSHOPPER

verdant-cache-clock

Status-line countdown to the 60-minute prompt-cache expiry (CLAUDE.md check-in cadence), with a toast at 50 minutes idle.

newtoaststatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verdant-cache-clock
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ verdant-cache-clock: cache warm ~60m
README

Verdant

Quick Log Playwright smoke CI docs-safety

These workflows include the Client secret boundary guard. The badge reflects overall workflow status, not the guard alone — see Client Secret Boundary Guard for how to verify the guard specifically.

Quick links: Workflow · Latest run · Artifacts are attached to each completed run under quicklog-smoke-artifacts (open the run page → Artifacts).

Verdant is a standalone Grow Room Operating System. It turns grow logs, plant photos, sensor readings, alerts, and AI-assisted analysis into safer grow decisions and better harvest outcomes.

The current product priority is the V0 operating loop:

Grow → Tent → Plant → Diary/Logs → Photo → Sensor Snapshot → AI Doctor → Alert/Recommendation → Approval-Required Action Queue

Tech stack

  • React + TypeScript + Vite
  • Tailwind CSS + shadcn/ui
  • Supabase (Auth, Database, Storage, Edge Functions) via Lovable Cloud
  • Vitest for tests

Grow Help Toolkit

/tools/grow-help-toolkit is a client-side nutrient, grow-light, and expense planner. It uses no account, backend, analytics, device control, or live sensor data. The last inputs are stored only in browser localStorage; CSV and print exports are also generated in the browser.

Core formulas:

  • PPM500 = EC × 500, PPM640 = EC × 640, PPM700 = EC × 700
  • EC = PPM ÷ selected scale; CF = EC × 10
  • Label rate: amount = dose per L/gal × working reservoir L/gal
  • EC target, one part: mL/L = (target EC − source-water EC) ÷ EC-per-mL/L
  • EC target, ratio-aware multipart: base mL/L = EC rise ÷ Σ(part EC-per-mL/L × part ratio); part mL/L = base mL/L × part ratio
  • Dilution: V1 = (C2 × V2) ÷ C1
  • Dry salt: grams = g/gal × working gallons
  • DLI (mol/m²/day) = PPFD × hours × 3600 ÷ 1,000,000
  • Estimated PPF, only when needed: PPF (µmol/s) = actual watts × efficacy (µmol/J)
  • Planning average PPFD: (fixture PPF × fixture count × canopy efficiency) ÷ area m²
  • Inverse-square approximation: PPFDnew = PPFDchart × (hchart ÷ hnew)²
  • Fixtures needed: (target PPFD × area m²) ÷ (fixture PPF × efficiency)
  • Electricity: kWh = (actual watts ÷ 1,000) × hours/day × days; cost = kWh × rate
  • Fixture-output photon cost: cost/mol = cycle electricity cost ÷ (PPF × fixture count × total light-hours × 3,600 ÷ 1,000,000)
  • Unit cost: cost/g = selected cycle cost ÷ dried saleable harvest grams
  • Optional user comparison: operating ROI % = (user comparison value − operating cost) ÷ operating cost × 100; setup payback cycles = setup cost ÷ positive operating-cycle savings

All light outputs are planning estimates. Verify canopy PPFD with a PAR meter; inverse-square behavior and the optional 3×3 interpolation are approximations, not measured PAR maps. Nutrient EC/PPM ranges are typical starting references, not guarantees. Cost-per-weight accepts dried saleable weight only and never prefills a yield or market price. Optional N/P/K ppm entries are grower-entered planning notes only; they do not infer a dose without product elemental analysis.

Local setup

bun install
bun run dev

The dev server runs Vite on http://localhost:8080. bun is the package manager (bun.lock is authoritative) — do not use npm or yarn. On Windows, run bun install from a checkout outside OneDrive; OneDrive reparse points break it.

Environment variables

Lovable Cloud auto-manages the .env file. Do not edit it by hand. Variables provided:

  • VITE_SUPABASE_URL
  • VITE_SUPABASE_PUBLISHABLE_KEY
  • VITE_SUPABASE_PROJECT_ID

Additional secrets (API keys for edge functions, third-party services) are configured via Lovable Cloud secrets — never commit secrets to the repo.

Production deployment

Production domain: https://verdantgrowdiary.com (also served on https://www.verdantgrowdiary.com).

  • Public acquisition, guide, cultivar, support, and local-tool routes are registered explicitly. Private workspace routes remain gated behind Supabase Auth.
  • SSL/TLS certificates belong to whichever platform the measured apex domain binding names — see docs/specs/release-topology-specification.md §4; do not assume a platform. Both the apex and www hostnames must serve a valid certificate before announcing a release.
  • DNS changes (apex A record, www A record) can interrupt SSL issuance — re-verify the certificate after any DNS update.
  • See docs/launch-checklist.md for the full pre-launch verification steps.

Public crawler surfaces:

  • public/robots.txt — allows crawling and points at the production sitemap.
  • public/sitemap.xml — lists the indexable public surfaces. Private authenticated and noindex routes are intentionally excluded.

Validation

Run all of the following before requesting review:

bunx vitest run
bunx eslint <changed files>
npm run build

All existing tests must pass. New behavior must ship with new tests.

Scanner guardrail changes must also run the CI-equivalent sentinel:

bun run test:scanner-guardrails:ci

See docs/testing/scanner-guardrails.md for scannerIt, installScannerGuardrail, cached scanner walks, and slow-test telemetry rules.

Watch-mode tests:

npx vitest

Scanner guardrail sentinel

The scanner guardrail suite walks the filesystem and is the most likely source of environmental timeout flakes. A 5000ms slow-test sentinel appends offenders to test-results/scanner-guardrail-slow-tests.jsonl.

bun run test:scanner-guardrails           # raw scanner sentinel (vitest)
bun run test:scanner-guardrails:ci        # CI-equivalent wrapper:
                                          #   - deletes the stale report
                                          #   - runs the scanner suite
                                          #   - validates JSONL row contract
                                          #   - fails the build if any slow
                                          #     row was emitted
bun run test:scanner-guardrails:ci -- --verbose
                                          # also prints report path, threshold,
                                          # stale-report removal state,
                                          # post-run report presence, row count,
                                          # validation stats (valid/invalid/slow),
                                          # and the value-preview truncation limit
bun run test:scanner-guardrails:clean                # remove the default report
bun run test:scanner-guardrails:clean -- <path>      # remove a specific report file

Report path: test-results/scanner-guardrail-slow-tests.jsonl.

Diagnostics behavior:

  • Under GITHUB_ACTIONS=true, the CI wrapper emits one ::error annotation per invalid or slow telemetry row (not just the first). Annotations include report path, JSONL line number, suite/test/file, durationMs/thresholdMs, and the failed-fields list.
  • Field diffs are compact and per-row. Each value is run through a truncating preview capped at the configured limit (80 characters by default) so log output stays small and never dumps full payloads.
  • Local terminal output remains readable; only the ::error lines are added under GitHub Actions.

See docs/testing/scanner-guardrails.md for the full contract.

Development workflow & safety standards

Every PR that touches data access, auth, AI, the Action Queue, sensors, device control, or migrations must satisfy the Verdant safety checklist.

AI Coach safety

The AI Coach is read-only and suggest-only. It must never trigger writes, device commands, or unattended Action Queue changes. Safety regressions are caught by:

Action Queue safety

Action Queue items remain approval-required. No code path may auto-approve, auto-complete, or auto-cancel queue items, and no executable device payload may ship through the queue. Safety and audit guarantees are covered by:

Sensor / live-data truthfulness

Sensor readings must never be faked as live. Every reading is labeled as one of demo, manual, live, stale, or invalid. Stale, missing, or suspicious telemetry must be surfaced as such — never silently substituted and never relabeled as healthy. See docs/sensor-truth-rules.md and docs/data-labeling-spec.md.

RLS / auth.uid() ownership

RLS is the ownership boundary for every user-owned table. Policies are written against auth.uid() and evaluated server-side. Never trust client-provided user_id — the frontend must not send it as a trusted field, and any client-supplied value must be re-checked server-side. No service_role key may appear in client code.

Pi-ingest deployed smoke test

After deploying the pi-ingest-readings edge function, run the deployed pi-ingest smoke verification described in docs/pi-ingest-smoke-runbook.md. It covers signed-bridge happy-path, replay/idempotency, tampered signature, and unknown-bridge cases. The contract that runbook verifies lives in docs/pi-ingest-write-transaction-contract.md.

Windows EcoWitt local testbench: see docs/ecowitt-windows-testbench.md.

Safety philosophy

Verdant follows a read-only, no-write, no-control architecture for advisory surfaces:

  • No fake live data. Sensor readings are labeled demo, manual, live, stale, or invalid.
  • No blind automation. AI suggests; the grower approves.
  • No device control from advisory surfaces. The Action Queue is approval-required.
  • Ownership is enforced server-side via Supabase RLS — never trust client-provided user_id.
  • No service_role keys in client code.

See docs/buildops-kit/README.md for the full BuildOps Kit covering product context, data-labeling, fixture contracts, AI Doctor output rules, Action Queue safety, prompt scaffolds, and the QA regression checklist.

One-Tent Loop Proof Safety Rules

The /one-tent-loop-proof route is a read-only diagnostic. Its rules are enforced by unit + fuzz + golden + Playwright tests. Any change to the proof surface must uphold the following:

  • Weak, stale, invalid, demo-only, unknown, or missing evidence must never render as healthy, present, verified, success, or "OK". Downstream steps blocked or weakened by weak telemetry must never render as present.
  • Downstream wording must be honest. Allowed phrasing includes "not healthy", "not verified", and "cannot be confirmed". The following unqualified phrases are forbidden anywhere on the proof surface or in the sanitized text report: healthy, verified, success, all good, no issues detected, confirmed safe, validated live.
  • The evidence checklist UI must preserve visible weak, unknown, stale, invalid, demo_only, missing, and blocked states. Do not hide or collapse a weak state into a neutral badge.
  • Sanitized text reports (top-gap block, artifact export) must never expose secrets, raw payloads, bridge tokens, service role keys, API keys, access tokens, or JWT-like strings. Any untrusted source label must pass through sanitizeShortLabel before rendering.
  • Demo, manual, live, stale, and invalid source labels must remain explicit in the UI and in text reports. Do not normalize them to a generic "sensor" label.

Local commands

  • Vitest rules + fuzz + evidence-ref safety: bun run test:one-tent-loop-proof-never-healthy
  • Golden top-gap text block (exact equality): bunx vitest run src/test/one-tent-loop-top-gap-report-golden.test.ts
  • Playwright never-healthy spec against the mocked harness (same config used in CI, no real auth, no Supabase writes, no storageState): bun run test:e2e:one-tent-loop-proof-never-healthy:dev
  • Full local gate (typecheck + vitest + sanitized artifact + Playwright): bun run check:one-tent-loop-proof-never-healthy

Local MCP RLS integration test

Verdant exposes three read-only MCP tools (list_grows, list_recent_diary_entries, get_latest_sensor_snapshot). The test src/test/mcp-local-rls-integration.test.ts proves that these tools enforce Supabase Row-Level Security through the signed-in grower's OAuth/session token, including under limit and includeArchived options, and that responses never leak another user's rows, raw_payload, service_role, JWTs, or bridge/OAuth secrets.

Beyond the explicit regression cases, the suite derives extra pagination/filter isolation cases from .lovable/mcp/manifest.json: every advertised limit/boolean-filter param automatically generates cross-user cases for both users, foreign-scope-id probes, and unauthenticated checks. Params are never invented — a tool that advertises no pagination/filter params (like get_latest_sensor_snapshot) is recorded as N/A instead of failing.

The suite is local-only and skips cleanly in CI/PRs where the harness is not configured. It never contacts hosted Supabase and never requires production secrets.

Required env vars

  • MCP_LOCAL_RLS_HARNESS=1
  • LOCAL_SUPABASE_URL (e.g. http://127.0.0.1:54321)
  • LOCAL_SUPABASE_ANON_KEY
  • LOCAL_SUPABASE_SERVICE_ROLE_KEY — local only, used exclusively for seeding/cleanup; MCP tool execution itself always routes through supabaseForUser(ctx) with an anon-scoped user token. Never paste a hosted/production service role key here, and never commit any service role key — local keys are ephemeral CLI-generated values.

Required local services

  • Local Supabase running (e.g. supabase start) with this repo's migrations applied (supabase db reset or supabase migration up).
  • Local grants: the local CLI stack does not grant API-role table privileges the way hosted Lovable Cloud's migration runner does, so every PostgREST request fails with 42501 permission denied until you mirror hosted reality (local database only):
  GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
  GRANT ALL ON TABLE public.grows, public.tents, public.diary_entries,
    public.sensor_readings TO service_role;
  GRANT SELECT ON TABLE public.grows, public.tents, public.diary_entries,
    public.sensor_readings TO authenticated;

The CI workflow runs this automatically after supabase db reset --local.

Run it

MCP_LOCAL_RLS_HARNESS=1 \
LOCAL_SUPABASE_URL=http://127.0.0.1:54321 \
LOCAL_SUPABASE_ANON_KEY=<local-anon-key> \
LOCAL_SUPABASE_SERVICE_ROLE_KEY=<local-service-role-key> \
bun run test:mcp:rls:local

The test:mcp:rls:local package script is a thin wrapper around bunx vitest run src/test/mcp-local-rls-integration.test.ts — it contains no keys; you always supply local env values yourself.

CI behavior

The mcp-local-rls-integration workflow (.github/workflows/mcp-local-rls-integration.yml) runs the harness against a fresh local Supabase on the runner:

  1. starts local Supabase via the CLI (no supabase link, no remote db push, no hosted refs, no repo secrets),
  2. masks the ephemeral local keys and waits for auth/REST readiness with a bounded retry loop,
  3. applies and verifies repo migrations with supabase db reset --local,
  4. runs the harness, and
  5. only when the job fails, uploads sanitized debug artifacts from artifacts/mcp-local-rls/ (harness log, response snapshots, vitest output). Artifacts are sanitized twice — at write time by the harness and again by scripts/sanitize-mcp-rls-artifacts.mjs — so JWTs, bearer tokens, service_role material, refresh/bridge/access tokens, client secrets, raw headers, raw_payload, and live env values are always redacted.

Documentation

Money-migration applied-check

scripts/assert-required-money-migrations-applied.mjs verifies that every migration listed in scripts/required-money-migrations.mjs is actually present in the target database's supabase_migrations.schema_migrations tracker. It is read-only (single SELECT) and blocks deploys when a required migration exists on disk but has not been applied to the target environment.

The .github/workflows/required-money-migrations.yml workflow runs this check against both sandbox and live. It reads each DB connection string from its protected GitHub environment:

  • verdant-sandbox → SUPABASE_DB_URL_SANDBOX
  • verdant-production → SUPABASE_DB_URL

Setting the GitHub secrets

  1. Get each project's pooled connection string from the Lovable Cloud project settings (Database → Connection string → Session pooler, URI format). It looks like postgresql://postgres.<ref>:REDACTED@aws-0-<region>.pooler.supabase.com:5432/postgres. The password is the database password, not the anon or service-role key.
  2. In GitHub, open Settings → Environments → verdant-sandbox and create SUPABASE_DB_URL_SANDBOX with the sandbox project's URL.
  3. Open Settings → Environments → verdant-production and create SUPABASE_DB_URL with the live project's URL.
  4. Re-run the required-money-migrations workflow to confirm both jobs go green. If a job errors with exit code 2, the URL is wrong or the pooler is unreachable; exit code 1 means a required migration is missing from that environment and must be applied before deploying.

Rotate these secrets whenever the database password is rotated.

Running the applied-check locally

Requires psql on PATH (brew install libpq on macOS, sudo apt-get install postgresql-client on Debian/Ubuntu).

# Sandbox
SUPABASE_DB_URL='postgresql://postgres.<sandbox-ref>:REDACTED@aws-0-<region>.pooler.supabase.com:5432/postgres' \
  TARGET_ENV=sandbox \
  node scripts/assert-required-money-migrations-applied.mjs

# Live
SUPABASE_DB_URL='postgresql://postgres.<live-ref>:REDACTED@aws-0-<region>.pooler.supabase.com:5432/postgres' \
  TARGET_ENV=live \
  node scripts/assert-required-money-migrations-applied.mjs

Exit codes: 0 = all required migrations applied, 1 = one or more missing (do not deploy), 2 = malformed required-file name (extractor could not derive a 14-digit prefix), 3 = no DB connection string, 4 = psql not on PATH, 5 = tracker query failed. Treat 2-5 as blocking. The script writes a machine-readable audit to audit/money-migrations/applied-audit.json and an expected-vs-actual diff to audit/money-migrations/applied-audit.diff.txt on every exit branch — the same files CI uploads as artifacts. Override the diff path with DIFF_PATH=/tmp/foo.diff.txt.

Pair it with the file-presence guard when auditing locally:

node scripts/assert-required-money-migrations.mjs

Unit tests for migrationVersion() and applied-check logic

src/test/required-money-migrations-version.test.ts covers the 14-digit prefix extractor (migrationVersion() in scripts/required-money-migrations.mjs) and the applied-vs-required comparison used by assert-required-money-migrations-applied.mjs.

Run just this file (fast, no DB needed):

# Focused run — recommended
bunx vitest run src/test/required-money-migrations-version.test.ts

# Verbose reporter (shows every case name)
bunx vitest run src/test/required-money-migrations-version.test.ts --reporter=verbose

# Filter to a single case
bunx vitest run src/test/required-money-migrations-version.test.ts -t "migrationVersion"
bunx vitest run src/test/required-money-migrations-version.test.ts -t "applied-check"

To inspect the exact prefixes the extractor produces for the current required list (useful when a filename rename shows up as one missing + one unknown):

node -e "
  import('./scripts/required-money-migrations.mjs').then(m => {
    for (const f of m.REQUIRED_MONEY_MIGRATIONS) {
      console.log(m.migrationVersion(f).padEnd(16), f);
    }
  });
"

To compare expected (required) vs actual (applied in a target DB) prefixes without running the guard, use the same pooled URL as the applied-check:

# Expected prefixes (from the source-of-truth list)
node -e "
  import('./scripts/required-money-migrations.mjs').then(m => {
    console.log(m.REQUIRED_MONEY_MIGRATIONS.map(m.migrationVersion).sort().join('\n'));
  });
" > /tmp/expected-versions.txt

# Actual prefixes (from the target DB's migration tracker)
psql "$SUPABASE_DB_URL" -Atc \
  "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version" \
  > /tmp/applied-versions.txt

# Required but NOT applied (what the guard would flag)
comm -23 <(sort -u /tmp/expected-versions.txt) <(sort -u /tmp/applied-versions.txt)

# Applied but NOT required (informational — expected to be non-empty)
comm -13 <(sort -u /tmp/expected-versions.txt) <(sort -u /tmp/applied-versions.txt)

The guard itself uses the same SELECT and comparison; these commands just let you eyeball the two sides independently.

One-shot prefix diff CLI (diff-money-migration-prefixes.mjs)

scripts/diff-money-migration-prefixes.mjs is a lightweight companion to the applied-check. It dumps the extractor's expected 14-digit prefixes from the required-money-migrations manifest and (optionally) diffs them against supabase_migrations.schema_migrations in a target database — in a single command, with no audit-file side effects.

Use it for fast local drift checks and as the "fast-fail" gate in CI before the heavier verifier runs.

Local prerequisites

Runtime:

  • Node.js 20+ — t
Source 2 files
hooks/register.ts 30 lines
1import type { Register } from "claude-code";
2
3import { cacheLabel, shouldWarn } from "./clock";
4
5export const register: Register = (on) => {
6  let lastTurnAt: number | null = null;
7  let warned = false;
8
9  on("session.start", async ($, e, next) => {
10    const started = await next(e);
11    $.clock.every(60_000, async () => {
12      if (lastTurnAt === null) return;
13      const idle = (await $.clock.now()) - lastTurnAt;
14      $.ui.status(cacheLabel(idle));
15      if (shouldWarn(idle, warned)) {
16        warned = true;
17        $.ui.toast("Prompt cache goes cold in ~10 min");
18      }
19    });
20    return started;
21  });
22
23  on("turn.complete", async ($, e, next) => {
24    lastTurnAt = await $.clock.now();
25    warned = false;
26    $.ui.status(cacheLabel(0));
27    return next(e);
28  });
29};
30
hooks/clock.ts 17 lines
1// Pure: the cache TTL and the label for a given idle time. CLAUDE.md › Check-in cadence
2// measured a 60-minute prompt-cache lifetime; a wake at <= 55 minutes is a cache read.
3export const CACHE_TTL_MIN = 60;
4export const WARN_AT_MIN = 50;
5
6export function cacheLabel(idleMs: number): string {
7  const idleMin = Math.max(0, Math.floor(idleMs / 60_000));
8  const left = CACHE_TTL_MIN - idleMin;
9  if (left <= 0) return `cache likely cold (idle ${idleMin}m)`;
10  if (idleMin >= WARN_AT_MIN) return `cache cold in ~${left}m: reply or arm check-in <= 55m`;
11  return `cache warm ~${left}m`;
12}
13
14export function shouldWarn(idleMs: number, alreadyWarned: boolean): boolean {
15  return !alreadyWarned && idleMs >= WARN_AT_MIN * 60_000 && idleMs < CACHE_TTL_MIN * 60_000;
16}
17