diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..d4aed397b --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# SessionStart bootstrap for Claude Code cloud sessions (Claude Code on the web). +# +# Registered in .claude/settings.json (matcher: startup|resume); runs before the +# agent takes its first turn. Local sessions exit immediately via the +# CLAUDE_CODE_REMOTE guard — a local machine is presumed provisioned by its +# owner, and this script must never mutate one. +# +# Purpose: give a fresh cloud VM the same tool inventory as +# .github/workflows/ci.yml, so the repo's gates (scripts/run-plugin-tests.sh, +# scripts/validate-plugins.sh, hygiene linters) run instead of SKIPping. +# In-repo manifests stay the single source of truth where one exists: +# Node — .node-version (standards-synced) +# ruff — .github/requirements-ci.txt (hash-locked) +# claude CLI / Biome — root package-lock.json (installed via npm ci) +# Tools with no in-repo manifest are pinned in the VERSION PINS block below. +# +# Idempotent by design: every step checks before it installs, so re-runs on +# session resume cost seconds. Required steps (Node, npm ci, ruff) fail the +# session start when they break; best-effort steps warn and continue, because +# a hygiene binary being briefly unreachable should not block a session. The +# cloud proxy only reliably allows GitHub release-asset downloads for repos +# attached to the session, so every GitHub-release install is best-effort. +set -euo pipefail + +if [[ "${CLAUDE_CODE_REMOTE:-}" != "true" ]]; then + echo "session-start: not a cloud session; nothing to do." >&2 + exit 0 +fi + +repo_root="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}" +cd -- "$repo_root" + +bin_dir="$HOME/.local/bin" +mkdir -p "$bin_dir" +export PATH="$bin_dir:$PATH" + +# --- VERSION PINS (only for tools with no in-repo manifest) ----------------- +# The proxy blocks the GitHub API and /releases/latest redirects; only direct +# /releases/download/ asset URLs resolve, hence hard pins. Each pinned asset +# also carries a SHA-256 recorded from a verified download of that exact +# version; a mismatch refuses the install. Bump pin and hash together. +shellcheck_pin="v0.11.0" # .shellcheckrc targets 0.11.0+ +shellcheck_sha="8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +actionlint_pin="1.7.12" # matches the pin documented in .github/actionlint.yaml +actionlint_sha="8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +typos_pin="v1.42.1" +typos_sha="fe1492d6c1079c328ef66de2094b7a3a4569987ec972ab56002c5db4746a8d1b" +ec_pin="v3.4.0" # editorconfig-checker 3.x, per .editorconfig-checker.json +ec_sha="feae0baaf8d55e51fd9b6c9e04497f2fb288b40034110fb9ac83fb1bf0b6011e" +gitleaks_pin="8.28.0" +gitleaks_sha="a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb" +shfmt_pin="v3.12.0" # bash-format plugin hook; optional in CI by design +shfmt_sha="d9fbb2a9c33d13f47e7618cf362a914d029d02a6df124064fff04fd688a745ea" +markdownlint_pin="0.23.1" # matches the .markdownlint-cli2.jsonc schema pin +check_jsonschema_pin="0.37.4" # pip/uv installs carry registry integrity checks + +# --- Node (required) --------------------------------------------------------- +# CI resolves Node from .node-version; the cloud image ships 20/21/22 via nvm, +# so the pinned major is installed from nodejs.org on first run (~10s). +node_pin="$(tr -d '[:space:]' <.node-version)" +current_node="$(node --version 2>/dev/null || true)" +if [[ "$current_node" != "v$node_pin" ]]; then + export NVM_DIR="${NVM_DIR:-/opt/nvm}" + if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then + echo "session-start: error: Node $node_pin required and nvm not found at $NVM_DIR" >&2 + exit 1 + fi + set +u # nvm.sh reads intentionally-unset variables + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + nvm install "$node_pin" >/dev/null + nvm alias default "$node_pin" >/dev/null + set -u +fi +node_bin="$(dirname -- "$(command -v node)")" + +# --- Session PATH (required) ------------------------------------------------- +# Hook-process env dies with this script; $CLAUDE_ENV_FILE is the sanctioned +# channel for shaping the session's Bash environment (per the cloud-environments +# doc). node_modules/.bin exposes the pinned claude CLI and Biome from npm ci. +if [[ -n "${CLAUDE_ENV_FILE:-}" ]]; then + # shellcheck disable=SC2016 # $PATH must stay literal for the session to expand + path_line="$(printf 'export PATH="%s:%s/node_modules/.bin:%s:$PATH"' \ + "$node_bin" "$repo_root" "$bin_dir")" + # Resume re-runs must not stack duplicate lines: append only when absent. + if ! grep -qxF "$path_line" "$CLAUDE_ENV_FILE" 2>/dev/null; then + printf '%s\n' "$path_line" >>"$CLAUDE_ENV_FILE" + fi +fi + +# --- Root npm toolchain (required) -------------------------------------------- +# Provides the pinned claude CLI and Biome that scripts/validate-plugins.sh and +# the biome-format contract tests expect. Skipped when node_modules is already +# in sync with package-lock.json, so resume-time re-runs are free. +if [[ ! -f node_modules/.package-lock.json || package-lock.json -nt node_modules/.package-lock.json ]]; then + npm ci --no-audit --no-fund +fi + +# --- ruff (required) ----------------------------------------------------------- +# The same hash-locked install CI's plugin-gate lane runs; a satisfied install +# is a fast no-op. Wheel hashes in the requirements file are Linux-x64 only, +# which matches the cloud VM. +python3 -m pip install --user --quiet --only-binary=:all: --require-hashes \ + --requirement .github/requirements-ci.txt + +# --- Hygiene binaries (best effort) -------------------------------------------- +# CI installs these via hosted composite actions this VM can't reach; having +# them locally lets the agent run the same hygiene checks before pushing. +# Failures warn instead of blocking: each corresponding contract test SKIPs +# visibly when its tool is absent, and CI still enforces the real gate. +fetch_release_tool() { + # fetch_release_tool + # Pass "-" as for a bare-binary asset (no archive). + local name="$1" url="$2" sha="$3" member="$4" tmp + if command -v "$name" >/dev/null 2>&1; then + return 0 + fi + if [[ "$(uname -m)" != "x86_64" ]]; then + echo "session-start: warning: skipping $name (non-x86_64 VM)" >&2 + return 0 + fi + tmp="$(mktemp -d)" + local failed=0 + curl -fsSL -o "$tmp/asset" "$url" || failed=1 + if [[ "$failed" -eq 0 ]] && ! echo "$sha $tmp/asset" | sha256sum --check --quiet --status; then + echo "session-start: warning: $name checksum mismatch ($url); refusing to install" >&2 + failed=1 + fi + if [[ "$failed" -eq 0 ]]; then + case "$url" in + *.tar.xz) tar -xJf "$tmp/asset" -C "$tmp" || failed=1 ;; + *.tar.gz) tar -xzf "$tmp/asset" -C "$tmp" || failed=1 ;; + *) member="asset" ;; # bare binary: install the download itself + esac + fi + if [[ "$failed" -eq 0 && -f "$tmp/$member" ]]; then + install -m 0755 "$tmp/$member" "$bin_dir/$name" + else + echo "session-start: warning: $name install failed ($url); its checks will SKIP" >&2 + fi + rm -rf "$tmp" + return 0 +} + +fetch_release_tool shellcheck \ + "https://github.com/koalaman/shellcheck/releases/download/${shellcheck_pin}/shellcheck-${shellcheck_pin}.linux.x86_64.tar.xz" \ + "$shellcheck_sha" "shellcheck-${shellcheck_pin}/shellcheck" +fetch_release_tool actionlint \ + "https://github.com/rhysd/actionlint/releases/download/v${actionlint_pin}/actionlint_${actionlint_pin}_linux_amd64.tar.gz" \ + "$actionlint_sha" "actionlint" +fetch_release_tool typos \ + "https://github.com/crate-ci/typos/releases/download/${typos_pin}/typos-${typos_pin}-x86_64-unknown-linux-musl.tar.gz" \ + "$typos_sha" "typos" +fetch_release_tool editorconfig-checker \ + "https://github.com/editorconfig-checker/editorconfig-checker/releases/download/${ec_pin}/ec-linux-amd64.tar.gz" \ + "$ec_sha" "bin/ec-linux-amd64" +fetch_release_tool gitleaks \ + "https://github.com/gitleaks/gitleaks/releases/download/v${gitleaks_pin}/gitleaks_${gitleaks_pin}_linux_x64.tar.gz" \ + "$gitleaks_sha" "gitleaks" +# shfmt ships as a bare binary (no archive); enables the bash-format plugin's +# format pass (its lint pass uses shellcheck above). +fetch_release_tool shfmt \ + "https://github.com/mvdan/sh/releases/download/${shfmt_pin}/shfmt_${shfmt_pin}_linux_amd64" \ + "$shfmt_sha" "-" + +if ! command -v markdownlint-cli2 >/dev/null 2>&1; then + npm install -g --no-audit --no-fund "markdownlint-cli2@${markdownlint_pin}" || + echo "session-start: warning: markdownlint-cli2 install failed" >&2 +fi + +if ! command -v check-jsonschema >/dev/null 2>&1; then + if command -v uv >/dev/null 2>&1; then + uv tool install --quiet "check-jsonschema==${check_jsonschema_pin}" || + echo "session-start: warning: check-jsonschema install failed" >&2 + else + python3 -m pip install --user --quiet "check-jsonschema==${check_jsonschema_pin}" || + echo "session-start: warning: check-jsonschema install failed" >&2 + fi +fi + +# --- Git history (best effort) -------------------------------------------------- +# Several CI lanes (sync/parity/portability checks) diff against origin/; +# CI checks out with fetch-depth: 0. A shallow cloud clone breaks base-ref +# resolution, so deepen it and make sure origin/main resolves. +git_dir="$(git rev-parse --git-dir)" +if [[ -f "$git_dir/shallow" ]]; then + git fetch --quiet --unshallow || + echo "session-start: warning: could not unshallow; base-ref diffs may fail" >&2 +fi +# Explicit destination refspec: in a single-branch clone a bare `fetch origin +# main` only writes FETCH_HEAD and never creates refs/remotes/origin/main. +git fetch --quiet origin "+main:refs/remotes/origin/main" || + echo "session-start: warning: could not fetch origin/main" >&2 + +# --- Report -------------------------------------------------------------------- +report_tool() { + # report_tool + local name="$1" + shift + if command -v "$name" >/dev/null 2>&1; then + echo "session-start: $name $("$name" "$@" 2>/dev/null | head -1)" + else + echo "session-start: $name ABSENT (its checks will SKIP)" + fi +} +echo "session-start: bootstrap complete in $repo_root" +report_tool node --version +report_tool ruff --version +report_tool shellcheck --version +report_tool actionlint --version +report_tool typos --version +report_tool editorconfig-checker --version +report_tool gitleaks version +report_tool shfmt --version +report_tool markdownlint-cli2 --help +report_tool check-jsonschema --version +report_tool jq --version +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 75384642d..1dd28f693 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,36 @@ { + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh" + } + ] + } + ] + }, + "extraKnownMarketplaces": { + "melodic-software": { + "source": { + "source": "directory", + "path": "./" + } + } + }, + "enabledPlugins": { + "markdown-format@melodic-software": true, + "bash-format@melodic-software": true, + "biome-format@melodic-software": true, + "typos-format@melodic-software": true, + "actionlint@melodic-software": true, + "eol-normalizer@melodic-software": true, + "guardrails@melodic-software": true, + "source-control@melodic-software": true, + "skill-quality@melodic-software": true + }, "worktree": { "baseRef": "head" } diff --git a/docs/CLOUD-SESSIONS.md b/docs/CLOUD-SESSIONS.md new file mode 100644 index 000000000..b8e97cd7b --- /dev/null +++ b/docs/CLOUD-SESSIONS.md @@ -0,0 +1,193 @@ +# Claude Code cloud sessions — concepts, setup guide, and this repo's setup + +A how-to for provisioning Claude Code on the web (cloud sessions): what the pieces are, how to +set them up for any account or repository, and how this repository is set up. Details +deliberately live in the linked official pages, not here — link freshness was verified on +2026-07-30, and per the [upstream-drift convention](conventions/upstream-drift/README.md) you +should re-fetch a page before acting on it. + +## What this is + +- [Claude Code on the web](https://code.claude.com/docs/en/claude-code-on-the-web) runs each + session in a fresh, isolated cloud VM with your repository cloned into it. +- Every session runs inside a + [cloud environment](https://code.claude.com/docs/en/cloud-environments) — the dialog with name, + network access, environment variables, and setup script. Environments are **scoped to your + claude.ai account** (or + [shared org-wide by an admin](https://code.claude.com/docs/en/cloud-environments#organization-shared-environments)), + **not to a repository**: one environment serves every repo and every surface that starts cloud + sessions (web, `claude --cloud`, mobile, desktop, routines). +- Two setup mechanisms exist, with an + [official division of labor](https://code.claude.com/docs/en/cloud-environments#setup-scripts-vs-sessionstart-hooks): + the environment's **setup script** provisions the VM itself (toolchains, CLI tools), while a + repo-committed **[SessionStart hook](https://code.claude.com/docs/en/hooks#sessionstart)** + handles project setup and runs in local and cloud sessions alike. +- [What carries over from your setup](https://code.claude.com/docs/en/cloud-environments#what-carries-over-from-your-setup) + is the key reference: repo-committed `.claude/` config reaches cloud sessions; user-level + `~/.claude` config never does. + +## Set up your own (any account, machine, or repo) + +### 1. Account level: the environment + +Usually nothing to do — onboarding creates a +[Default environment](https://code.claude.com/docs/en/cloud-environments#the-default-environment) +whose Trusted network level already reaches the +[default allowed domains](https://code.claude.com/docs/en/cloud-environments#default-allowed-domains) +(common package registries, GitHub, SchemaStore). Configure an environment only when you need +more, and keep it repo-agnostic, since it serves all repos: + +- [Create or edit environments](https://code.claude.com/docs/en/cloud-environments#configure-your-environment) + from the selector at claude.ai/code; pick a + [network access level](https://code.claude.com/docs/en/cloud-environments#access-levels) if + Trusted isn't right. +- [Environment variables](https://code.claude.com/docs/en/cloud-environments#set-environment-variables) + are readable by anyone who uses the environment and there is no secrets store — no credentials. +- A [setup script](https://code.claude.com/docs/en/cloud-environments#setup-scripts) is only for + tools missing from the + [pre-installed inventory](https://code.claude.com/docs/en/cloud-environments#installed-tools); + mind its [requirements](https://code.claude.com/docs/en/cloud-environments#script-requirements) + and [caching behavior](https://code.claude.com/docs/en/cloud-environments#environment-caching). + The docs' worked example installs the `gh` CLI, which pairs with the + [GitHub proxy](https://code.claude.com/docs/en/cloud-environments#github-proxy) for auth. +- CLI users pick their environment with + [`/remote-env`](https://code.claude.com/docs/en/cloud-environments#select-an-environment-from-the-cli). + +### 2. Repo level: a committed SessionStart hook + +Everything repo-specific goes in source control, following the docs' pattern in +[Install dependencies with a SessionStart hook](https://code.claude.com/docs/en/cloud-environments#install-dependencies-with-a-sessionstart-hook): + +- Register a `SessionStart` hook (matcher `startup|resume`) in the repo's + `.claude/settings.json`, pointing at a script in the repo via `$CLAUDE_PROJECT_DIR`. +- In the script, exit immediately unless `CLAUDE_CODE_REMOTE=true` so local machines are never + mutated, then install what the repo's own checks need. +- Design rules that matter in practice: make every step idempotent (hooks run on every startup + and resume — see the + [limitations list](https://code.claude.com/docs/en/cloud-environments#limitations-in-cloud-sessions)), + fail the session only for installs the session genuinely can't work without, and warn-and- + continue for the rest. Persist `PATH` or other variables by appending to `$CLAUDE_ENV_FILE`. +- Merge the hook to the default branch; from then on every cloud session on that repo picks it + up. In a cloud session you can also just ask Claude to create the hook — an Anthropic-provided + `session-start-hook` skill is preloaded there for exactly this. + +### Setup script vs SessionStart hook: decision criteria + +Where a given piece of setup belongs, per the +[official split](https://code.claude.com/docs/en/cloud-environments#setup-scripts-vs-sessionstart-hooks) +plus the cost model of +[environment caching](https://code.claude.com/docs/en/cloud-environments#environment-caching): + +- **Setup script** (environment dialog; cached): heavy, repo-agnostic, static installs — SDKs + (e.g. .NET, which the docs call out as setup-script material), `apt` packages, Docker image + pulls. Runs as root; its cost is paid once per cache rebuild (script/network-config edit, or + roughly-seven-day expiry), not per session. +- **SessionStart hook** (repo-committed; every session start and resume): anything driven by the + repo's own manifests or that must track branch state — dependency installs, pinned-tool + provisioning. Runs locally and in the cloud, so guard cloud-only work with + `CLAUDE_CODE_REMOTE` and make every step idempotent; the cost is paid per session. +- **Neither is for processes**: the cache keeps files, not running services. Start databases or + `docker compose` stacks per session (ask Claude, or start them from the hook). +- **Performance lever — cache the hook's work**: the setup script runs after the repository is + cloned, so a guarded line in the environment's setup script can run this repo's bootstrap and + bake its results into the cached snapshot, dropping per-session hook time to the idempotent + re-check (~3 s here): + + ```bash + [ -f .claude/hooks/session-start.sh ] && CLAUDE_CODE_REMOTE=true bash .claude/hooks/session-start.sh || true + ``` + + The guard keeps it a no-op for repositories without the script, so the environment stays + generic. (How the cache interacts with sessions across *different* repos isn't documented; + the idempotent hook makes either behavior safe.) + +### One environment or several? + +Start with one Default. Environments are account-scoped and repo-agnostic, so a single +Trusted-network environment serves every repository. Add a second, named environment only when a +class of work needs something incompatible or heavy enough to isolate — a big SDK whose cache +churn you want contained, or a +[custom domain allowlist](https://code.claude.com/docs/en/cloud-environments#allow-specific-domains). +A repo needing an uninstalled toolchain (the docs' example is the .NET SDK) means adding its +install to a setup script — extend Default, or create a dedicated environment and select it when +starting sessions on that repo; NuGet and dotnet.microsoft.com are already on the default +allowlist. + +## How this repository is set up + +The environment side stays generic (Default environment, Trusted network, no variables, at most +the optional `gh` setup-script one-liner from above). The repo side: + +- [`.claude/settings.json`](../.claude/settings.json) registers the `SessionStart` hook + (matcher `startup|resume`). +- [`.claude/hooks/session-start.sh`](../.claude/hooks/session-start.sh) is the bootstrap. Cloud + VMs only; ~40 s on a fresh VM, ~3 s on re-runs. It provisions the tool inventory + [`ci.yml`](../.github/workflows/ci.yml) pins, reading in-repo manifests wherever one exists: + +| Tool | Pin source | Required? | +|---|---|---| +| Node | `.node-version` (via the VM's nvm) | required — CI pins a major the VM image doesn't ship | +| claude CLI + Biome | root `package-lock.json` (`npm ci`) | required | +| ruff | `.github/requirements-ci.txt` (hash-locked) | required | +| shellcheck, actionlint, typos, editorconfig-checker, gitleaks | pinned in the hook (GitHub release binaries) | best effort — warns and continues | +| markdownlint-cli2, check-jsonschema | pinned in the hook (npm -g / uv tool) | best effort | +| full git history + `origin/main` | `git fetch` | best effort — the base-ref diff gates need it | + +Best-effort rather than required, deliberately: the plugin contract suites SKIP visibly when an +optional tool is absent and CI remains the enforcing gate, while a required install failure would +block the session from starting. GitHub release-asset downloads are additionally best-effort +because the [GitHub proxy](https://code.claude.com/docs/en/cloud-environments#github-proxy) +documents that release assets from repositories not attached to the session can return 403. + +Not installed at session start (install on demand when working in those areas): the four plugin +npm roots (`plugins/miro`, `plugins/knowledge/skills/youtube-digest/extraction`, +`plugins/knowledge/skills/course-digest/extraction`, +`plugins/ai-briefing/skills/generate/output/build`) and +`.github/standards/runner-policy` — each is an `npm ci` in that directory; the heavy ones pull +Playwright. `gh`, `pwsh`, and `lychee` are likewise on-demand. + +### Plugins in sessions on this repo + +Being the marketplace doesn't make this repo's plugins active in a session — plugins load only +when a marketplace is declared and plugins are enabled. `.claude/settings.json` does both, which +is the documented path for cloud sessions (see +[Discover and install plugins](https://code.claude.com/docs/en/discover-plugins) and +[extraKnownMarketplaces / enabledPlugins](https://code.claude.com/docs/en/settings#plugin-settings)): + +- `extraKnownMarketplaces` declares this repo as its own marketplace via a `directory` source + with a relative path, which + [resolves against the repository's checkout](https://code.claude.com/docs/en/plugin-marketplaces#relative-paths) + — cloud sessions install from the clone at session start; local collaborators are prompted + once they trust the folder. +- `enabledPlugins` turns on a deliberately lean default set, curated to mirror this repo's own + gates rather than everything the marketplace ships (every enabled plugin adds per-turn context + cost): the six format/lint-on-edit hooks (`markdown-format`, `bash-format`, `biome-format`, + `typos-format`, `actionlint`, `eol-normalizer`), plus `guardrails`, `source-control`, and + `skill-quality`. The session-start hook provisions every tool those hooks shell out to. +- Everything else stays on demand: `/plugin install @melodic-software` in any session. + +### GitHub MCP tools vs the gh CLI + +Both exist in cloud sessions and don't conflict — they serve different callers: + +- The **built-in GitHub MCP tools** are how the agent itself reads issues, PRs, and CI; they + authenticate through the + [GitHub proxy](https://code.claude.com/docs/en/cloud-environments#github-proxy) with no setup. +- The **`gh` CLI** is what this repo's plugin scripts and hooks shell out to (several + `source-control`, `guardrails`, and `work-items` suites SKIP without it). It isn't + pre-installed; the environment setup script installs it, and in cloud sessions it + [authenticates via the proxy automatically](https://code.claude.com/docs/en/cloud-environments#work-with-github-issues-and-pull-requests) + — no token needed. Locally, contributors authenticate `gh` themselves as usual. + +### Maintenance caveats + +- Some hook pin sources are materialized from `melodic-software/standards` (see + [`AGENTS.md`](../AGENTS.md)) — of the files the hook reads, `.node-version` is in the synced + set (verified against the `chore: sync standards components` history on 2026-07-30), so its + Node pin updates arrive via sync. `.claude/settings.json` and the hook script itself are + repo-owned. +- The hook's own version pins exist only because those tools have no in-repo manifest; the cloud + proxy blocks the GitHub API and `releases/latest` redirects, so the hook can't self-resolve + "latest". Each GitHub-release asset also carries a pinned SHA-256 the hook verifies before + installing (mismatch refuses the install and warns). Bump pin and hash together when the + corresponding configs bump.