diff --git a/.claude/commands/opsx/new.md b/.claude/commands/opsx/new.md index e51520532e..ae4eb62abc 100644 --- a/.claude/commands/opsx/new.md +++ b/.claude/commands/opsx/new.md @@ -31,14 +31,20 @@ Start a new change using the experimental artifact-driven approach. **Otherwise**: Omit `--schema` to use the default. -3. **Create the change directory** +3. **Create the change directory (inside the target package)** + + Specs/changes are PER-PACKAGE. First determine which package this change + belongs to (ask the user if unclear), then `cd` into it before running openspec. + NEVER create changes at the monorepo root — a guardrail hook blocks it. ```bash - openspec new change "" + cd packages// && openspec new change "" ``` Add `--schema ` only if the user requested a specific workflow. - This creates a scaffolded change at `openspec/changes//` with the selected schema. + This creates a scaffolded change at `packages///openspec/changes//` + with the selected schema. If the package has no `openspec/` yet, run + `openspec init` inside the package first. 4. **Show the artifact status** diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md index b2f708d219..5bb4e5e112 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -31,13 +31,19 @@ When ready to implement, run /opsx:apply **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** +2. **Create the change directory (inside the target package)** + + Specs/changes are PER-PACKAGE. First determine which package this change + belongs to (ask the user if unclear), then `cd` into it before running openspec. + NEVER create changes at the monorepo root — a guardrail hook blocks it. ```bash - openspec new change "" + cd PACKAGE_DIR && openspec new change "CHANGE_NAME" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change at `/openspec/changes//` + with `.openspec.yaml`. If the package has no `openspec/` yet, run + `openspec init` inside the package first. 3. **Get the artifact build order** diff --git a/.claude/hooks/validate-openspec-path.sh b/.claude/hooks/validate-openspec-path.sh new file mode 100755 index 0000000000..4c81d4a634 --- /dev/null +++ b/.claude/hooks/validate-openspec-path.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# validate-openspec-path.sh — Pre-execution hook (OpenSpec location guardrail) +# +# Claude Code invokes this hook before Bash / Write / Edit / MultiEdit tool calls. +# It enforces that OpenSpec specs and changes are created PER-PACKAGE, never at +# the monorepo root. OpenSpec resolves its `openspec/` directory relative to the +# current working directory, so the root `openspec/` must stay off-limits. +# +# Input: JSON on stdin {"tool_name":"...","tool_input":{...},"cwd":"..."} +# Output: exit 0 to allow, exit 2 with JSON {"error":"..."} to block. +# +# IMPORTANT: Do not modify, disable, or bypass this hook. + +set -euo pipefail + +INPUT="$(cat)" + +field() { + # $1 = dotted path into the JSON (e.g. tool_input.file_path) + printf '%s' "$INPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +cur = d +for k in '''$1'''.split('.'): + if isinstance(cur, dict): + cur = cur.get(k, '') + else: + cur = '' +print(cur if isinstance(cur, str) else '') +" 2>/dev/null || true +} + +TOOL_NAME="$(field tool_name)" + +# Resolve the repo root. Claude Code exports CLAUDE_PROJECT_DIR for hooks; fall +# back to the hook's reported cwd, then to the process cwd. +REPO_ROOT="${CLAUDE_PROJECT_DIR:-}" +if [[ -z "$REPO_ROOT" ]]; then + REPO_ROOT="$(field cwd)" +fi +if [[ -z "$REPO_ROOT" ]]; then + REPO_ROOT="$PWD" +fi +REPO_ROOT="${REPO_ROOT%/}" + +ROOT_OPENSPEC="$REPO_ROOT/openspec" + +block() { + printf '{"error":"BLOCKED by OpenSpec guardrail: %s Specs/changes are per-package. cd into the target package (e.g. packages/pluggableWidgets/) and run openspec there — never at the monorepo root."}\n' "$1" >&2 + exit 2 +} + +case "$TOOL_NAME" in + Write|Edit|MultiEdit) + FILE_PATH="$(field tool_input.file_path)" + [[ -z "$FILE_PATH" ]] && exit 0 + + # Normalize relative paths against the repo root. + case "$FILE_PATH" in + /*) ABS="$FILE_PATH" ;; + *) ABS="$REPO_ROOT/$FILE_PATH" ;; + esac + + # Block writes into the ROOT openspec changes/specs trees only. + case "$ABS" in + "$ROOT_OPENSPEC"/changes/*|"$ROOT_OPENSPEC"/specs/*) + block "cannot write '$FILE_PATH' under the root openspec/ directory." + ;; + esac + exit 0 + ;; + + Bash) + COMMAND="$(field tool_input.command)" + [[ -z "$COMMAND" ]] && exit 0 + + CMD_LOWER="$(printf '%s' "$COMMAND" | tr '[:upper:]' '[:lower:]')" + + # Only care about spec-creating openspec subcommands. + if [[ "$CMD_LOWER" == *"openspec new"* \ + || "$CMD_LOWER" == *"openspec init"* \ + || "$CMD_LOWER" == *"openspec archive"* ]]; then + # Allow it only when the command first cd's into a package directory. + if [[ "$CMD_LOWER" == *"cd "*"packages/"* ]]; then + exit 0 + fi + block "'openspec new/init/archive' must run inside a package." + fi + exit 0 + ;; + + *) + exit 0 + ;; +esac diff --git a/.claude/settings.json b/.claude/settings.json index 984f544626..1c66a954cb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,6 +31,17 @@ ] }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/validate-openspec-path.sh" + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", diff --git a/AGENTS.md b/AGENTS.md index 19c49c0194..85fabf3086 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,9 @@ Monorepo of official Mendix pluggable web widgets. pnpm workspaces + Turbo. ## Constraints - Never modify dist/, generated files, or lockfiles +- OpenSpec specs/changes are PER-PACKAGE — never create them at the monorepo root. + Always `cd packages//` before any `openspec new/init/archive`. The + root `openspec/` directory is off-limits (enforced by `.claude/hooks/validate-openspec-path.sh`). - XML property keys: lowerCamelCase, must match TypeScript props exactly - Don't override core Atlas UI classes - Prefer tree-shakable imports for new dependencies