SLOPSHOPPER

verdant-guard

Refuses tool calls that break Verdant's AGENTS.md fences: force-push, pushes to verdant-grow-diary/main, rebase, --no-verify, dependency/lockfile changes…

newguardtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verdant-guard
› fix the failing auth test and add an audit log call ╭─────────────────────────────────╮ │ verdant-guard │ ⏺ Read(src/auth.ts) │ verdant-guard blocked a command │ ⎿ Read 6 lines ╰─────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by verdant-guard: verdant-guard: Force-push is forbidden (AGENTS.md › Git and merges: never force-p ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 94 lines
1import type { ProcessRunResult, Register } from "claude-code";
2
3import {
4  MIGRATION_PATH,
5  PUBLISHED_MIGRATION_MSG,
6  checkBash,
7  checkFileEdit,
8  checkInWarning,
9  checkMcp,
10  repoRelative,
11} from "./rules";
12
13type Git = (argv: readonly string[]) => Promise<ProcessRunResult | null>;
14
15const BASE_REFS = ["origin/verdant-grow-diary", "origin/main"];
16
17/** A migration is published when the base branch (or, with no base ref, HEAD) already holds it. */
18async function isPublished(git: Git, rel: string): Promise<boolean> {
19  for (const ref of BASE_REFS) {
20    const r = await git(["git", "cat-file", "-e", `${ref}:${rel}`]);
21    if (r && r.exitCode === 0) return true;
22  }
23  const base = await git(["git", "rev-parse", "--verify", "-q", "origin/verdant-grow-diary"]);
24  if (base && base.exitCode === 0) return false;
25  const r = await git(["git", "cat-file", "-e", `HEAD:${rel}`]);
26  return r !== null && r.exitCode === 0;
27}
28
29/** The checkout root, so paths anchor exactly even when the checkout sits under ~/src or ~/docs. */
30async function repoRoot(git: Git): Promise<string | null> {
31  const r = await git(["git", "rev-parse", "--show-toplevel"]);
32  return r && r.exitCode === 0 ? r.stdout.trim() || null : null;
33}
34
35/** The refusal reason for writing `filePath`, or null. */
36async function fileReason(git: Git, filePath: unknown): Promise<string | null> {
37  if (typeof filePath !== "string") return null;
38  const root = await repoRoot(git);
39  const reason = checkFileEdit(filePath, root);
40  if (reason) return reason;
41  const rel = repoRelative(filePath, root);
42  if (MIGRATION_PATH.test(rel) && (await isPublished(git, rel)))
43    return PUBLISHED_MIGRATION_MSG(rel);
44  return null;
45}
46
47export const register: Register = (on) => {
48  on("tool.call", { tool: "Bash" }, ($, e, next) => {
49    const reason = checkBash(e.command);
50    if (!reason) return next(e);
51    $.ui.toast("verdant-guard blocked a command");
52    return { deny: `verdant-guard: ${reason}` };
53  });
54
55  on("tool.call", { tool: "Edit" }, async ($, e, next) => {
56    const reason = await fileReason((argv) => $.process.run(argv).catch(() => null), e.file_path);
57    if (!reason) return next(e);
58    $.ui.toast("verdant-guard blocked an edit");
59    return { deny: `verdant-guard: ${reason}` };
60  });
61
62  on("tool.call", { tool: "Write" }, async ($, e, next) => {
63    const reason = await fileReason((argv) => $.process.run(argv).catch(() => null), e.file_path);
64    if (!reason) return next(e);
65    $.ui.toast("verdant-guard blocked a write");
66    return { deny: `verdant-guard: ${reason}` };
67  });
68
69  on("tool.call", { tool: "NotebookEdit" }, async ($, e, next) => {
70    const reason = await fileReason(
71      (argv) => $.process.run(argv).catch(() => null),
72      e.notebook_path,
73    );
74    if (!reason) return next(e);
75    $.ui.toast("verdant-guard blocked a notebook edit");
76    return { deny: `verdant-guard: ${reason}` };
77  });
78
79  on("tool.call", async ($, e, next) => {
80    if (!e.tool.startsWith("mcp__")) return next(e);
81    const input = e as unknown as Record<string, unknown>;
82    const reason = checkMcp(e.tool, input);
83    if (reason) {
84      $.ui.toast("verdant-guard blocked an MCP call");
85      return { deny: `verdant-guard: ${reason}` };
86    }
87    const warning = checkInWarning(e.tool, input);
88    if (!warning) return next(e);
89    const ran = await next(e);
90    if (ran.deny !== undefined) return ran;
91    return { ...ran, context: [...(ran.context ?? []), warning] };
92  });
93};
94
hooks/rules.ts 448 lines
1// Pure rules for verdant-guard: no engine, no I/O, deterministic.
2// Each check returns a refusal reason, or null when the call may run.
3// Source of every rule: AGENTS.md / CLAUDE.md on the verdant-grow-diary deploy branch.
4
5export const PROTECTED_BRANCHES = ["verdant-grow-diary", "main"] as const;
6
7// The production Supabase project ref (CURRENT_STATE.md, standing directive 2026-08-25).
8export const PRODUCTION_PROJECT_REF = "knkwiiywfkbqznbxwqfh";
9
10const HEREDOC_START = /(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
11const SHELL_READS_STDIN = /(^|[\s|;&(])(bash|sh|zsh|dash)(\s+-[a-z]+)*\s*(<<|$)/;
12
13/**
14 * Drops heredoc bodies: they are data (a file being written, a script for python or node),
15 * not shell commands. A body fed to a shell (`bash <<EOF`) is kept, since the shell runs it.
16 */
17export function stripHeredocs(command: string): string {
18  const out: string[] = [];
19  let end: string | null = null;
20  let keep = false;
21  for (const line of command.split("\n")) {
22    if (end !== null) {
23      if (line.trim() === end) end = null;
24      else if (keep) out.push(line);
25      continue;
26    }
27    out.push(line);
28    const m = HEREDOC_START.exec(line);
29    if (m && m[2]) {
30      end = m[2];
31      keep = SHELL_READS_STDIN.test(line.slice(0, m.index).trimEnd() + " <<");
32    }
33  }
34  return out.join("\n");
35}
36
37/**
38 * Splits a shell command into simple-command segments and tokens. Quote-aware: `&&`, `||`,
39 * `;`, `|` and newlines split only outside quotes, so `grep "a|git push --force"` stays one
40 * command. Quotes are removed from tokens; a backslash escapes the next character outside
41 * single quotes. It does not expand `$(…)`, backticks or `bash -c` strings (see README).
42 */
43/** Index of the `)` closing the `(` at `open`, respecting nested parens and quotes; -1 if none. */
44function matchingParen(text: string, open: number): number {
45  let depth = 0;
46  let quote: "'" | '"' | null = null;
47  for (let i = open; i < text.length; i += 1) {
48    const c = text[i];
49    if (quote !== null) {
50      if (c === "\\" && quote === '"') i += 1;
51      else if (c === quote) quote = null;
52      continue;
53    }
54    if (c === "\\") i += 1;
55    else if (c === "'" || c === '"') quote = c;
56    else if (c === "(") depth += 1;
57    else if (c === ")" && --depth === 0) return i;
58  }
59  return -1;
60}
61
62export function segments(command: string): string[][] {
63  const text = stripHeredocs(command);
64  const out: string[][] = [];
65  let tokens: string[] = [];
66  let token = "";
67  let inToken = false;
68  let quote: "'" | '"' | null = null;
69  const endToken = () => {
70    if (inToken) tokens.push(token);
71    token = "";
72    inToken = false;
73  };
74  const endSegment = () => {
75    endToken();
76    if (tokens.length > 0) out.push(tokens);
77    tokens = [];
78  };
79  for (let i = 0; i < text.length; i += 1) {
80    const c = text[i]!;
81    if (quote !== null) {
82      if (quote === '"' && c === "$" && text[i + 1] === "(") {
83        // A command substitution still runs inside double quotes: check its body as commands.
84        const end = matchingParen(text, i + 1);
85        if (end > i + 1) {
86          out.push(...segments(text.slice(i + 2, end)));
87          token += text.slice(i, end + 1);
88          i = end;
89          continue;
90        }
91      }
92      if (c === quote) quote = null;
93      else if (c === "\\" && quote === '"' && i + 1 < text.length) token += text[++i];
94      else token += c;
95      continue;
96    }
97    if (c === "'" || c === '"') {
98      quote = c;
99      inToken = true;
100    } else if (c === "\\" && i + 1 < text.length) {
101      // A backslash-newline is a line continuation, not a character.
102      if (text[i + 1] !== "\n") {
103        token += text[i + 1];
104        inToken = true;
105      }
106      i += 1;
107    } else if (c === "\n" || c === ";" || c === "|" || c === "(" || c === ")") {
108      if (c === "|" && text[i + 1] === "|") i += 1;
109      endSegment();
110    } else if (c === "&" && text[i - 1] !== ">" && text[i + 1] !== ">") {
111      // `&&` and a lone background `&` both end a command; `2>&1` and `&>` are redirections.
112      if (text[i + 1] === "&") i += 1;
113      endSegment();
114    } else if (/\s/.test(c)) {
115      endToken();
116    } else {
117      token += c;
118      inToken = true;
119    }
120  }
121  endSegment();
122  // An unclosed quote would hide everything after it, so fall back to the conservative
123  // split that treats every operator as a separator.
124  if (quote !== null) return [...out, ...naiveSegments(text)];
125  return out;
126}
127
128function naiveSegments(text: string): string[][] {
129  return text
130    .split(/&&|\|\||;|\||\n|\(|\)|(?<![>])&(?![>])/)
131    .map((s) =>
132      s
133        .trim()
134        .split(/\s+/)
135        .filter(Boolean)
136        .map((t) => t.replace(/^['"]|['"]$/g, "")),
137    )
138    .filter((t) => t.length > 0);
139}
140
141/** Drops leading `VAR=value` assignments and `sudo`/`env`/`exec` wrappers. */
142function stripPrefix(tokens: string[]): string[] {
143  let i = 0;
144  for (const t of tokens) {
145    if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) && !["sudo", "env", "exec", "time"].includes(t)) break;
146    i += 1;
147  }
148  return tokens.slice(i);
149}
150
151/** For `git -C dir push ...` returns ["push", ...]. */
152function gitArgs(tokens: string[]): string[] | null {
153  if (tokens[0] !== "git") return null;
154  let i = 1;
155  for (let t = tokens[i]; t !== undefined && t.startsWith("-"); t = tokens[i]) {
156    // -C <dir> and -c <k=v> consume a value
157    i += t === "-C" || t === "-c" ? 2 : 1;
158  }
159  return tokens.slice(i);
160}
161
162const FORCE_FLAGS = /^(--force|-f|--force-with-lease(=.*)?|--force-if-includes)$/;
163
164function checkGit(args: string[]): string | null {
165  const [sub, ...rest] = args;
166  if (sub === "push") {
167    if (rest.some((t) => FORCE_FLAGS.test(t) || (/^\+/.test(t) && !t.startsWith("+-")))) {
168      return "Force-push is forbidden (AGENTS.md › Git and merges: never force-push or rewrite history). Update the branch by merging from base.";
169    }
170    if (rest.includes("--no-verify")) {
171      return "`--no-verify` skips the repo's pre-commit/pre-push safety gates. Run the hooks and fix what they report.";
172    }
173    const positional = rest.filter((t) => !t.startsWith("-"));
174    for (const ref of positional.slice(1)) {
175      const target = ref.includes(":") ? ref.split(":").pop()! : ref;
176      const branch = target.replace(/^refs\/heads\//, "");
177      if ((PROTECTED_BRANCHES as readonly string[]).includes(branch)) {
178        return `Pushing to \`${branch}\` is forbidden (AGENTS.md: never push directly to verdant-grow-diary or main). Push your own task branch and open a draft PR.`;
179      }
180    }
181    return null;
182  }
183  if (sub === "rebase" && !rest.some((t) => t === "--abort" || t === "--quit")) {
184    return "`git rebase` rewrites history (AGENTS.md: update branches by merging from base). Use `git merge origin/<base>`.";
185  }
186  if (
187    sub === "pull" &&
188    rest.some((t) => t === "--rebase" || t === "-r" || t.startsWith("--rebase="))
189  ) {
190    return "`git pull --rebase` rewrites history. Use `git pull --no-rebase` or `git merge`.";
191  }
192  if (sub === "commit" && rest.some((t) => t === "--no-verify" || t === "-n")) {
193    return "`git commit --no-verify` skips lint-staged, the full-project tsc and the docs-safety asserts. Commit without it and fix what fails.";
194  }
195  if (sub === "filter-branch" || sub === "filter-repo") {
196    return "History rewriting is forbidden (AGENTS.md › Git and merges).";
197  }
198  return null;
199}
200
201const LOCK_MSG =
202  "Dependency and lockfile changes are off-limits without Matthew's approval (AGENTS.md › Off-limits). Bun is canonical; never add/change deps with npm/yarn/pnpm.";
203
204function checkPackageManager(tokens: string[]): string | null {
205  const [pm, sub, ...rest] = tokens;
206  const positional = rest.filter((t) => !t.startsWith("-"));
207  if (pm === "npm") {
208    if (["add", "uninstall", "remove", "rm", "un", "update", "up", "upgrade"].includes(sub ?? ""))
209      return LOCK_MSG;
210    // A bare install (no package named) is the run skill's public-registry bootstrap; naming a package is a dependency change.
211    if ((sub === "install" || sub === "i") && positional.length > 0) return LOCK_MSG;
212    return null;
213  }
214  if (pm === "yarn" || pm === "pnpm") {
215    if (["add", "remove", "install", "i", "up", "upgrade", "update"].includes(sub ?? ""))
216      return LOCK_MSG;
217    return null;
218  }
219  if (pm === "bun") {
220    if (["add", "a", "remove", "rm", "update"].includes(sub ?? "")) return LOCK_MSG;
221    if (sub === "install" || sub === "i") {
222      if (positional.length > 0) return LOCK_MSG;
223      return "Don't run `bun install` here: bun.lock pins ~137 tarballs on a Lovable registry that 403s outside its sandbox. If node_modules exists, use it; otherwise follow the verified bootstrap in .claude/skills/run-verdant-grow-diary/SKILL.md.";
224    }
225  }
226  return null;
227}
228
229const PW_VALUE_FLAGS = new Set([
230  "--project",
231  "--grep",
232  "-g",
233  "--grep-invert",
234  "--reporter",
235  "--workers",
236  "-j",
237  "--config",
238  "-c",
239  "--retries",
240  "--timeout",
241  "--output",
242  "--shard",
243  "--repeat-each",
244  "--max-failures",
245  "--trace",
246]);
247
248/** Drops a package-runner prefix: `bunx`, `npx`, `bun x`, `pnpm dlx|exec`, `yarn dlx|exec`. */
249function stripRunner(tokens: string[]): string[] {
250  const [a, b] = tokens;
251  let rest: string[];
252  if (a === "bunx" || a === "npx") rest = tokens.slice(1);
253  else if (a === "bun" && b === "x") rest = tokens.slice(2);
254  else if ((a === "pnpm" || a === "yarn") && (b === "dlx" || b === "exec")) rest = tokens.slice(2);
255  else return tokens;
256  // Skip the runner's own flags (`-y`, `--yes`, `--bun`, `--silent`, …). `-p`/`--package`
257  // take a value; `-c`/`--call` take the command itself, which is what gets checked.
258  for (let i = 0; i < rest.length; i += 1) {
259    const t = rest[i] ?? "";
260    if (!t.startsWith("-")) return rest.slice(i);
261    if ((t === "-c" || t === "--call") && rest[i + 1] !== undefined) {
262      return rest[i + 1]!.split(/\s+/).filter(Boolean);
263    }
264    if (t.startsWith("--call=")) return t.slice("--call=".length).split(/\s+/).filter(Boolean);
265    if (t === "-p" || t === "--package") i += 1;
266  }
267  return [];
268}
269
270function checkPlaywright(tokens: string[]): string | null {
271  const t = stripRunner(tokens);
272  if (t[0] !== "playwright" || t[1] !== "test") return null;
273  const args = t.slice(2);
274  let project: string | null = null;
275  let specs = 0;
276  for (let i = 0; i < args.length; i += 1) {
277    const a = args[i] ?? "";
278    if (a.startsWith("--project=")) project = a.slice("--project=".length);
279    else if (a === "--project") project = args[i + 1] ?? null;
280    if (PW_VALUE_FLAGS.has(a)) {
281      i += 1;
282      continue;
283    }
284    if (!a.startsWith("-")) specs += 1;
285  }
286  if (project && project.includes("mocked") && specs === 0) {
287    return `\`--project=${project}\` without a spec filter can reach real Supabase (that project installs no global route mocks). Pass an explicit spec path.`;
288  }
289  return null;
290}
291
292const PROD_MSG =
293  "Production database changes, deploys, promotion and rollback are Matthew's decisions (AGENTS.md › Release and Environment Rules). Prepare a release packet or escalation instead.";
294
295function checkProductionOps(rawTokens: string[], whole: string): string | null {
296  const tokens = stripRunner(rawTokens);
297  const [cmd, a, b] = tokens;
298  if (cmd === "supabase") {
299    if (a === "db" && (b === "push" || (b === "reset" && tokens.includes("--linked"))))
300      return PROD_MSG;
301    if (a === "migration" && (b === "up" || b === "repair")) return PROD_MSG;
302    if (a === "functions" && b === "deploy") return PROD_MSG;
303    if (a === "secrets" && (b === "set" || b === "unset")) return PROD_MSG;
304  }
305  if (cmd === "vercel") {
306    if (tokens.includes("--prod") || ["promote", "rollback", "alias"].includes(a ?? ""))
307      return PROD_MSG;
308  }
309  if (cmd === "gh" && a === "pr" && (b === "merge" || b === "ready")) {
310    return "Merging and marking PRs ready belong to Chemdawg after 35/35 required checks plus an independent exact-head PASS (AGENTS.md). Drafts remain draft.";
311  }
312  if (
313    (cmd === "psql" || cmd === "pg_dump" || cmd === "pg_restore") &&
314    whole.includes(PRODUCTION_PROJECT_REF)
315  ) {
316    return PROD_MSG;
317  }
318  return null;
319}
320
321/** The whole Bash rule set. */
322export function checkBash(command: string): string | null {
323  for (const raw of segments(command)) {
324    const tokens = stripPrefix(raw);
325    if (tokens.length === 0) continue;
326    const git = gitArgs(tokens);
327    const reason =
328      (git ? checkGit(git) : null) ??
329      checkPackageManager(tokens) ??
330      checkPlaywright(tokens) ??
331      checkProductionOps(tokens, command);
332    if (reason) return reason;
333  }
334  return null;
335}
336
337/** Normalises a path to its repo-relative form by anchoring on known roots. */
338export function repoRelative(filePath: string, repoRoot?: string | null): string {
339  const p = filePath.replace(/\\/g, "/");
340  // With the repository root known, strip it exactly: the heuristic below would anchor on the
341  // first `/src/` or `/docs/` anywhere, which is wrong for a checkout under ~/src/ or ~/docs/.
342  const root = repoRoot ? repoRoot.replace(/\\/g, "/").replace(/\/+$/, "") : "";
343  if (root && p.startsWith(`${root}/`)) return p.slice(root.length + 1);
344  for (const root of ["src/", "supabase/", "scripts/", "docs/", "e2e/", "config/", ".github/"]) {
345    const i = p.indexOf(`/${root}`);
346    if (i >= 0) return p.slice(i + 1);
347    if (p.startsWith(root)) return p;
348  }
349  return p.replace(/^.*\//, "");
350}
351
352const GENERATED = [
353  /^src\/routeTree\.gen\.ts$/,
354  /^src\/integrations\/supabase\/types\.ts$/,
355  /^supabase\/functions\/mcp\/index\.ts$/,
356  /^supabase\/functions\/_shared\/lib\//,
357];
358
359const LOCKFILES = new Set([
360  "bun.lock",
361  "bun.lockb",
362  "package-lock.json",
363  "yarn.lock",
364  "pnpm-lock.yaml",
365]);
366
367export const MIGRATION_PATH = /^supabase\/migrations\/[^/]+\.sql$/;
368
369/**
370 * Static file-edit rules. Published-migration immutability needs a git lookup,
371 * so it is decided in register.ts with `isPublished`.
372 */
373export function checkFileEdit(filePath: string, repoRoot?: string | null): string | null {
374  const rel = repoRelative(filePath, repoRoot);
375  if (GENERATED.some((re) => re.test(rel))) {
376    return `\`${rel}\` is generated — never hand-edit it (CLAUDE.md › Conventions). Regenerate it with the repo's tooling.`;
377  }
378  if (LOCKFILES.has(rel)) return LOCK_MSG;
379  return null;
380}
381
382export const PUBLISHED_MIGRATION_MSG = (rel: string) =>
383  `\`${rel}\` is a published migration and is permanent history (AGENTS.md › Migration Immutability). Ship a new additive migration, or check config/local-supabase-replay-compatibility.json.`;
384
385/**
386 * MCP tools that publish, merge, promote or write production, keyed by service and tool name.
387 * The server segment of `mcp__<server>__<tool>` varies by how a connector is installed
388 * (`Supabase`, `supabase`, `claude_ai_Supabase`), so it is matched by service, not exactly.
389 */
390const MCP_DENY: Record<string, string> = {
391  github__merge_pull_request: "merge",
392  github__enable_pr_auto_merge: "merge",
393  supabase__apply_migration: "prod",
394  supabase__execute_sql: "prod",
395  supabase__deploy_edge_function: "prod",
396  supabase__merge_branch: "prod",
397  supabase__reset_branch: "prod",
398  supabase__delete_branch: "prod",
399  supabase__pause_project: "prod",
400  supabase__restore_project: "prod",
401  vercel__request_promote: "prod",
402  vercel__request_rollback: "prod",
403  vercel__create_deployment: "prod",
404  vercel__start_rolling_release: "prod",
405  vercel__complete_rolling_release: "prod",
406  vercel__approve_rolling_release_stage: "prod",
407  lovable__deploy_project: "prod",
408};
409
410const MCP_SERVICES = ["github", "supabase", "vercel", "lovable"] as const;
411
412/** `mcp__claude_ai_Supabase__execute_sql` → `supabase__execute_sql`; null for other servers. */
413function mcpServiceTool(tool: string): string | null {
414  const m = /^mcp__(.+?)__([^_].*)$/.exec(tool);
415  if (!m || !m[1] || !m[2]) return null;
416  const server = m[1].toLowerCase();
417  // The service must be a whole `_`- or `-`-separated part of the server name, so
418  // `claude-ai-supabase` and `supabase_prod` match while `notsupabase` does not.
419  const parts = server.split(/[_-]/);
420  const service = MCP_SERVICES.find((s) => parts.includes(s));
421  return service ? `${service}__${m[2]}` : null;
422}
423
424export function checkMcp(tool: string, input: Record<string, unknown>): string | null {
425  const key = mcpServiceTool(tool);
426  const kind = key ? MCP_DENY[key] : undefined;
427  if (kind === "merge") {
428    return "Merging belongs to Chemdawg after 35/35 required checks plus an independent exact-head PASS (AGENTS.md).";
429  }
430  if (kind === "prod") return PROD_MSG;
431  if (key === "github__update_pull_request" && input.draft === false) {
432    return "Drafts remain draft (AGENTS.md › Git and merges). Readiness is decided by the merge owner.";
433  }
434  return null;
435}
436
437/** CLAUDE.md › Check-in cadence: the prompt cache lives 60 min; arm at <= 55. */
438export const CHECK_IN_MAX_MINUTES = 55;
439
440export function checkInWarning(tool: string, input: Record<string, unknown>): string | null {
441  if (!/send_later$/.test(tool)) return null;
442  const minutes = typeof input.delay_minutes === "number" ? input.delay_minutes : null;
443  if (minutes !== null && minutes > CHECK_IN_MAX_MINUTES && minutes <= 75) {
444    return `verdant-guard: this check-in is armed at ${minutes} min. CLAUDE.md asks for <= ${CHECK_IN_MAX_MINUTES} min so the wake lands inside the 60-minute prompt cache; a 56–75 min gap is usually an accidental near-miss.`;
445  }
446  return null;
447}
448