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

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
/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 × 700EC = PPM ÷ selected scale; CF = EC × 10amount = dose per L/gal × working reservoir L/galmL/L = (target EC − source-water EC) ÷ EC-per-mL/Lbase mL/L = EC rise ÷ Σ(part EC-per-mL/L × part ratio); part mL/L = base mL/L × part ratioV1 = (C2 × V2) ÷ C1grams = g/gal × working gallonsDLI (mol/m²/day) = PPFD × hours × 3600 ÷ 1,000,000PPF (µmol/s) = actual watts × efficacy (µmol/J)(fixture PPF × fixture count × canopy efficiency) ÷ area m²PPFDnew = PPFDchart × (hchart ÷ hnew)²(target PPFD × area m²) ÷ (fixture PPF × efficiency)kWh = (actual watts ÷ 1,000) × hours/day × days; cost = kWh × ratecost/mol = cycle electricity cost ÷ (PPF × fixture count × total light-hours × 3,600 ÷ 1,000,000)cost/g = selected cycle cost ÷ dried saleable harvest gramsoperating ROI % = (user comparison value − operating cost) ÷ operating cost × 100; setup payback cycles = setup cost ÷ positive operating-cycle savingsAll 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.
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.
Lovable Cloud auto-manages the .env file. Do not edit it by hand. Variables provided:
VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEYVITE_SUPABASE_PROJECT_IDAdditional secrets (API keys for edge functions, third-party services) are configured via Lovable Cloud secrets — never commit secrets to the repo.
Production domain: https://verdantgrowdiary.com (also served on https://www.verdantgrowdiary.com).
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.A record, www A record) can interrupt SSL issuance — re-verify the certificate after any DNS update.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.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
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:
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.::error lines are added under GitHub Actions.See docs/testing/scanner-guardrails.md for the full contract.
Every PR that touches data access, auth, AI, the Action Queue, sensors, device control, or migrations must satisfy the Verdant safety checklist.
docs/security-checklist.md — required per-PR security review.docs/security-exceptions.md — the registry of intentionally accepted security warnings. Any deviation from the checklist must be recorded here..github/pull_request_template.md — PR template that links the checklist and the validation commands above.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 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 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 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.
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.
Verdant follows a read-only, no-write, no-control architecture for advisory surfaces:
demo, manual, live, stale, or invalid.user_id.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.
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:
present.healthy, verified, success, all good, no issues detected, confirmed safe, validated live.weak, unknown, stale, invalid, demo_only, missing, and blocked states. Do not hide or collapse a weak state into a neutral badge.sanitizeShortLabel before rendering.bun run test:one-tent-loop-proof-never-healthybunx vitest run src/test/one-tent-loop-top-gap-report-golden.test.tsstorageState): bun run test:e2e:one-tent-loop-proof-never-healthy:devbun run check:one-tent-loop-proof-never-healthyVerdant 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=1LOCAL_SUPABASE_URL (e.g. http://127.0.0.1:54321)LOCAL_SUPABASE_ANON_KEYLOCAL_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
supabase start) with this repo's migrations applied (supabase db reset or supabase migration up).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:
supabase link, no remote db push, no hosted refs, no repo secrets),supabase db reset --local,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.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_SANDBOXverdant-production → SUPABASE_DB_URLpostgresql://postgres.<ref>:REDACTED@aws-0-<region>.pooler.supabase.com:5432/postgres. The password is the database password, not the anon or service-role key.SUPABASE_DB_URL_SANDBOX with the sandbox project's URL.SUPABASE_DB_URL with the live project's URL.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.
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
migrationVersion() and applied-check logicsrc/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.
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.
Runtime:
hooks/register.ts 94 lines1import 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};
94hooks/rules.ts 448 lines1// 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