diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e93b123 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# PFVCluster environment variables +# Copy to .env and fill in values for local development/testing. + +# Pi-hole (netinfra/pihole/docker-compose.yml) +PIHOLE_WEB_PASSWORD=changeme + +# Shellcheck wrapper (tests/shellcheck.sh) — no config needed, uses Docker. diff --git a/AGENTS.md b/AGENTS.md index d4b3255..9db9068 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,43 @@ # Agent Guidelines +## Quick Start + +**You are an AI agent working on this project. Your first actions, in order:** + +1. **Set up the environment:** `bash scripts/setup-hooks.sh` (installs git hooks — idempotent). +2. **Read this file** (`AGENTS.md`) — project policy and domain knowledge. +3. **Read [questions-v1.md](questions-v1.md)** — open questions awaiting human input. +4. **Check Redmine** — `~/daytoday/redmine/bin/redmine list --assigned-to-me -p 55` for active work. +5. **Check current state:** `git log --oneline -10`. +6. **Run rule audit:** `bash scripts/check-rules.sh --fast`. + +## Enforcement Model + +Git hooks (`scripts/pre-commit`, `scripts/pre-push`) enforce the rules defined in +`scripts/check-rules.sh`. The rules engine checks: shellcheck (zero warnings +including info-level), Docker image pinning (no `:latest`), container naming, +required files, Discourse pointer headers, and more. Run `bash scripts/check-rules.sh` +for a full audit or `--fast` for pre-commit speed. Bypass with `--no-verify` +(emergencies only). + +## Task Tracking + +- **Redmine is the system of record for all work.** +- **WORKING.md** is the only in-repo task tracker — a scratchpad for the current + session. The pre-commit hook blocks commits while any task remains unchecked. +- Clear WORKING.md before responding to the user. + +## Working Style + +- **Stop over-thinking.** Get to code and output faster. Explore with code; + gather ground truth. Do not burn tokens reasoning about things a quick command + answers. +- **Farm work out to deterministic tooling:** linters, LSPs, formatters, test + runners. If an LSP is wired up, use it; otherwise pull a Docker image and lint + inside it. +- **Use sub-agents as subcontractors:** scoped spec in, distilled deliverable out. + Never read 10+ files sequentially; batch into agent calls. + ## Documentation policy (IMPORTANT) **Discourse is the canonical source of truth for all knowledge documentation.** @@ -38,6 +76,7 @@ k8s/ k3s cluster setup scripts (HA control plane over Tailscale) proxmox/ Proxmox fleet docs (hardware audit, capacity, storage) + perf tuning (perf/) awx/ Ansible AWX deployment (k3s + AWX Operator) tests/ Test suite + VM validation harness + remote.sh SSH chokepoint +scripts/ Framework: git hooks, rule engine (check-rules.sh), shared lib docs/ Server-build docs, docmap index, and archive archive/ Historical/superseded code (provisioning -> replaced by KNELIAC project) vendor/ Vendored KNELShellFramework @@ -181,6 +220,8 @@ lives (gitignored) at `/home/reachableceo/projects/KNEL-AIMiddleware/discourse-c | Script | Purpose | |--------|---------| +| [`scripts/check-rules.sh`](scripts/check-rules.sh) | Rule audit engine (shellcheck, image pinning, Discourse pointers, required files) | +| [`scripts/setup-hooks.sh`](scripts/setup-hooks.sh) | Install git hooks (pre-commit, pre-push) | | [`tests/remote.sh`](tests/remote.sh) | **SSH chokepoint** — all Proxmox host + sandbox VM access routes here | | [`netinfra/dns-cluster-setup/remote-dns.sh`](netinfra/dns-cluster-setup/remote-dns.sh) | SSH chokepoint for DNS infra hosts (netinfra-01/02, tsrouter, netboot) | | `~/daytoday/redmine/bin/redmine` | Redmine CLI wrapper (ticket read/write via Docker container) | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e52dad --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# Makefile — convenience dispatch to scripts/. +# +# Not required. The scripts in scripts/ are the real entry points and work +# standalone. This file just gives you short verbs if you're at a terminal. +# +# In Mode 2 (Hermes/OWUI/MCP), agents call the scripts directly or via API — +# they don't need this file. + +# Project-specific overrides for check-rules.sh +export PROJECT_DOC_EXEMPT ?= AGENTS.md STATUS.md WORKING.md README.md ADOPTING.md LICENSE .env.example questions-v1.md BASELINE-PROMPT.md PATTERNS.md +export PROJECT_DISCOURSE_HOST ?= community.turnsys.com + +.PHONY: setup validate fast lint test garden up down status clean help + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +setup: ## Install git hooks + @bash scripts/setup-hooks.sh + +validate: ## Full rule audit (includes tests) + @bash scripts/check-rules.sh + +fast: ## Fast rule audit (pre-commit equivalent) + @bash scripts/check-rules.sh --fast + +lint: ## Lint shell scripts (shellcheck via docker) + @docker run --rm -v "$$(pwd):/mnt" koalaman/shellcheck:stable \ + $$(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./node_modules -prune -o \( -name '*.sh' -o -name '*.bash' \) -print | sed 's|^\./|/mnt/|') || true + +test: ## Run the test suite (override per project) + @bash scripts/test.sh + +garden: ## Doc-sprawl / Discourse-migration report + @bash scripts/garden.sh + +up: ## Bring up the docker-compose stack + @bash scripts/up.sh + +down: ## Bring down the docker-compose stack + @bash scripts/down.sh + +status: ## Show repo status snapshot + @echo "== branch =="; git branch --show-current 2>/dev/null || echo "(no branch)" + @echo "== last commit =="; git log --oneline -1 2>/dev/null || true + @echo "== working tree =="; git status --short 2>/dev/null || echo "(not a git repo)" + @echo "== STATUS.md head =="; sed -n '1,12p' STATUS.md 2>/dev/null || echo "(no STATUS.md)" + +clean: ## Remove build/test artifacts (override per project) + @echo "make clean: nothing to clean — override this in your project's Makefile." diff --git a/WORKING.md b/WORKING.md new file mode 100644 index 0000000..59c38a5 --- /dev/null +++ b/WORKING.md @@ -0,0 +1,9 @@ +# WORKING.md — Active Session Tracker + +Agent work only. The human decides when it's done. +The todos tool is banned; this is the only task tracker. +A commit is blocked while any task below remains unchecked. + +## Current Tasks + +(none — all agent work is committed) diff --git a/netinfra/pihole/docker-compose.yml b/netinfra/pihole/docker-compose.yml index 99effb8..aaf650c 100644 --- a/netinfra/pihole/docker-compose.yml +++ b/netinfra/pihole/docker-compose.yml @@ -4,7 +4,7 @@ services: # Root cause of the 2026-08 gravity.db corruption: default /dev/shm (64M) # was too small for FTL's shared-memory metrics. 1024M has been stable. shm_size: '1024M' - image: pihole/pihole:latest + image: pihole/pihole:2026.07.0 hostname: pihole entrypoint: ["/usr/local/bin/gravity-validate.sh"] ports: @@ -42,7 +42,7 @@ services: - dnsnet autoheal: container_name: autoheal - image: willfarrell/autoheal:latest + image: willfarrell/autoheal:1.2.0 environment: AUTOHEAL_CONTAINER_LABEL: autoheal AUTOHEAL_INTERVAL: 30 diff --git a/proxmox/perf/scripts/probe-vm-dns.sh b/proxmox/perf/scripts/probe-vm-dns.sh index 3ba82ff..21f9fb7 100644 --- a/proxmox/perf/scripts/probe-vm-dns.sh +++ b/proxmox/perf/scripts/probe-vm-dns.sh @@ -13,7 +13,7 @@ for vmid in $(qm list 2>/dev/null | awk 'NR>1 && $4=="running" {print $1}'); do net0=$(qm config "$vmid" 2>/dev/null | awk '/^net0:/{print $0}') echo "" echo "--- VMID $vmid: $name ---" - echo " net0: $(echo "$net0" | sed 's/net0: //')" + echo " net0: ${net0//net0: /}" # Try to get IP via guest-agent if qm config "$vmid" 2>/dev/null | grep -q 'agent:.*enabled=1\|^agent: 1'; then diff --git a/questions-v1.md b/questions-v1.md new file mode 100644 index 0000000..2248587 --- /dev/null +++ b/questions-v1.md @@ -0,0 +1,56 @@ +# questions-v1.md + +> Git-tracked question log. The agent writes; the human reviews/edits inline. +> Version up when a round of answers lands. Synthesize resolved Q&A to Discourse/Redmine. +> See BASELINE-PROMPT.md §9. + +## Open questions + +### Q1. Git remote for meta? +- **Context:** meta is now a git repo (locally) but has no remote configured. The auto-commit+push policy (baseline §4) can't complete without one. +- **Options:** (a) new Gitea repo under reachableceo; (b) nest under an existing repo; (c) keep local-only for now. +- **Question:** Where should meta push? +- **Answer:** _(human)_ +- **Decision:** _(human/agent)_ +- **Synthesized to:** — + +Go with option a. The tea command is setup on this workstation (and on ultix-offstage). I guess, also capture that the tea command (and docker login) are setup on my workstations, so that in the future, projects know they can use tea to setup a repo. Also, i want this to be TSYS wide, so it should go under the TSYSGroupCorporate organization. Call the repo: TSYSGroupAIOS . Make it a template repository. + +### Q2. The bin/ wrapper gap (redmine-cli / discourse-cli) +- **Context:** PFVCluster's operational.md and AGENTS.md reference `~/daytoday/redmine/bin/redmine` and `~/daytoday/discourse/bin/discourse` as the entrypoints. But `ls ~/daytoday/{redmine,discourse}/` shows only `.gitignore` + `AGENTS.md` (+ MIGRATION-PLAN.md for discourse) — no `bin/`, no Dockerfile. The actual CLI source lives in `~/projects/KNEL-AIMiddleware/{redmine,discourse}-cli/`. +- **Question:** Are the `bin/` wrappers something that should exist (and were lost / never committed), or is the documentation aspirational? Should the template reference these CLIs at all, or stay tool-agnostic? +- **Answer:** _(human)_ +- **Decision:** _(human/agent)_ +- **Synthesized to:** — + +The clis should exist. Maybe the AGENTS.md reference the actual paths? I dont need duplicate code. I think i was using the directories as kind of "shortcuts" vs the ~/projects/... path every time. So, for this repo, reference the full path/container name/invoke notes. Does that make sense? + +### Q3. Should the template ship the Discourse pointer-header pattern? +- **Context:** PFVCluster migrated 36 in-repo `.md` files to 10-line pointer stubs citing `https://community.turnsys.com/t/`. The template currently has `scripts/garden.sh` that *warns* about oversized non-Discourse `.md`, but doesn't enforce the pointer-header format. +- **Options:** (a) keep it advisory (garden.sh warn only); (b) add an opt-in check-rule that fails if a tracked `.md` lacks a Discourse URL (excluding AGENTS.md/STATUS.md/etc.); (c) leave it project-local — infra projects want it, personal/business projects don't. +- **Question:** Which option, and is the assumption in (c) right? +- **Answer:** _(human)_ +- **Decision:** _(human/agent)_ +- **Synthesized to:** — + +All projects need it. Discourse/redmine is MANDATORY. No exceptions. What is project specific is which categories to use, and maybe some tagging/topic guidelines etc. + +### Q4. Sub-agent nudge hook — wanted? +- **Context:** A sub-agent proposed a non-blocking Crush hook (`hooks/nudge-subagent.sh`) that emits a stderr reminder after the Nth sequential file read, nudging toward dispatching a sub-agent. Mirrors football's "never read 10+ files sequentially" rule. +- **Options:** (a) add it (non-blocking, advisory); (b) leave sub-agent use as prose policy only. +- **Question:** Worth adding, or too noisy? +- **Answer:** _(human)_ +- **Decision:** _(human/agent)_ +- **Synthesized to:** — + +Preseving tokens/quota burn is a HUGE priority. It lets me and you do far more work for much longer. Also, I want to move away from harness specific hooks. Git hooks/strong AGENTS.md protocols are strongly preferred. Ill be shifting away from crush over next few weeks to using OpenWebUi/Hermes and a whole swarm of agents with reporting/working relationships etc etc. So anything that is harness specific, get rid of it and make it portable. + + +### Q5. JOURNAL.md vs Discourse audit-log for infra projects +- **Context:** The template ships `docs/JOURNAL.md` as the append-only decision log. But PFVCluster (the most mature infra project) has NO JOURNAL.md — it uses Discourse topic #298 as the audit log and Redmine for work tracking. PATTERNS.md §5 noted this divergence. +- **Question:** Should the template keep JOURNAL.md as the default, with infra projects swapping it for the Discourse-audit-log pattern? Or drop JOURNAL.md entirely in favor of "Discourse is the SoR"? +- **Answer:** _(human)_ +- **Decision:** _(human/agent)_ +- **Synthesized to:** — + +No more JOURNAL.md . Redmine is the system of record. JOURNAL.md was a hack I was using until redmine integration was in place. And, yes, discourse can also be used as well. Its a bit of a tricky decision, what should go to redmine vs discourse. I usually keep working notes/evolving status etc in Redmine and then synthesize to Discourse. But thats me as a lowly human :) You figure it out as you go and per project. diff --git a/scripts/check-rules.sh b/scripts/check-rules.sh new file mode 100755 index 0000000..7ba3a6a --- /dev/null +++ b/scripts/check-rules.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# check-rules.sh — project rule audit engine. +# +# Usage: +# bash scripts/check-rules.sh # full audit (verbose, includes slow checks) +# bash scripts/check-rules.sh --fast # fast audit (quiet, skips slow checks) — for pre-commit +# bash scripts/check-rules.sh --quiet # full audit, only prints failures +# +# Exit code: 0 = all rules pass (warnings are non-fatal), 1 = one or more FAILED. +# +# This is a generalized version of the rules engine proven in the +# RCEO-PersonalAssistant project. Add project-specific checks by appending +# `check "" ""` calls below. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +# --- argument parsing --- +RULE_FAST=false +RULE_VERBOSE=true +for arg in "$@"; do + case "$arg" in + --fast) RULE_FAST=true; RULE_VERBOSE=false ;; + --quiet) RULE_VERBOSE=false ;; + *) die "check-rules.sh: unknown argument '$arg'" ;; + esac +done +export RULE_FAST RULE_VERBOSE + +init_counters +$RULE_VERBOSE && echo "=== Project Rule Audit ===" + +TODAY="$(date +%Y-%m-%d)" + +# ---------------------------------------------------------------------------- +# 1. Shellcheck — every .sh/.bash must pass (zero warnings, incl. info-level). +# Runs in Docker so the host stays clean (no native shellcheck required). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Shell scripts (shellcheck)" +mapfile -d '' SH_FILES < <(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./archive -prune -o -path ./node_modules -prune -o \( -name '*.sh' -o -name '*.bash' \) -print0 2>/dev/null) +if [ "${#SH_FILES[@]}" -gt 0 ]; then + if have shellcheck; then + if shellcheck "${SH_FILES[@]}" >/dev/null 2>&1; then + check "All shell scripts pass shellcheck (host)" "pass" + else + check "shellcheck reports violations — run: shellcheck " "fail" + fi + elif have docker; then + MNT_FILES=() + for f in "${SH_FILES[@]}"; do MNT_FILES+=("/mnt/${f#./}"); done + if docker run --rm -v "$REPO_ROOT:/mnt" koalaman/shellcheck:stable "${MNT_FILES[@]}" >/dev/null 2>&1; then + check "All shell scripts pass shellcheck (docker)" "pass" + else + check "shellcheck (docker) reports violations" "fail" + fi + else + check "No shellcheck or docker available to lint scripts" "warn" + fi +else + check "No shell scripts to lint" "pass" +fi + +# ---------------------------------------------------------------------------- +# 2. Docker image pinning — no ':latest' tags in compose or Dockerfiles. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Docker image pinning" +if grep -rqE '(image:|FROM).*:latest' --include='docker-compose*.y*ml' --include='Dockerfile*' . 2>/dev/null; then + check "No ':latest' image tags (pin everything)" "fail" +else + check "No ':latest' image tags" "pass" +fi + +# ---------------------------------------------------------------------------- +# 2b. Container naming — every service in a docker-compose file MUST set an +# explicit container_name (never rely on Docker's default _). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Container naming" +COMPOSE_FILES="$(find . -path ./.git -prune -o \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) -print 2>/dev/null || true)" +if [ -n "$COMPOSE_FILES" ]; then + BAD=0 + while IFS= read -r cf; do + [ -n "$cf" ] || continue + # Count top-level service keys (2-space indent under services:) and + # compare against the number of container_name: declarations. + svc_count=$(awk '/^services:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]{2}[[:alnum:]_-]+:[[:space:]]*$/{c++} END{print c+0}' "$cf") + cn_count=$(grep -cE '^[[:space:]]*container_name:' "$cf" 2>/dev/null || echo 0) + if [ "${svc_count:-0}" -gt 0 ] && [ "$cn_count" -lt "$svc_count" ]; then + BAD=$((BAD + 1)) + fi + done </dev/null; then + if [ "$POINTER_MISSING" -eq 0 ]; then + $RULE_VERBOSE && printf ' %s\n' "Missing $DISCOURSE_HOST URL in:" + fi + POINTER_MISSING=$((POINTER_MISSING + 1)) + $RULE_VERBOSE && printf ' %s\n' "$f" + fi +done < <(find . -path ./.git -prune -o -path ./.tmp -prune -o -path ./vendor -prune -o -path ./archive -prune -o -name '*.md' -print0 2>/dev/null) +if [ "$POINTER_MISSING" -eq 0 ]; then + check "All non-exempt .md cite Discourse ($DISCOURSE_HOST)" "pass" +else + check "$POINTER_MISSING .md file(s) missing Discourse pointer (see BASELINE-PROMPT.md §3)" "fail" +fi + +# ---------------------------------------------------------------------------- +# 5. Git state — uncommitted changes are a warning (the pre-push hook hardens +# this where it matters). +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Git state" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if git diff --quiet && git diff --cached --quiet; then + check "Working tree clean" "pass" + else + check "Uncommitted changes present" "warn" + fi +else + check "Not a git repo (git checks skipped)" "pass" +fi + +# ---------------------------------------------------------------------------- +# 6. Hooks installed — self-check that git hooks were set up. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Git hooks" +if [ -f .git/hooks/pre-commit ]; then + check "pre-commit hook installed" "pass" +else + check "pre-commit NOT installed (run: bash scripts/setup-hooks.sh)" "warn" +fi +if [ -f .git/hooks/pre-push ]; then + check "pre-push hook installed" "pass" +else + check "pre-push NOT installed (run: bash scripts/setup-hooks.sh)" "warn" +fi + +# ---------------------------------------------------------------------------- +# 7. WORKING.md completion — no unchecked tasks may remain at commit time. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Task completion" +if [ -f WORKING.md ]; then + UNCHECKED="$(grep -cF -- '- [ ]' WORKING.md || true)" + if [ "$UNCHECKED" -eq 0 ]; then + check "WORKING.md has no unchecked tasks" "pass" + else + check "WORKING.md has ${UNCHECKED} unchecked task(s) — finish them before committing" "fail" + fi +else + check "WORKING.md absent (no active task tracker)" "pass" +fi + +# ---------------------------------------------------------------------------- +# 8. CNW markers — empty `CNW:` markers flag unresolved questions for the human. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "Unresolved questions" +EMPTY_CNW="$(grep -rn 'CNW:$' . --include='*.md' 2>/dev/null | head -20 || true)" +if [ -z "$EMPTY_CNW" ]; then + check "No empty CNW: markers (unresolved questions)" "pass" +else + CNW_COUNT="$(printf '%s\n' "$EMPTY_CNW" | grep -c . || true)" + check "${CNW_COUNT} unresolved CNW: marker(s) — needs user input" "warn" +fi + +# ---------------------------------------------------------------------------- +# 9. Hygiene — merge-conflict markers and trailing whitespace must never land. +# ---------------------------------------------------------------------------- +$RULE_VERBOSE && log_step "File hygiene" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + CONFLICT="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | xargs -r grep -lE '^(<<<<<<<|=======|>>>>>>>)' 2>/dev/null || true)" + if [ -z "$CONFLICT" ]; then check "No merge-conflict markers staged" "pass"; else check "Merge-conflict markers staged: $CONFLICT" "fail"; fi +fi + +# ---------------------------------------------------------------------------- +# 10. (slow, skipped in --fast) Project test suite via scripts/test.sh. +# ---------------------------------------------------------------------------- +if [ "$RULE_FAST" = false ] && [ -x scripts/test.sh ]; then + $RULE_VERBOSE && log_step "Test suite (scripts/test.sh)" + if bash scripts/test.sh >/dev/null 2>&1; then + check "scripts/test.sh passes" "pass" + else + check "scripts/test.sh FAILS" "fail" + fi +fi + +print_summary_and_exit diff --git a/scripts/docker-run.sh b/scripts/docker-run.sh new file mode 100755 index 0000000..2e0ce38 --- /dev/null +++ b/scripts/docker-run.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# docker-run.sh — canonical ephemeral-container wrapper. +# +# Keeps the host clean: every build/test/generation runs inside a pinned image. +# Ensures output files are owned by the invoking user (not root). +# +# Usage: +# docker-run.sh [command...] +# Runs in with the repo mounted at /data, cwd /data. +# With no command, drops into the image's default entrypoint. +# docker-run.sh --shell +# Interactive shell inside the container (for debugging). +# +# Examples: +# docker-run.sh python:3.12-slim python3 -m pytest +# docker-run.sh pandoc/extra report.md -o report.pdf +# docker-run.sh --shell node:20 +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" + +SHELL_MODE=false +case "${1:-}" in + --shell) SHELL_MODE=true; shift ;; + -h|--help) + sed -n '2,18p' "$0"; exit 0 ;; +esac + +[ "$#" -ge 1 ] || { sed -n '2,18p' "$0"; exit 1; } + +if [ "$SHELL_MODE" = true ]; then + # ${SHELL:-sh} must expand inside the container, not in this outer shell. + # shellcheck disable=SC2016 + docker_run "$1" sh -c 'exec "${SHELL:-sh}"' +else + docker_run "$@" +fi diff --git a/scripts/garden.sh b/scripts/garden.sh new file mode 100755 index 0000000..67b0592 --- /dev/null +++ b/scripts/garden.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# garden.sh — the gardening loop. +# +# Reports doc sprawl and files that violate the "Discourse is the system of +# record for documentation; gitea .md files are stubs" policy. Run via +# `bash scripts/garden.sh`. Findings are WARNINGS (advisory); fix them at a natural break. +# +# What it checks: +# 1. Markdown sprawl: count of .md files per directory (top-10 by count). +# 2. Oversized .md files (default >300 lines) that don't cite a Discourse URL +# — candidates to migrate to Discourse, leaving a stub. +# 3. .md files with no Discourse link at all (informational; exempt: the +# operational files in EXEMPT_FILES). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +SIZE_LIMIT="${GARDEN_MD_LINE_LIMIT:-300}" +# Operational files that legitimately live next to code, not in Discourse. +EXEMPT_FILES="${GARDEN_EXEMPT:-AGENTS.md STATUS.md WORKING.md questions-v.*.md PATTERNS.md BASELINE-PROMPT.md README.md}" + +log_step "Gardening report for $REPO_ROOT" + +# --- 1. sprawl by directory ------------------------------------------------- +log_info "Markdown file count by directory (top 10):" +find . -path ./.git -prune -o -name '*.md' -print 2>/dev/null \ + | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -10 | sed 's/^/ /' + +# --- 2. oversized .md without a Discourse link ------------------------------ +log_info "Oversized .md (>${SIZE_LIMIT} lines) lacking a Discourse URL — migrate candidates:" +OVERSIZED=0 +while IFS= read -r -d '' f; do + # skip exempt files (glob match against basename and relative path) + exempt=false + base=$(basename "$f") + rel=${f#./} + for pat in $EXEMPT_FILES; do + # shellcheck disable=SC2254 # glob match is intentional + case "$base" in $pat) exempt=true; break ;; esac + # shellcheck disable=SC2254 + case "$rel" in $pat) exempt=true; break ;; esac + done + [ "$exempt" = true ] && continue + lines=$(wc -l < "$f" 2>/dev/null || echo 0) + if [ "$lines" -gt "$SIZE_LIMIT" ]; then + if ! grep -qiE 'community\.turnsys\.com|discourse' "$f" 2>/dev/null; then + printf ' %-60s %s lines\n' "$f" "$lines" + OVERSIZED=$((OVERSIZED + 1)) + fi + fi +done < <(find . -path ./.git -prune -o -name '*.md' -print0 2>/dev/null) +[ "$OVERSIZED" -eq 0 ] && echo " (none)" + +# --- 3. summary ------------------------------------------------------------- +log_step "Gardening summary" +echo " Oversized non-Discourse .md files: $OVERSIZED" +if [ "$OVERSIZED" -eq 0 ]; then + log_ok "no migration candidates" +else + log_warn "$OVERSIZED file(s) to migrate to Discourse" +fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..a3dd5e4 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# lib/common.sh — shared helpers for shell scripts and hooks in this repo. +# +# Source it from any script: +# #!/usr/bin/env bash +# set -euo pipefail +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib/common.sh +# source "$HERE/lib/common.sh" # or the appropriate relative path +# +# This library exists to drive a known cross-project inconsistency to zero: +# every repo used to re-paste the ANSI color block, redefine log_* helpers, +# pick one of three incompatible shebangs, and roll its own docker wrapper. +# Import this once instead. + +# Do NOT set -euo pipefail here unconditionally — some callers (git hooks) +# source this file and rely on controlling their own shell options. We only +# guarantee the functions below are defined. + +############################################################################### +# Config — override via environment before sourcing if needed +############################################################################### +: "${TEMPLATE_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +export TEMPLATE_ROOT + +############################################################################### +# ANSI colors (defined once, used everywhere) +############################################################################### +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BLUE=''; BOLD=''; NC='' +fi +export RED GREEN YELLOW BLUE BOLD NC + +############################################################################### +# Logging +############################################################################### +log_info() { printf "${BLUE}›${NC} %s\n" "$*"; } +log_ok() { printf "${GREEN}✓${NC} %s\n" "$*"; } +log_warn() { printf "${YELLOW}⚠${NC} %s\n" "$*" >&2; } +log_error() { printf "${RED}✗${NC} %s\n" "$*" >&2; } +log_step() { printf "\n${BOLD}== %s ==${NC}\n" "$*"; } + +die() { log_error "$*"; exit 1; } + +############################################################################### +# Predicates +############################################################################### +# have — return 0 if is on PATH +have() { command -v "$1" >/dev/null 2>&1; } + +############################################################################### +# Path helpers +############################################################################### +repo_root() { + # Prefer git's notion of the repo root, fall back to $TEMPLATE_ROOT, then pwd. + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + else + printf '%s\n' "${TEMPLATE_ROOT:-$(pwd)}" + fi +} + +############################################################################### +# Privilege helpers +############################################################################### +# as_root — run the remaining args as root via sudo, or directly if already root. +as_root() { + if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi +} + +############################################################################### +# Docker wrapper +############################################################################### +# docker_run +# Ephemeral container, host-uid ownership, repo mounted at /data, cwd /data. +# Drives the "host stays clean; everything runs in containers" policy and +# ensures output files are owned by the invoking user, not root. +docker_run() { + [ "$#" -ge 1 ] || die "docker_run: image required" + local image="$1"; shift + have docker || die "docker not found on PATH" + local root + root="$(repo_root)" + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -v "$root:/data" \ + -w /data \ + "$image" "$@" +} + +############################################################################### +# Rule-audit accumulator (used by scripts/check-rules.sh) +# Globals read/written: RULE_PASS RULE_WARN RULE_FAIL +############################################################################### +init_counters() { RULE_PASS=0; RULE_WARN=0; RULE_FAIL=0; } + +# check +check() { + local desc="$1" result="$2" + case "$result" in + pass) + RULE_PASS=$((RULE_PASS + 1)) + if [ "${RULE_VERBOSE:-true}" = true ]; then printf " ${GREEN}PASS${NC} %s\n" "$desc"; fi + ;; + warn) + RULE_WARN=$((RULE_WARN + 1)) + if [ "${RULE_VERBOSE:-true}" = true ]; then printf " ${YELLOW}WARN${NC} %s\n" "$desc"; fi + ;; + fail) + RULE_FAIL=$((RULE_FAIL + 1)) + printf " ${RED}FAIL${NC} %s\n" "$desc" + ;; + *) + die "check(): invalid result '$result' (use pass|warn|fail)" + ;; + esac +} + +# print_summary_and_exit +print_summary_and_exit() { + if [ "${RULE_VERBOSE:-true}" = true ]; then + printf "\n=== Summary ===\n PASS: %s\n WARN: %s\n FAIL: %s\n\n" \ + "$RULE_PASS" "$RULE_WARN" "$RULE_FAIL" + fi + if [ "$RULE_FAIL" -gt 0 ]; then + if [ "${RULE_VERBOSE:-true}" = true ]; then + printf "AUDIT FAILED — %s rule(s) violated.\n" "$RULE_FAIL" + fi + exit 1 + fi + if [ "${RULE_VERBOSE:-true}" = true ]; then printf "AUDIT PASSED.\n"; fi + exit 0 +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..1833906 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# pre-commit — fast rule audit (< 1s typical). +# Hot-path bypass: commits that ONLY touch STATUS.md / WORKING.md skip the +# audit so frequent status/task commits stay frictionless. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +CHANGED="$(git diff --cached --name-only)" +HOT_PATHS="$(printf '%s\n' "$CHANGED" | grep -vE '^(STATUS.md|WORKING.md)$' || true)" + +if [ -z "$HOT_PATHS" ]; then + echo "hot-path files only (STATUS/WORKING) — skipping rule audit" + exit 0 +fi + +if ! bash scripts/check-rules.sh --fast; then + echo "" + echo "pre-commit audit FAILED. Fix the violations above before committing." + echo "Full audit: bash scripts/check-rules.sh" + echo "Bypass: git commit --no-verify (emergencies only)" + exit 1 +fi +exit 0 diff --git a/scripts/pre-push b/scripts/pre-push new file mode 100755 index 0000000..7c5bf7d --- /dev/null +++ b/scripts/pre-push @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# pre-push — full rule audit + clean-working-tree gate before pushing. +# Installed via: bash scripts/setup-hooks.sh +# +# Combines two proven policies observed across projects: +# - KNEL-AIMiddleware: block push if the working tree is dirty. +# - RCEO-PersonalAssistant: block push if the full test suite fails. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +echo "pre-push: running full rule audit..." + +# Full audit (non-fast): runs the slow test suite via `make test` if present. +if ! bash scripts/check-rules.sh --quiet; then + echo "" + echo "pre-push audit FAILED. Push blocked." + echo "Re-run with output: bash scripts/check-rules.sh" + echo "Bypass: git push --no-verify (emergencies only)" + exit 1 +fi + +echo "pre-push: all rules and tests passed." +exit 0 diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100755 index 0000000..57219ac --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# setup-hooks.sh — install this repo's git hooks. +# +# Mechanism: copy scripts/pre-commit and scripts/pre-push into .git/hooks/ and +# make them executable. This is the most portable pattern (works on any clone, +# no `git config core.hooksPath` mutation, survives config resets, idempotent). +# +# Run once after cloning: bash scripts/setup-hooks.sh +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/lib/common.sh" +REPO_ROOT="$(repo_root)" +cd "$REPO_ROOT" + +[ -d .git ] || die "no .git directory here — run this from a git checkout" + +HOOKS_DIR=".git/hooks" +HOOK_NAMES="pre-commit pre-push" + +log_step "Installing git hooks" +for name in $HOOK_NAMES; do + src="scripts/$name" + dst="$HOOKS_DIR/$name" + [ -f "$src" ] || die "source hook not found: $src" + cp "$src" "$dst" + chmod +x "$dst" + log_ok "installed $dst" +done + +cat <