Initial commit
This commit is contained in:
44
.claude/hooks/bash-guard.sh
Executable file
44
.claude/hooks/bash-guard.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 1 — blocks dangerous shell invocations the agent shouldn't run
|
||||
# autonomously. Reads PreToolUse JSON on stdin; exits 2 with stderr to block,
|
||||
# 0 to allow. Reinforces the Git Safety Protocol in CLAUDE.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
||||
|
||||
blocks=(
|
||||
'(^|[[:space:]])--no-verify([[:space:]]|$)'
|
||||
'(^|[[:space:]])--no-gpg-sign([[:space:]]|$)'
|
||||
'git[[:space:]]+push[[:space:]]+([^&|;]*[[:space:]])?(-f|--force)([[:space:]]|$)'
|
||||
'git[[:space:]]+reset[[:space:]]+[^&|;]*--hard'
|
||||
'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'
|
||||
'git[[:space:]]+checkout[[:space:]]+\.([[:space:]]|$)'
|
||||
'git[[:space:]]+restore[[:space:]]+\.([[:space:]]|$)'
|
||||
'git[[:space:]]+branch[[:space:]]+-D'
|
||||
'git[[:space:]]+commit[[:space:]]+[^&|;]*--amend'
|
||||
'rm[[:space:]]+-rf?[[:space:]]+/'
|
||||
'rm[[:space:]]+-rf?[[:space:]]+~'
|
||||
'rm[[:space:]]+-rf?[[:space:]]+\$HOME'
|
||||
)
|
||||
|
||||
for pattern in "${blocks[@]}"; do
|
||||
if [[ "$cmd" =~ $pattern ]]; then
|
||||
cat >&2 <<EOF
|
||||
BLOCKED by .claude/hooks/bash-guard.sh
|
||||
|
||||
This template forbids the agent from running this autonomously:
|
||||
Pattern: ${pattern}
|
||||
Command: ${cmd}
|
||||
|
||||
If the user has explicitly authorized this action this turn, ask them to
|
||||
run it themselves (\`! <command>\` in the prompt) or document the override
|
||||
in their request. See CLAUDE.md → "Executing actions with care" and the
|
||||
Git Safety Protocol section.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
43
.claude/hooks/generator-first-nudge.sh
Executable file
43
.claude/hooks/generator-first-nudge.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 1 — enforces the generator-first rule. Blocks hand-rolled scaffolding
|
||||
# under packages/ or apps/ via mkdir/cp/touch. Use `pnpm turbo gen <kind>`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
||||
|
||||
# Match creation of a NEW top-level packages/<name>/ or apps/<name>/ directory.
|
||||
# Allows working inside an existing package (e.g. `mkdir -p packages/blog/src/foo`).
|
||||
patterns=(
|
||||
'mkdir[[:space:]]+(-p[[:space:]]+)?packages/[a-zA-Z0-9_-]+/?([[:space:]]|$)'
|
||||
'mkdir[[:space:]]+(-p[[:space:]]+)?apps/[a-zA-Z0-9_-]+/?([[:space:]]|$)'
|
||||
'cp[[:space:]]+-[rR][[:space:]]+packages/[^[:space:]]+[[:space:]]+packages/[a-zA-Z0-9_-]+/?([[:space:]]|$)'
|
||||
)
|
||||
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if [[ "$cmd" =~ $pattern ]]; then
|
||||
cat >&2 <<EOF
|
||||
BLOCKED by .claude/hooks/generator-first-nudge.sh
|
||||
|
||||
This template enforces "generator-first" — hand-rolled scaffolding under
|
||||
packages/ or apps/ is forbidden. Use a generator:
|
||||
|
||||
pnpm turbo gen feature # new vertical feature
|
||||
pnpm turbo gen core-package <name> # optional core (events|realtime|audit|trpc|ui)
|
||||
pnpm turbo gen event # event contract or handler
|
||||
pnpm turbo gen job # background job
|
||||
pnpm turbo gen realtime # realtime channel or handler
|
||||
pnpm turbo gen core-ui-component # atomic-design UI component
|
||||
|
||||
If you're modifying an existing package (e.g. \`mkdir -p packages/blog/src/x\`)
|
||||
this hook will not block you. If you genuinely need to bypass (e.g. fixing
|
||||
the generator itself), ask the user to authorize and re-state the intent.
|
||||
|
||||
Command: ${cmd}
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
47
.claude/hooks/library-policy-nudge.sh
Executable file
47
.claude/hooks/library-policy-nudge.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Advisory — nudges the agent to run /evaluate-library before adding runtime
|
||||
# dependencies. Non-blocking (exit 0). Stdout is injected as system-reminder
|
||||
# context by the harness.
|
||||
#
|
||||
# Dispatches on payload shape:
|
||||
# .tool_input.command → PreToolUse / Bash (pnpm add / pnpm i <pkg>)
|
||||
# .tool_input.file_path → PostToolUse / Edit|Write (**/package.json edits)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
|
||||
# --- PreToolUse / Bash path ---
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
||||
if [[ -n "$cmd" ]]; then
|
||||
# Match: pnpm add <...> or pnpm i <pkg> — must have a space after keyword
|
||||
if [[ "$cmd" =~ (^|[[:space:]])pnpm[[:space:]]+(add[[:space:]]|i[[:space:]]) ]]; then
|
||||
# Skip dev-dependency installs — no policy evaluation needed for devDeps
|
||||
if [[ ! "$cmd" =~ (^|[[:space:]])(-D|--save-dev)([[:space:]]|$) ]]; then
|
||||
cat <<'EOF'
|
||||
[library-policy-nudge] Runtime dependency detected — evaluate before adding.
|
||||
|
||||
Run the evaluate-library skill first:
|
||||
/evaluate-library <name> --tier <feature|core|app> --target <package-path>
|
||||
|
||||
This ensures the dependency is logged in docs/decisions/ before the pre-commit gate fires.
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- PostToolUse / Edit|Write path ---
|
||||
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')
|
||||
if [[ "$file_path" == */package.json ]]; then
|
||||
cat <<'EOF'
|
||||
[library-policy-nudge] package.json edited — verify any new runtime dependencies are evaluated.
|
||||
|
||||
If you added a runtime dependency, run the evaluate-library skill:
|
||||
/evaluate-library <name> --tier <feature|core|app> --target <package-path>
|
||||
|
||||
This ensures the dependency is logged in docs/decisions/ before the pre-commit gate fires.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
73
.claude/hooks/library-policy-nudge.test.sh
Executable file
73
.claude/hooks/library-policy-nudge.test.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke tests for library-policy-nudge.sh
|
||||
# Usage: bash .claude/hooks/library-policy-nudge.test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/library-policy-nudge.sh"
|
||||
MARKER="/evaluate-library"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local name="$1"
|
||||
local input="$2"
|
||||
local output
|
||||
output=$(printf '%s' "$input" | bash "$SCRIPT" 2>/dev/null)
|
||||
if echo "$output" | grep -qF "$MARKER"; then
|
||||
echo " PASS: $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $name"
|
||||
echo " Expected stdout to contain: $MARKER"
|
||||
echo " Got: ${output:-<empty>}"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_output() {
|
||||
local name="$1"
|
||||
local input="$2"
|
||||
local output
|
||||
output=$(printf '%s' "$input" | bash "$SCRIPT" 2>/dev/null)
|
||||
if ! echo "$output" | grep -qF "$MARKER"; then
|
||||
echo " PASS: $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $name"
|
||||
echo " Expected no $MARKER in stdout, got: $output"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "library-policy-nudge.sh smoke tests"
|
||||
echo "------------------------------------"
|
||||
|
||||
# pnpm add <pkg> → reminder (runtime dep)
|
||||
assert_contains \
|
||||
"pnpm add foo triggers reminder" \
|
||||
'{"tool_input":{"command":"pnpm add foo"}}'
|
||||
|
||||
# pnpm add -D <pkg> → no reminder (dev dep)
|
||||
assert_no_output \
|
||||
"pnpm add -D foo produces no reminder" \
|
||||
'{"tool_input":{"command":"pnpm add -D foo"}}'
|
||||
|
||||
# pnpm add --save-dev <pkg> → no reminder (dev dep, long flag)
|
||||
assert_no_output \
|
||||
"pnpm add --save-dev foo produces no reminder" \
|
||||
'{"tool_input":{"command":"pnpm add --save-dev foo"}}'
|
||||
|
||||
# Edit on non-package.json → no reminder
|
||||
assert_no_output \
|
||||
"Edit on feature.manifest.ts produces no reminder" \
|
||||
'{"tool_input":{"file_path":"/workspace/packages/auth/src/feature.manifest.ts"}}'
|
||||
|
||||
# Edit on package.json → reminder
|
||||
assert_contains \
|
||||
"Edit on package.json triggers reminder" \
|
||||
'{"tool_input":{"file_path":"/workspace/packages/auth/package.json"}}'
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
31
.claude/hooks/post-manifest-edit.sh
Executable file
31
.claude/hooks/post-manifest-edit.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 3 — when a feature.manifest.ts is edited, remind the agent to surface
|
||||
# drift and follow manifest-first ordering. Non-blocking (stderr exit 0 is
|
||||
# visible in transcript; we don't want this to kill the agent's flow).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')
|
||||
|
||||
if [[ "$file_path" != *"feature.manifest.ts" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
feature=$(echo "$file_path" | sed -nE 's|.*packages/([^/]+)/src/feature\.manifest\.ts$|\1|p')
|
||||
|
||||
cat >&2 <<EOF
|
||||
[post-manifest-edit] feature.manifest.ts changed (${feature:-unknown feature})
|
||||
|
||||
Manifest-first ordering reminder:
|
||||
(1) manifest entry ← you just did this
|
||||
(2) contracts — xInputSchema, xOutputSchema, IXUseCase
|
||||
(3) tests (red) — colocated *.test.ts
|
||||
(4) implementation — use-case + controller + DI binding
|
||||
|
||||
Surface drift now:
|
||||
pnpm --filter @repo/${feature:-<feature>} test typecheck lint
|
||||
pnpm conformance
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
55
.claude/hooks/prompt-context.sh
Executable file
55
.claude/hooks/prompt-context.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 2 — injects relevant ADR + workflow pointers when the user's prompt
|
||||
# mentions concepts covered by an ADR or a hard ordering rule.
|
||||
# stdout is appended to the agent's context for this turn.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
prompt=$(printf '%s' "$input" | jq -r '.prompt // ""' | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
inject=()
|
||||
|
||||
if echo "$prompt" | grep -qE 'event|publish|consume|cross-feature|job queue'; then
|
||||
inject+=('Events/jobs: ADR-015 + docs/guides/events-and-jobs.md. Rules E0 (events for cross-feature only), E1 (handlers private), J0 (jobs for deferred work).')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'realtime|socket\.io|channel|broadcast|presence'; then
|
||||
inject+=('Realtime: ADR-016 + docs/guides/realtime.md. Rules R0 (state delivery only), R1 (handlers private), R2 (socket.io in core-realtime only).')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'audit|compliance|gdpr|dpa|erasure'; then
|
||||
inject+=('Audit: ADR-018 + docs/guides/audit-and-compliance.md. Optional core; scaffold with pnpm turbo gen core-package audit.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'sentry|otel|opentelemetry|tracing|instrumentation|pii|scrub'; then
|
||||
inject+=('Instrumentation: ADR-014 (interfaces) + ADR-017 (OTel migration). PII rules non-negotiable: sendDefaultPii=false, server-side scrub at OTel processor layer.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'use case|use-case|controller|repository|feature\.manifest|new feature|scaffold'; then
|
||||
inject+=('Manifest-first ordering: (1) manifest → (2) contracts (xInputSchema, xOutputSchema, IXUseCase) → (3) tests (red) → (4) impl (green). Use pnpm turbo gen feature/event/job/realtime — never hand-roll.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'prd|epic|story|task|sandcastle|dispatch|orchestrat'; then
|
||||
inject+=('Workflow: docs/architecture/agent-first-workflow-and-conformance.md + ADR-019. PRDs live in docs/work/prds/ — use the to-prd skill. Stress-test plans with grill-with-docs.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'di container|inject|bind-production|bind-dev-seed|symbols'; then
|
||||
inject+=('DI: ADR-008 (per-feature containers). Binders take ctx from core-shared/di. Use .toDynamicValue() for factory bindings. Tests inject mocks directly — no container rebinding.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'boundary|boundaries|cross-package|cross feature'; then
|
||||
inject+=('Boundaries: ADR-006 + ADR-010. Five tags (app|core|core-composition|feature|tooling). Features may only depend on core + tooling. Enforced by ESLint + Turborepo boundaries.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'coverage|uncovered|lcov|mutation|stryker|coverage band'; then
|
||||
inject+=('Coverage: ADR-020 + docs/guides/coverage.md (cookbook). 4 layers — L0 vitest thresholds, L1 pnpm coverage:diff (cover-the-diff), L2 coverage/summary.json (committed trend), L3 pnpm mutate (Stryker on entities + use-cases). Manifest-driven: feature.manifest.ts coverage.bands is the single source of truth.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'commit|message|changelog|conventional'; then
|
||||
inject+=('Conventional Commits (non-negotiable): <type>(<scope>): <imperative subject> (≤72 chars). Types: feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert. Use `!` for breaking changes. Body explains WHY if non-obvious. Examples: feat(auth): hash password before persisting; refactor(docs)!: consolidate scaffolding into guides. See CLAUDE.md Key Conventions.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'release|version|bump|semver|tag\b'; then
|
||||
inject+=('Releases: ADR-021 + docs/guides/releasing.md. Hybrid versioning — root template (template-v...) + 5 feature packages (auth-v..., blog-v..., etc.) version independently from 0.1.0. release-please reads Conventional Commits and opens a rolling release PR on every push to main; merging cuts per-package tags. Bump targeting is by commit-path, not (scope). Pre-1.0 policy: feat: -> patch, feat!: -> minor.')
|
||||
fi
|
||||
if echo "$prompt" | grep -qE 'refactor|deepening|shallow|architecture|seam|adapter|interface design|design it twice'; then
|
||||
inject+=('Architecture refactors: invoke the improve-codebase-architecture skill (.claude/skills/improve-codebase-architecture/SKILL.md). Vocabulary: module (= feature by default in this repo) / interface / seam / adapter / depth / leverage / locality. Process: Explore -> Present numbered candidates -> Grilling loop. Hard constraints: respect ADRs 001-021 (factory-function shape, per-feature DI, manifest-first, generator-first, boundary tags, vendor isolation). Companion files: DEEPENING.md (dependency categories), INTERFACE-DESIGN.md (parallel sub-agent design pattern), LANGUAGE.md (vocab + this-repo identifier mapping).')
|
||||
fi
|
||||
|
||||
if [ ${#inject[@]} -gt 0 ]; then
|
||||
echo "=== context-relevant pointers (from .claude/hooks/prompt-context.sh) ==="
|
||||
printf -- '- %s\n' "${inject[@]}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
17
.claude/hooks/session-start.sh
Executable file
17
.claude/hooks/session-start.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 2 — surfaces a fresh session's "where to look first" pointers.
|
||||
# Output on stdout is injected as additional context.
|
||||
|
||||
cat <<'EOF'
|
||||
=== template-vertical session pointers ===
|
||||
Canonical vocabulary: docs/glossary.md (resolve "what does X mean here?" first)
|
||||
Architecture: AGENTS.md, docs/architecture/overview.md, docs/architecture/agent-first-workflow-and-conformance.md
|
||||
Workflow: pnpm work status | pnpm work next | pnpm work dispatch (ADR-019)
|
||||
Generator-first: pnpm turbo gen <kind> beats hand-rolled scaffolding (non-negotiable)
|
||||
Conformance: pnpm conformance + pnpm fallow (5-gate drift detection)
|
||||
Conventional Commits (non-negotiable): <type>(<scope>): <subject> — see CLAUDE.md Key Conventions
|
||||
Releases: release-please reads commits + opens rolling release PR on merge to main (ADR-021)
|
||||
Skills: to-prd, grill-with-docs, grill-me, handoff, improve-codebase-architecture, evaluate-library (.claude/skills/)
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
47
.claude/hooks/stop-check-manifest-tests.sh
Executable file
47
.claude/hooks/stop-check-manifest-tests.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 3 — when the agent tries to stop, check whether feature.manifest.ts
|
||||
# changes have matching test changes. If manifest moved without tests,
|
||||
# nudge the agent to continue (exit 2 forces continuation).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
|
||||
# Loop guard — Claude Code sets stop_hook_active when a Stop hook already
|
||||
# forced continuation; don't loop infinitely.
|
||||
already_stopped=$(printf '%s' "$input" | jq -r '.stop_hook_active // false')
|
||||
if [ "$already_stopped" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run inside the repo
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
manifest_changed=$(git diff --name-only HEAD 2>/dev/null | grep -E 'feature\.manifest\.ts$' || true)
|
||||
|
||||
if [ -z "$manifest_changed" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tests_changed=$(git diff --name-only HEAD 2>/dev/null | grep -E '\.test\.(ts|tsx)$' || true)
|
||||
|
||||
if [ -n "$tests_changed" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
[stop-check] Manifest changes detected without matching test changes:
|
||||
|
||||
${manifest_changed}
|
||||
|
||||
Manifest-first ordering says: contracts + a red test must land before
|
||||
implementation. If you already shipped tests in a previous commit on this
|
||||
branch (and only the manifest changed this turn), say so and re-stop —
|
||||
this hook tracks unstaged + uncommitted-on-HEAD diffs only and can't tell.
|
||||
|
||||
Otherwise, write the sibling test file(s) for any new use case before stopping.
|
||||
EOF
|
||||
|
||||
exit 2
|
||||
72
.claude/settings.json
Normal file
72
.claude/settings.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/bash-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/generator-first-nudge.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/library-policy-nudge.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-manifest-edit.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/library-policy-nudge.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/prompt-context.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-check-manifest-tests.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
91
.claude/skills/evaluate-library/EXAMPLES/approved-example.md
Normal file
91
.claude/skills/evaluate-library/EXAMPLES/approved-example.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
package: clsx
|
||||
version: "^2.1.1"
|
||||
tier: feature
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [danijel, claude-sonnet-4-6]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- node -e "const p = JSON.parse(require('fs').readFileSync('./node_modules/clsx/package.json','utf8')); console.log(p.license)"
|
||||
- ls node_modules/clsx/dist/clsx.d.ts
|
||||
- npm view clsx time.modified
|
||||
- pnpm audit --audit-level=moderate
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
<!-- Result: MIT -->
|
||||
|
||||
`package.json` declares `"license": "MIT"`. MIT is on the allowlist. Pass.
|
||||
|
||||
## Filter: types
|
||||
|
||||
<!-- Result: native -->
|
||||
|
||||
`clsx` ships its own `.d.ts` declarations at `dist/clsx.d.ts`. No `@types/clsx` package needed.
|
||||
The TypeScript surface covers the full public API (`ClassValue`, overloads). Pass.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
<!-- Result: active -->
|
||||
|
||||
Last npm release: `2.1.1` published 2024-02-06 (15 months ago at evaluation date — within the 18-month threshold). GitHub shows open PR/issue activity within the last 3 months. The library is small, intentionally stable, and actively maintained. Pass.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
<!-- Result: pass -->
|
||||
|
||||
`clsx` is a pure string-concatenation utility. It imports nothing from Node.js or browser globals; it has zero transitive dependencies. Adding it to `packages/navigation` as a `feature`-tagged package introduces no boundary violations under ADR-006 or ADR-010. It does not import `@sentry/*`, `@opentelemetry/*`, or any core-reserved vendor SDK. Pass.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
<!-- Result: pass -->
|
||||
|
||||
The locked workspace must-haves are: `zod`, `inversify`, `payload`, `@trpc/server`, `superjson`, `reflect-metadata`. None of these perform CSS class-name composition. There is no existing utility in the workspace for this purpose. Pass.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
<!-- Result: n/a -->
|
||||
|
||||
`clsx` is a pure in-process string utility. It performs no network calls, transmits no user data, and has no SaaS endpoint. EU residency filter does not apply.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
<!-- Result: clean -->
|
||||
|
||||
`pnpm audit --audit-level=moderate` returns 0 vulnerabilities for `clsx@2.1.1` at evaluation time. No accepted advisories.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
<!-- Result: pass -->
|
||||
|
||||
Named consumer: `packages/navigation/src/ui/components/navigation-menu.tsx` — the `NavigationMenuLink` component must compute conditional class names for the active/inactive link state. Without `clsx` this is implemented as a ternary chain that becomes unreadable past three conditions. The component exists today; this is not a hypothetical future use case.
|
||||
|
||||
Secondary consumer: `packages/navigation/src/ui/components/mobile-nav.tsx` — open-state drawer overlay class computation. Both components are blocked on this adoption.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Replaces inline ternary chains like `` `base-class ${isActive ? 'active' : ''} ${isDisabled ? 'disabled' : ''}` ``. No library is being retired — this is a first-time adoption of a class-composition utility. No parallel adoption risk.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
**Mechanical.** `clsx` is called only at the component leaf level. Removal means replacing `clsx(...)` calls with equivalent template-literal ternaries — a mechanical sed-style refactor bounded to the `packages/navigation/src/ui/` subtree. No data format dependencies, no vendor lock-in, no protocol coupling.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
1. **`classnames`** — functional equivalent, MIT, widely used. Rejected in favour of `clsx` because `clsx` is the successor written by the same author with better TypeScript support and 2× faster benchmarks at comparable bundle size (330 B vs 440 B minzipped). `classnames` would also pass all eight filters; `clsx` is strictly preferable.
|
||||
|
||||
2. **Inline ternary chains (no library)** — the current approach. Adequate for one or two conditions; degrades rapidly past three. The `navigation-menu` component already has four conditional classes; this is the threshold where a utility library pays for itself. Rejected as the status quo.
|
||||
|
||||
3. **`tailwind-merge`** — superset of `clsx` that also de-duplicates conflicting Tailwind classes. Overkill for this use case (navigation components use a small, non-conflicting class set). Higher migration cost out (data-format dependency on Tailwind class semantics). Deferred.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
package: trpc-to-openapi
|
||||
version: "^1.2.0"
|
||||
tier: core
|
||||
decision: rejected
|
||||
date: 2026-05-14
|
||||
deciders: [danijel, claude-sonnet-4-6]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: fail
|
||||
verification-commands:
|
||||
- npm info trpc-to-openapi license
|
||||
- npm info trpc-to-openapi time.modified
|
||||
- pnpm audit --audit-level=moderate
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
<!-- Result: MIT -->
|
||||
|
||||
`package.json` declares `"license": "MIT"`. On the allowlist. Pass.
|
||||
|
||||
## Filter: types
|
||||
|
||||
<!-- Result: native -->
|
||||
|
||||
`trpc-to-openapi` ships TypeScript declarations (`.d.ts`) alongside the compiled output. Full API surface typed. Pass.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
<!-- Result: active -->
|
||||
|
||||
Last npm release: `1.2.0` published within the past 12 months at evaluation date. GitHub shows active issue triage. Pass.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
<!-- Result: pass -->
|
||||
|
||||
`trpc-to-openapi` would land in a `core`-tagged package alongside the tRPC router configuration. Core packages are permitted to hold tRPC-adjacent tooling. The library imports `@trpc/server` (already a workspace must-have) and standard `zod` types. No boundary violations under ADR-006 or ADR-010. Pass.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
<!-- Result: pass -->
|
||||
|
||||
No existing workspace library performs OpenAPI spec generation from tRPC routers. `trpc-to-openapi` does not duplicate any locked must-have. Pass.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
<!-- Result: n/a -->
|
||||
|
||||
`trpc-to-openapi` is a pure in-process code-generation utility. It produces an OpenAPI JSON spec at build time or request time; it transmits nothing to a vendor endpoint. EU residency filter does not apply.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
<!-- Result: clean -->
|
||||
|
||||
`pnpm audit --audit-level=moderate` returns 0 vulnerabilities at evaluation time. Pass.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
<!-- Result: fail -->
|
||||
|
||||
**No named consumer exists.**
|
||||
|
||||
The proposal arose during a 2026-05-14 grill session exploring whether to expose the tRPC router surface as a REST API for external consumers. The session established that **all current callers are TypeScript** and use `createCaller` directly — there are no HTTP REST clients calling the API, and no external consumers are blocked waiting for an OpenAPI spec.
|
||||
|
||||
The hypothetical consumers cited were:
|
||||
|
||||
- "External partners might want a REST API someday" — speculative; no partner is waiting.
|
||||
- "A mobile client might prefer REST over tRPC-HTTP" — hypothetical; no mobile client exists.
|
||||
- "OpenAPI docs improve DX for third-party integrations" — no third-party integration is in flight.
|
||||
|
||||
The grill-session question "who calls this code path today, or who is blocked waiting for it?" had the honest answer: nobody. The library would have shipped approximately 30 lines of `.meta({...})` annotations per router and a `superjson`-incompatible HTTP handler configuration in exchange for zero downstream consumers — pure carrying cost.
|
||||
|
||||
This trace exists as a permanent record per ADR-022 §4 so future agents do not re-evaluate `trpc-to-openapi` without first answering whether a named consumer now exists. If a concrete external integration is later planned, re-open this evaluation with the integration as the named consumer, re-run all eight filters, and write a new trace.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing is being retired. The adoption would have been additive alongside the existing `createCaller` usage path.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
**Hard.** Once `.meta({...})` annotations are added to tRPC procedures, they accumulate across routers over time. Removal requires stripping those annotations, deleting the OpenAPI spec generation step, and coordinating with any REST consumers that may have formed since adoption. The `superjson`-incompatible HTTP handler creates a parallel request path that would need to be decommissioned. Hard-rated because of the scattered annotation surface.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
1. **`@anatine/zod-nestjs` + NestJS** — full REST framework alternative; overkill for a tRPC-native repo and would require replacing the tRPC layer entirely. Not a serious alternative for this use case.
|
||||
|
||||
2. **Custom OpenAPI spec, hand-authored** — maintain a `openapi.yaml` alongside the tRPC router. Zero runtime cost; no dependency; the spec is always exactly what consumers need. Viable if a named consumer materialises and the schema surface is stable. The correct path when named-consumer passes.
|
||||
|
||||
3. **No action (status quo)** — current approach: TypeScript callers use `createCaller`; no REST surface exposed. Correct given that no named consumer exists today. This is the chosen outcome.
|
||||
110
.claude/skills/evaluate-library/POLICY.md
Normal file
110
.claude/skills/evaluate-library/POLICY.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Library Evaluation Policy — Quick Reference
|
||||
|
||||
> Authoritative source: `docs/decisions/adr-022-library-evaluation-policy.md`
|
||||
> Authoritative runbook: `.claude/skills/evaluate-library/SKILL.md`
|
||||
|
||||
---
|
||||
|
||||
## Why this policy exists
|
||||
|
||||
The repo ships with a deliberately narrow runtime surface (six deps per feature package). That discipline is uncodified. Three signals exposed the gap: a near-miss adding `trpc-to-openapi` for hypothetical REST consumers; three ADRs recording library choices _after_ adoption; and no EU-residency gate before a library could silently transmit user data to a US-only SaaS endpoint. ADR-022 codifies the discipline and makes it agent-runnable.
|
||||
|
||||
---
|
||||
|
||||
## Tier trigger
|
||||
|
||||
The policy applies to **direct runtime dependencies** in feature- and core-tier packages. Devdeps and app-tier deps are exempt.
|
||||
|
||||
| Where the dep lands | Process required | Companion record |
|
||||
| -------------------------- | ------------------------- | ---------------- |
|
||||
| `apps/<x>` | Author's call — no policy | — |
|
||||
| `packages/<feature>` | Trace required | — |
|
||||
| `packages/core-*` | Trace required | ADR required |
|
||||
| New optional-core category | Trace required | ADR required |
|
||||
|
||||
The trigger maps onto the existing ESLint `boundaries` tag system (ADR-006, ADR-010) — no new mental model.
|
||||
|
||||
---
|
||||
|
||||
## Eight hard auto-reject filters
|
||||
|
||||
**Phase 1 — cheap (always run to completion)**
|
||||
|
||||
| # | Filter | Auto-reject condition |
|
||||
| --- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **license** | Outside `MIT`, `Apache-2.0`, `BSD-*`, `ISC`, `MPL-2.0` |
|
||||
| 2 | **types** | No `.d.ts` and no `@types/<pkg>` |
|
||||
| 3 | **shadow-check** | Functional parallel to a locked must-have (`zod`, `inversify`, `payload`, `@trpc/server`, `superjson`, `reflect-metadata`) |
|
||||
| 4 | **boundary-fit** | Dep would violate ESLint boundary rules for the target tier (e.g., `@sentry/node` in a feature package — ADR-017 §4) |
|
||||
|
||||
**Phase 2 — expensive (short-circuit after first reject)**
|
||||
|
||||
| # | Filter | Auto-reject condition |
|
||||
| --- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 5 | **maintenance** | Last release ≥ 18 months OR activity gap ≥ 12 months (`abandoned`) |
|
||||
| 6 | **cve-scan** | Open advisory at `moderate` severity or above (via `pnpm audit`) |
|
||||
| 7 | **eu-residency** | Library transmits user data/telemetry to a vendor endpoint with no EU data region available or not configured |
|
||||
| 8 | **named-consumer** | No concrete call site exists today and no feature is blocked waiting for it — hypothetical future use does not qualify |
|
||||
|
||||
A single failure in any filter → `decision: rejected`. Cheap filters always run; expensive filters stop at the first fail.
|
||||
|
||||
---
|
||||
|
||||
## Three discussion prompts
|
||||
|
||||
Not auto-reject filters — any answer is acceptable with justification. Required in every trace.
|
||||
|
||||
1. **replaces** — What existing approach does this replace? Parallel adoption of the same capability is a smell.
|
||||
2. **migration-cost-out** — Rate the removal cost 18 months from now: mechanical / hard / impossible.
|
||||
3. **alternatives-considered** — Two named alternatives minimum. For core-tier, also duplicated into the companion ADR.
|
||||
|
||||
---
|
||||
|
||||
## Trace artifact
|
||||
|
||||
Every decision — approved or rejected — produces a file at `docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`.
|
||||
|
||||
**Required frontmatter fields:**
|
||||
|
||||
| Field | Values |
|
||||
| ------------------------------- | ------------------------------------------ |
|
||||
| `package` | npm package name |
|
||||
| `version` | semver range |
|
||||
| `tier` | `app` \| `feature` \| `core` |
|
||||
| `decision` | `approved` \| `rejected` |
|
||||
| `date` | `YYYY-MM-DD` |
|
||||
| `deciders` | list of authors (human and/or agent) |
|
||||
| `adr` | `adr-NNN` or `null` |
|
||||
| `filter-results.license` | SPDX id |
|
||||
| `filter-results.types` | `native` \| `@types/<x>` \| `none` |
|
||||
| `filter-results.maintenance` | `active` \| `dormant` \| `abandoned` |
|
||||
| `filter-results.boundary-fit` | `pass` \| `fail` |
|
||||
| `filter-results.shadow-check` | `pass` \| `fail` \| `"shadows <x>"` |
|
||||
| `filter-results.eu-residency` | `ok` \| `n/a` \| `self-hostable` \| `fail` |
|
||||
| `filter-results.cve-scan` | `clean` \| advisory ID \| `fail` |
|
||||
| `filter-results.named-consumer` | `pass` \| `fail` |
|
||||
| `verification-commands` | list of literal commands run |
|
||||
| `accepted-cves` | list of accepted advisory IDs (optional) |
|
||||
|
||||
Skipped expensive filters (short-circuited by an earlier reject) → write `skip` as the frontmatter value and note "Not evaluated" in the prose section.
|
||||
|
||||
The trace lands in **the same commit** as the `package.json` change. The pre-commit hook validates this for approved traces.
|
||||
|
||||
---
|
||||
|
||||
## Four-layer enforcement stack
|
||||
|
||||
| Layer | Latency | Catches |
|
||||
| ----------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
|
||||
| Claude `PreToolUse`/`PostToolUse` hook | inline | Agent skipping the skill before `pnpm add` or `package.json` edit |
|
||||
| `/evaluate-library` skill | seconds | The decision itself + writes the trace |
|
||||
| Git pre-commit hook (`scripts/library-decisions/check.mjs`) | pre-commit | Humans or agents bypassing the skill |
|
||||
| Sandcastle reviewer prompt | per-slice | Bypasses that slipped past pre-commit |
|
||||
|
||||
The Claude hook injects a `<system-reminder>` pointing to this skill. It is non-blocking — devdep additions and app-tier changes trigger the reminder but do not require a trace. The pre-commit hook is the deterministic gate.
|
||||
|
||||
---
|
||||
|
||||
## Composition with generators
|
||||
|
||||
`pnpm turbo gen core-package <name>` emits **pre-shipped traces** — one per direct runtime dep of the new core package — pre-marked `decision: approved` and citing the relevant ADR (ADR-015 for events, ADR-016 for realtime, ADR-018 for audit). No separate evaluation needed for scaffolded optional cores.
|
||||
308
.claude/skills/evaluate-library/SKILL.md
Normal file
308
.claude/skills/evaluate-library/SKILL.md
Normal file
@@ -0,0 +1,308 @@
|
||||
---
|
||||
name: evaluate-library
|
||||
description: Walk the 9-filter + 3-prompt library evaluation protocol for a named package, write the decision trace to docs/library-decisions/, and return pass/fail. Use when adding a runtime dependency to a feature or core package, or when the library-policy-nudge hook fires.
|
||||
---
|
||||
|
||||
<invocation>
|
||||
|
||||
```
|
||||
/evaluate-library <package-name> --tier <feature|core|app> --target <package-path>
|
||||
```
|
||||
|
||||
All three arguments are required. The `library-policy-nudge` hook emits this exact invocation. For `app`-tier packages, evaluation still runs but a trace is optional (author's call per ADR-022 §1).
|
||||
|
||||
</invocation>
|
||||
|
||||
<runbook>
|
||||
|
||||
## Overview
|
||||
|
||||
Walk nine hard auto-reject filters in **collect-cheap-skip-expensive** order, then answer three discussion prompts. Write the trace unconditionally at the end — including for rejections. A rejection trace is a permanent record that prevents future agents from re-litigating the same decision.
|
||||
|
||||
## Phase 1 — Cheap filters (always run to completion, even if one fails)
|
||||
|
||||
Run all four cheap filters regardless of their outcomes. Record each result before moving to Phase 2.
|
||||
|
||||
### Filter 1: license
|
||||
|
||||
Command: `node -e "const p = JSON.parse(require('fs').readFileSync('./node_modules/<pkg>/package.json','utf8')); console.log(p.license)"`
|
||||
|
||||
Allowlist: `MIT`, `Apache-2.0`, `BSD-2-Clause`, `BSD-3-Clause`, `ISC`, `MPL-2.0`.
|
||||
|
||||
Result values: the SPDX identifier (e.g. `MIT`) if allowed, or `<SPDX-id> (rejected)` if outside the allowlist. Anything outside the allowlist is an automatic reject but does not stop Phase 1.
|
||||
|
||||
### Filter 2: types
|
||||
|
||||
Check whether TypeScript types ship with the package or via `@types/<pkg>`:
|
||||
|
||||
```
|
||||
ls node_modules/<pkg>/index.d.ts 2>/dev/null && echo native || npm info @types/<pkg> version 2>/dev/null | head -1
|
||||
```
|
||||
|
||||
Result values: `native` (ships its own `.d.ts`), `@types/<pkg>` (community types available), or `none` (auto-reject — un-typed library shifts maintenance cost to the feature).
|
||||
|
||||
### Filter 3: shadow-check
|
||||
|
||||
Check whether this library duplicates a must-have already locked in the workspace. Locked must-haves: `zod` (validation), `inversify` (DI, ADR-002), `payload` (CMS), `@trpc/server` (API layer), `superjson` (serialisation), `reflect-metadata` (DI metadata).
|
||||
|
||||
Command: `cat package.json | grep -E '"(zod|inversify|payload|@trpc/server|superjson|reflect-metadata)"'` — run from the workspace root.
|
||||
|
||||
Result values: `pass` (no shadow), `fail` (exact duplicate of a locked dep), `"shadows <x>"` (functional parallel that would create two libraries doing the same job — auto-reject). A replacement must be a separate ADR with consequences analysis, not a parallel adoption.
|
||||
|
||||
### Filter 4: boundary-fit
|
||||
|
||||
Confirm the dependency does not violate ESLint boundary-tag rules for the target tier (ADR-006, ADR-010, ADR-017).
|
||||
|
||||
Key rules:
|
||||
|
||||
- Feature packages cannot import `@sentry/*` or `@opentelemetry/sdk-*` directly — those are reserved for core (ADR-017 §4).
|
||||
- No package may import across feature boundaries without going through the event bus or tRPC.
|
||||
- Optional core packages can only be imported by apps and `core-composition`-tagged packages.
|
||||
|
||||
Check by reviewing what the proposed library's transitive imports would bring in and whether any violate the boundary ruleset.
|
||||
|
||||
Result values: `pass` or `fail`.
|
||||
|
||||
---
|
||||
|
||||
After Phase 1: tally results. If **any cheap filter failed**, the overall decision is `rejected`. Proceed to Phase 2 anyway — all expensive filters still run if the Phase 1 decision is already rejected (they inform the full record). If all cheap filters passed, proceed to Phase 2 to determine the final decision.
|
||||
|
||||
## Phase 2 — Expensive filters (short-circuit after first reject)
|
||||
|
||||
Run in order. On the first failure, set remaining filter results to `skip` and skip to the [Trace write step](#trace-write-step).
|
||||
|
||||
### Filter 5: maintenance
|
||||
|
||||
Check last release date and recent PR/issue activity:
|
||||
|
||||
```
|
||||
npm info <pkg> time.modified
|
||||
npm info <pkg> time | tail -5
|
||||
```
|
||||
|
||||
Result values:
|
||||
|
||||
- `active` — last release < 18 months **and** PR/issue activity < 12 months
|
||||
- `dormant` — stable, not actively developed (acceptable for finished libraries like `reflect-metadata`)
|
||||
- `abandoned` — last release ≥ 18 months **or** no activity in ≥ 12 months → auto-reject; short-circuit remaining expensive filters
|
||||
|
||||
On `abandoned` → set `cve-scan`, `eu-residency`, `named-consumer`, `socketRisk` to `skip` → write trace.
|
||||
|
||||
### Filter 6: cve-scan
|
||||
|
||||
```
|
||||
pnpm audit --audit-level=moderate 2>&1 | head -40
|
||||
```
|
||||
|
||||
Result values: `clean` (no advisories), an advisory ID like `GHSA-xxxx-xxxx-xxxx` (accepted risk — document in `accepted-cves` frontmatter), or `fail` (open advisory not accepted → auto-reject; short-circuit remaining expensive filters).
|
||||
|
||||
On `fail` → set `eu-residency`, `named-consumer`, `socketRisk` to `skip` → write trace.
|
||||
|
||||
### Filter 7: eu-residency
|
||||
|
||||
Applies only if the library transmits user data, telemetry, business state, or secrets to a vendor-controlled endpoint by default. Examples: analytics SDKs, error-tracking clients, AI APIs, log aggregation services.
|
||||
|
||||
Exemptions (result: `n/a`): pure in-process libraries (no network calls), self-hostable software where the operator controls the endpoint, and build-time-only tools.
|
||||
|
||||
For non-exempt libraries: verify the vendor offers an EU data region AND that the integration in `target` is configured to use it.
|
||||
|
||||
Result values: `ok` (vendor offers EU region, integration configured), `n/a` (no data transmission), `self-hostable` (operator-controlled endpoint), `fail` → auto-reject; short-circuit `named-consumer`.
|
||||
|
||||
On `fail` → set `named-consumer`, `socketRisk` to `skip` → write trace.
|
||||
|
||||
### Filter 8: named-consumer
|
||||
|
||||
Answer: **Who calls this code path today, or who is blocked waiting for it?**
|
||||
|
||||
A named consumer is a concrete call site that exists now or a feature blocked on this capability today. "We might want this later", "external clients could use this", and "it would be nice to have" are not named consumers.
|
||||
|
||||
If the only possible callers are hypothetical or future → `fail` → set `socketRisk` to `skip` → auto-reject.
|
||||
|
||||
Result value: `pass` or `fail`.
|
||||
|
||||
### Filter 9: supply-chain behavior (Socket)
|
||||
|
||||
**Expensive — network call. Run last in Phase 2. Short-circuit: if any earlier Phase 2 filter already rejected the library, set `socketRisk` to `skip` and proceed to the [Trace write step](#trace-write-step).**
|
||||
|
||||
Verify the package's supply-chain health via `socket-cli`:
|
||||
|
||||
```
|
||||
npx socket-cli@latest scan . --json 2>&1
|
||||
```
|
||||
|
||||
This scans the current directory's lockfile for packages installed from the target under evaluation. For a targeted single-package check before installing:
|
||||
|
||||
```
|
||||
npx socket-cli@latest info <pkg>@<version> --json 2>&1
|
||||
```
|
||||
|
||||
The JSON output contains an array of findings, each with a `severity` field. Cross-reference with the repo-root `.socket.json` `issueRules` to determine the classification:
|
||||
|
||||
| Finding severity | `.socket.json` rule | `socketRisk` value |
|
||||
| ----------------------------------- | ------------------- | ------------------- |
|
||||
| No findings, or only `medium`/`low` | `ignore` | `clean` |
|
||||
| `high`-severity finding present | `warn` | `flagged` |
|
||||
| `critical`-severity finding present | `error` | `<finding-summary>` |
|
||||
|
||||
Where `<finding-summary>` is a concise label for the critical finding (e.g. `"new-author-on-publish"`, `"install-scripts-added"`, `"exfiltrates-env"`).
|
||||
|
||||
Set `filter-results.socketRisk` in the trace frontmatter to one of these three values.
|
||||
|
||||
Result values:
|
||||
|
||||
- `clean` — no meaningful supply-chain signals; proceed to Phase 3 prompts.
|
||||
- `flagged` — `high`-severity finding; document the specific signal in the trace body and decide whether to accept with justification. Not an auto-reject.
|
||||
- `<finding-summary>` — `critical`-severity finding; auto-reject. This is the last filter — no further filters to skip.
|
||||
|
||||
---
|
||||
|
||||
## Skip sentinel
|
||||
|
||||
When a filter is short-circuited (not evaluated), write `skip` for its frontmatter value. The Zod schema validates approved traces end-to-end; rejected/partial traces may carry `skip` in fields that would normally require an enum value. The pre-commit check only validates that approved traces exist for new deps — partial traces are informational records.
|
||||
|
||||
## Three discussion prompts
|
||||
|
||||
Answer all three in the trace, regardless of filter outcome. These are not auto-reject filters; any answer is acceptable with justification.
|
||||
|
||||
### Prompt: replaces
|
||||
|
||||
What existing library or approach does this replace? New-and-old running in parallel is a smell — name the thing being retired and the retirement plan, or explain why parallel adoption is intentional and time-bounded.
|
||||
|
||||
### Prompt: migration-cost-out
|
||||
|
||||
What does ripping this back out look like 18 months from now? Rate: **mechanical** (swap one package, update call sites), **hard** (scattered integration points, data-format dependencies), or **impossible** (vendor lock-in, protocol coupling). Higher cost raises the bar for adoption.
|
||||
|
||||
### Prompt: alternatives-considered
|
||||
|
||||
Name at least two alternatives evaluated before choosing this library. For `core`-tier adoptions, this section is also duplicated into the companion ADR. If no alternatives exist, explain why (e.g., the library is the de-facto standard with no viable substitutes).
|
||||
|
||||
---
|
||||
|
||||
## Sub-processor classification
|
||||
|
||||
Answer these two questions before writing the trace. The answers become
|
||||
top-level frontmatter fields required by ADR-022 §9.
|
||||
|
||||
### Question: is-sub-processor
|
||||
|
||||
**Does the vendor receive personal data on the operator's behalf?**
|
||||
|
||||
A library is a sub-processor when it transmits personal data (user identifiers,
|
||||
email addresses, behavioural events, request bodies, etc.) to a vendor-controlled
|
||||
endpoint — analytics SDKs, error-tracking clients, AI APIs, log aggregation
|
||||
services. Network calls alone do not make a library a sub-processor; only calls
|
||||
that carry personal data do.
|
||||
|
||||
Pure in-process libraries (no network calls), self-hostable software where the
|
||||
operator controls the endpoint, and build-time-only tools are **not** sub-processors.
|
||||
|
||||
Set `is-sub-processor: true | false`.
|
||||
|
||||
### Question: processes-pii
|
||||
|
||||
**Does the library process personal data in-process, even without transmitting
|
||||
it to a vendor?**
|
||||
|
||||
A library processes PII if it reads, validates, serialises, stores, or
|
||||
transforms data fields that may contain personal information (names, emails,
|
||||
IDs, content authored by users, authentication credentials). A self-hosted
|
||||
database or CMS is a prime example: no data leaves to a vendor, yet the
|
||||
library clearly handles PII.
|
||||
|
||||
Pure utility libraries (DI containers, type validators, serialisers operating
|
||||
on already-typed objects without inspecting field semantics, test runners)
|
||||
typically answer `false`.
|
||||
|
||||
Set `processes-pii: true | false`.
|
||||
|
||||
### Conditional block: when is-sub-processor is true
|
||||
|
||||
When `is-sub-processor: true`, five additional fields are **required** in the
|
||||
trace frontmatter. Gather them before writing the trace:
|
||||
|
||||
```
|
||||
data-sent: "<what personal data the library transmits to the vendor>"
|
||||
region: "<vendor data region, e.g. eu-west-1 or eu>"
|
||||
dpa-signed: true | false # has the operator signed a DPA with this vendor?
|
||||
sccs-required: true | false # does the vendor require SCCs (non-EEA transfer)?
|
||||
contact: "<vendor DPO or privacy contact email/URL>"
|
||||
```
|
||||
|
||||
If the vendor does not yet have a signed DPA or if you cannot determine the
|
||||
region, record `dpa-signed: false` / region as best-known and add a prose note
|
||||
under `## Sub-processor` in the trace body explaining the gap.
|
||||
|
||||
---
|
||||
|
||||
## Trace write step
|
||||
|
||||
Write the trace **unconditionally** at evaluation end — even for rejections, even for partial traces.
|
||||
|
||||
**Path:** `docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`
|
||||
|
||||
Use today's date. Use `docs/library-decisions/_template.md` as the structural guide.
|
||||
|
||||
Frontmatter rules:
|
||||
|
||||
- `decision: approved` only if all eight filters passed. Otherwise `decision: rejected`.
|
||||
- `adr: null` for feature-tier. For core-tier approvals, coordinate the ADR slug before writing (`adr: adr-NNN`).
|
||||
- `verification-commands` — include the literal commands run for each filter, one per line.
|
||||
- `accepted-cves: []` (empty unless you accepted a specific advisory).
|
||||
- `is-sub-processor` and `processes-pii` are **always required** (see Sub-processor classification above).
|
||||
- When `is-sub-processor: true`, include `data-sent`, `region`, `dpa-signed`, `sccs-required`, and `contact`.
|
||||
- For skipped expensive filters, write `skip` for the frontmatter value and omit the prose section body or note "Not evaluated — skipped due to earlier rejection."
|
||||
|
||||
Frontmatter template:
|
||||
|
||||
```yaml
|
||||
---
|
||||
package: <name>
|
||||
version: "<semver range>"
|
||||
tier: app | feature | core
|
||||
decision: approved | rejected
|
||||
date: <YYYY-MM-DD>
|
||||
deciders: [<author>, ...]
|
||||
adr: adr-NNN | null
|
||||
lastRevalidated: null
|
||||
is-sub-processor: false
|
||||
processes-pii: false
|
||||
# include the block below only when is-sub-processor: true
|
||||
# data-sent: "<description>"
|
||||
# region: "<eu | eu-west-1 | ...>"
|
||||
# dpa-signed: false
|
||||
# sccs-required: false
|
||||
# contact: "<url or email>"
|
||||
filter-results:
|
||||
license: <SPDX id>
|
||||
types: native | "@types/<x>" | none
|
||||
maintenance: active | dormant | abandoned
|
||||
boundary-fit: pass | fail
|
||||
shadow-check: pass | fail | "shadows <x>"
|
||||
eu-residency: ok | n/a | self-hostable | fail
|
||||
cve-scan: clean | "<advisory-id>" | fail
|
||||
named-consumer: pass | fail
|
||||
socketRisk: clean | flagged | <arbitrary-string>
|
||||
verification-commands:
|
||||
- <literal command that produced each filter result>
|
||||
accepted-cves: []
|
||||
---
|
||||
```
|
||||
|
||||
After writing the trace:
|
||||
|
||||
- For approved traces: confirm the trace is staged in the same commit as the `package.json` change. The pre-commit hook validates this.
|
||||
- For rejected traces: stage the trace file alone. Do not run `pnpm add <pkg>`.
|
||||
|
||||
</runbook>
|
||||
|
||||
<output-format>
|
||||
|
||||
After completing the evaluation, emit a one-paragraph summary:
|
||||
|
||||
```
|
||||
/evaluate-library result: <approved|rejected> — <package>@<version> (<tier>)
|
||||
Rejection filters (if any): <filter names>
|
||||
Trace written to: docs/library-decisions/<date>-<package>.md
|
||||
```
|
||||
|
||||
</output-format>
|
||||
167
.claude/skills/evaluate-library/TRACE-TEMPLATE.md
Normal file
167
.claude/skills/evaluate-library/TRACE-TEMPLATE.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Trace Template
|
||||
|
||||
Use this file as the structural guide when writing a library decision trace. Copy the frontmatter block and all 11 headings. Replace placeholder values with real results.
|
||||
|
||||
Trace path: `docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`
|
||||
|
||||
---
|
||||
|
||||
## Frontmatter — all filters evaluated
|
||||
|
||||
```markdown
|
||||
---
|
||||
package: <npm-package-name>
|
||||
version: "<semver range>"
|
||||
tier: app | feature | core
|
||||
decision: approved | rejected
|
||||
date: <YYYY-MM-DD>
|
||||
deciders: [<author>, ...]
|
||||
adr: adr-NNN | null
|
||||
filter-results:
|
||||
license: <SPDX id>
|
||||
types: native | "@types/<x>" | none
|
||||
maintenance: active | dormant | abandoned
|
||||
boundary-fit: pass | fail
|
||||
shadow-check: pass | fail | "shadows <x>"
|
||||
eu-residency: ok | n/a | self-hostable | fail
|
||||
cve-scan: clean | "<advisory-id>" | fail
|
||||
named-consumer: pass | fail
|
||||
socketRisk: clean | flagged | <finding-summary>
|
||||
verification-commands:
|
||||
- <literal command that produced the license result>
|
||||
- <literal command that confirmed types>
|
||||
- <literal command that checked maintenance>
|
||||
- <literal command that ran the CVE scan>
|
||||
accepted-cves: []
|
||||
---
|
||||
```
|
||||
|
||||
## Frontmatter — partial trace (expensive filters short-circuited)
|
||||
|
||||
When an expensive filter fails (Phase 2 short-circuit), set remaining filter fields to `skip`. The Zod schema validates approved traces end-to-end; `skip` is the accepted sentinel for unevaluated fields in rejected traces.
|
||||
|
||||
Example: `maintenance: abandoned` → `cve-scan`, `eu-residency`, `named-consumer` skipped.
|
||||
|
||||
```markdown
|
||||
---
|
||||
package: <npm-package-name>
|
||||
version: "<semver range>"
|
||||
tier: feature | core
|
||||
decision: rejected
|
||||
date: <YYYY-MM-DD>
|
||||
deciders: [<author>, ...]
|
||||
adr: null
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: abandoned
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: skip
|
||||
cve-scan: skip
|
||||
named-consumer: skip
|
||||
socketRisk: skip
|
||||
verification-commands:
|
||||
- npm view <pkg> time.modified
|
||||
accepted-cves: []
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required headings (11 total, in this order)
|
||||
|
||||
### Filter sections (8)
|
||||
|
||||
```markdown
|
||||
## Filter: license
|
||||
|
||||
<!-- Result: <SPDX id> -->
|
||||
|
||||
Record the SPDX identifier from `package.json` or `npx license-checker --packages <pkg>`.
|
||||
Allowed: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, MPL-2.0.
|
||||
Anything else → auto-reject (note the identifier and rejection reason).
|
||||
|
||||
## Filter: types
|
||||
|
||||
<!-- Result: native | @types/<x> | none -->
|
||||
|
||||
Confirm TypeScript types are available. `native` = ships its own `.d.ts`; `@types/<x>` = community
|
||||
types package exists and is current; `none` = no types → auto-reject.
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
<!-- Result: active | dormant | abandoned -->
|
||||
|
||||
Check last release date and recent PR/issue activity. `active` = last release < 18 months AND
|
||||
activity < 12 months. `dormant` = stable but not actively developed (acceptable for finished
|
||||
libraries). `abandoned` = auto-reject.
|
||||
If skipped (earlier expensive filter failed), write: "Not evaluated — skipped due to <filter> rejection."
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
<!-- Result: pass | fail -->
|
||||
|
||||
Confirm the dependency does not violate ESLint boundary-tag rules for the target tier
|
||||
(ADR-006, ADR-010, ADR-017). Name the specific rule checked and the result.
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
<!-- Result: pass | fail | "shadows <x>" -->
|
||||
|
||||
Check whether this library duplicates a must-have already locked in the workspace.
|
||||
Locked must-haves: zod, inversify, payload, @trpc/server, superjson, reflect-metadata.
|
||||
`shadows <x>` → auto-reject; a replacement requires a dedicated ADR.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
<!-- Result: ok | n/a | self-hostable | fail | skip -->
|
||||
|
||||
If the library transmits user data, telemetry, or business state to a vendor-controlled
|
||||
endpoint by default, the vendor must offer an EU data region and the integration must be
|
||||
configured to use it. Pure in-process libraries and build-time tools → `n/a`.
|
||||
If skipped, write: "Not evaluated — skipped due to <filter> rejection."
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
<!-- Result: clean | "<advisory-id>" | fail | skip -->
|
||||
|
||||
Run `pnpm audit --audit-level=moderate`. `clean` = no advisories at adoption time. Record
|
||||
accepted advisory IDs in the `accepted-cves` frontmatter field and explain the risk acceptance
|
||||
here. If skipped, write: "Not evaluated — skipped due to <filter> rejection."
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
<!-- Result: pass | fail | skip -->
|
||||
|
||||
Answer: "Who calls this code path today, or who is blocked waiting for it?"
|
||||
Hypothetical future callers are not consumers (ADR-022 §2.8 — the direct response to the
|
||||
2026-05-14 OpenAPI near-miss). If skipped, write: "Not evaluated — skipped due to <filter> rejection."
|
||||
```
|
||||
|
||||
### Prompt sections (3)
|
||||
|
||||
```markdown
|
||||
## Prompt: replaces
|
||||
|
||||
<!-- Required: answer in either direction with justification -->
|
||||
|
||||
What existing library or approach does this replace? New-and-old running in parallel is a smell.
|
||||
Name the thing being retired and its retirement plan, or explain why parallel adoption is
|
||||
intentional and time-bounded.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
<!-- Required: mechanical | hard | impossible + justification -->
|
||||
|
||||
What does ripping this back out look like 18 months from now? Rate: mechanical (swap package,
|
||||
update call sites), hard (scattered integration, data-format dependencies), or impossible
|
||||
(vendor lock-in, protocol coupling). Higher migration cost raises the adoption bar.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
<!-- Required: minimum two named alternatives, or "none with explanation" -->
|
||||
|
||||
Name at least two alternatives evaluated before choosing this library. For core-tier adoptions,
|
||||
this section is also duplicated into the companion ADR. If no alternatives exist, explain why.
|
||||
```
|
||||
18
.claude/skills/grill-me/SKILL.md
Normal file
18
.claude/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when the user wants to stress-test a plan, get grilled, or mentions "grill me". Use grill-with-docs instead when the plan should cross-check against ADRs + glossary + manifests.
|
||||
---
|
||||
|
||||
Interview the user relentlessly about every aspect of this plan until you reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions **one at a time**, waiting for feedback before continuing.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead. Useful shortcuts in this repo:
|
||||
|
||||
- `pnpm work status` — current epics and ready stories
|
||||
- `cat packages/<feature>/src/feature.manifest.ts` — declared use cases / events / audits
|
||||
- `ls docs/decisions/` — ADRs by number
|
||||
- `pnpm fallow` — dead exports, dupes, complexity hotspots
|
||||
- grep manifests across all features: `grep -r "publishes:" packages/*/src/feature.manifest.ts`
|
||||
|
||||
When grilling pulls in ADR / glossary / manifest cross-checks and you want to update those docs inline, switch to `grill-with-docs` instead.
|
||||
106
.claude/skills/grill-with-docs/SKILL.md
Normal file
106
.claude/skills/grill-with-docs/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: grill-with-docs
|
||||
description: Stress-test a plan against this repo's domain glossary, ADRs, conformance rules, and feature manifests. Update docs/glossary.md inline as terms crystallize; offer ADRs sparingly. Use when the user wants to harden a plan before it becomes a PRD.
|
||||
---
|
||||
|
||||
<what-to-do>
|
||||
|
||||
Interview the user relentlessly about every aspect of this plan until you reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions **one at a time**, waiting for feedback on each before continuing.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead of asking. The repo has fast feedback loops — run `pnpm work status`, grep manifests, read feature `feature.manifest.ts`, check ADRs. Speculation is a last resort.
|
||||
|
||||
</what-to-do>
|
||||
|
||||
<supporting-info>
|
||||
|
||||
## Repo doc map
|
||||
|
||||
This repo uses a **single context** with these doc locations:
|
||||
|
||||
```
|
||||
/
|
||||
├── docs/
|
||||
│ ├── glossary.md ← lazy-create when first term resolves
|
||||
│ ├── decisions/ ← ADRs (adr-NNN-<slug>.md, 3-digit zero-pad)
|
||||
│ │ ├── adr-001-monorepo-tool.md
|
||||
│ │ ├── adr-018-audit-and-compliance.md
|
||||
│ │ └── ...
|
||||
│ ├── architecture/ ← long-form specs + workflow design
|
||||
│ ├── work/ ← PRDs, epics, stories, tasks
|
||||
│ └── guides/ ← how-to runbooks
|
||||
└── packages/<feature>/src/feature.manifest.ts ← per-feature contract
|
||||
```
|
||||
|
||||
There is **no `CONTEXT.md` or `CONTEXT-MAP.md`** — this repo uses `docs/glossary.md` (create lazily) plus the per-feature `feature.manifest.ts` files for machine-readable domain shape. Don't create the multi-context layout (`CONTEXT-MAP.md`) unless this becomes a polyrepo.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary + manifests
|
||||
|
||||
When the user introduces a term that conflicts with `docs/glossary.md` (if it exists) or with a `feature.manifest.ts` entry, call it out immediately:
|
||||
|
||||
> "Your glossary defines `cancellation` as the act of voiding an unsent invoice, but you seem to mean the user-initiated subscription teardown — which is it?"
|
||||
|
||||
> "`auth.signIn` exists in `packages/auth/src/feature.manifest.ts` with that exact slug — are you adding a new use case or extending the existing one?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term:
|
||||
|
||||
> "You're saying `account` — do you mean a `User` (entity in `packages/auth`) or a Payload-collection record? Those are distinct."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent edge cases that force precision about boundaries between concepts. Lean on the existing feature set — auth, blog, media, marketing-pages, navigation — for grounding examples.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, verify it against the code. Look at:
|
||||
|
||||
- The feature's `feature.manifest.ts` for declared use cases, audits, publishes, consumes
|
||||
- `packages/<feature>/src/application/use-cases/` for the actual shape
|
||||
- `packages/<feature>/src/di/bind-production.ts` for what's wired
|
||||
- `docs/decisions/adr-NNN-*.md` for the decision history
|
||||
|
||||
If you find a contradiction, surface it:
|
||||
|
||||
> "You said cross-feature reactions happen through the bus, but `packages/auth/src/feature.manifest.ts` shows `publishes: []` — has this been wired yet?"
|
||||
|
||||
### Cross-reference with ADRs
|
||||
|
||||
Before recommending an approach, scan `docs/decisions/` for relevant ADRs. If your recommendation contradicts a current-status ADR, surface that explicitly:
|
||||
|
||||
> "You're proposing direct cross-feature imports, but `adr-006-vertical-feature-packages.md` plus rule R20 in the ESLint config forbid that — events (`core-events`) are the sanctioned path. Want to use events, or do you want to reopen the ADR?"
|
||||
|
||||
### Cross-reference with conformance rules
|
||||
|
||||
The conformance system (`docs/architecture/agent-first-workflow-and-conformance.md`) defines hard contracts:
|
||||
|
||||
- Every use case has a manifest entry → contracts → tests → impl (in that order)
|
||||
- TS brands (`Instrumented`, `Captured`, `Audited`) attached at DI bind time
|
||||
- ESLint rules enforce manifest ↔ code alignment
|
||||
- `pnpm conformance` enforces cross-feature event closure
|
||||
|
||||
If the plan would violate any of these, flag it.
|
||||
|
||||
### Update `docs/glossary.md` inline
|
||||
|
||||
When a term is resolved during the conversation, append it to `docs/glossary.md` right then — don't batch. Lazy-create the file when the first term is resolved. Use the format in [glossary-format.md](./glossary-format.md).
|
||||
|
||||
Only include terms meaningful to **this repo's domain** (template / monorepo / agent-first workflow / clean architecture / vertical features). Skip general programming concepts. Skip implementation details — those belong in code or ADRs.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **Result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any is missing, skip the ADR. The repo's ADRs follow a long-form `Context → Decision → Alternatives considered → Consequences → Related` shape (see `docs/decisions/adr-015-events-and-jobs.md` for a representative example). Number is next-highest in `docs/decisions/` zero-padded to 3 digits (`adr-020-...`, `adr-021-...`).
|
||||
|
||||
If the grill produced a major refactor decision rather than a new feature, lead the user to an ADR; if it produced a feature plan, lead to a PRD via `to-prd`.
|
||||
|
||||
</supporting-info>
|
||||
62
.claude/skills/grill-with-docs/glossary-format.md
Normal file
62
.claude/skills/grill-with-docs/glossary-format.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# docs/glossary.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# Glossary
|
||||
|
||||
Domain vocabulary for `template-vertical`. Terms specific to this repo — clean architecture, vertical features, agent workflow, conformance. General programming concepts don't belong here; implementation details belong in code or ADRs.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Feature**:
|
||||
A vertical slice owning its Clean Architecture layers (entities → application → infrastructure → DI → integrations).
|
||||
_Avoid_: module, domain, app.
|
||||
|
||||
**Use case**:
|
||||
A single business action exposed by a feature, implemented as a factory `(deps) => async (input) => output`. Each one has a manifest entry, a Zod input/output schema pair, a colocated test, and a controller.
|
||||
_Avoid_: command, action, handler.
|
||||
|
||||
**Manifest**:
|
||||
The `feature.manifest.ts` file that declares a feature's use cases, audits, publishes, consumes, and required core packages. Source of truth for conformance gates.
|
||||
|
||||
**Conformance**:
|
||||
The 5-gate enforcement system (TS brands → ESLint → boot assertion → `pnpm conformance` → fallow) that keeps manifest and code aligned.
|
||||
|
||||
## Workflow
|
||||
|
||||
**PRD**:
|
||||
The top-level requirements doc at `docs/work/prds/<date>-<slug>.prd.md` that seeds an epic.
|
||||
|
||||
**Epic**:
|
||||
A large body of work containing stories. Folder at `docs/work/epics/<epic-slug>/_epic.md`.
|
||||
|
||||
**Story**:
|
||||
One use case or technical capability. Folder under the epic, file `_story.md`.
|
||||
|
||||
**Task**:
|
||||
One vertical slice = one PR = one commit. File `<slug>.task.md` under the story folder.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **PRD** decomposes into one or more **Epics**
|
||||
- An **Epic** contains one or more **Stories**
|
||||
- A **Story** is implemented by one or more **Tasks**
|
||||
- A **Use case** is declared in a **Manifest** before it has code
|
||||
- **Conformance** asserts that the **Manifest** and the code agree
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- (none yet — append here when conflicts are resolved during grilling)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid.
|
||||
- **Flag conflicts explicitly.** When grilling surfaces ambiguity, capture both meanings under "Flagged ambiguities" with the resolution.
|
||||
- **Keep definitions tight.** One sentence max. Define what it IS, not what it does.
|
||||
- **Show relationships.** Use bold term names; express cardinality where obvious.
|
||||
- **Only domain terms specific to this repo.** General programming concepts (timeouts, retries, errors, DI) don't belong even if used heavily. Before adding, ask: is this concept unique to template-vertical, or generic?
|
||||
- **Group under subheadings** when natural clusters emerge (Architecture, Workflow, Instrumentation, etc.).
|
||||
|
||||
This repo is **single-context**: one `docs/glossary.md`, no `CONTEXT-MAP.md`. Don't switch to multi-context layout unless the repo splits.
|
||||
36
.claude/skills/handoff/SKILL.md
Normal file
36
.claude/skills/handoff/SKILL.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: handoff
|
||||
description: Compact the current conversation into a handoff document for another agent to pick up. Use when the user wants to transition work to a fresh session, switch worktrees, or hand off to a subagent.
|
||||
argument-hint: "What will the next session be used for?"
|
||||
---
|
||||
|
||||
Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save it to a path produced by `mktemp -t handoff-XXXXXX.md` (read the file before you write to it).
|
||||
|
||||
Suggest the skills the next session should use, if any. In this repo, the common follow-ups are:
|
||||
|
||||
- `grill-with-docs` — stress-test the plan before coding
|
||||
- `to-prd` — materialize the plan into `docs/work/prds/<date>-<slug>.prd.md`
|
||||
- `superpowers:writing-plans` — author the implementation plan
|
||||
- `superpowers:subagent-driven-development` — dispatch implementer + reviewer subagents per task
|
||||
|
||||
## Don't duplicate
|
||||
|
||||
Reference these artifacts by path or URL rather than inlining their content:
|
||||
|
||||
- PRDs (`docs/work/prds/*.prd.md`), epics (`docs/work/epics/<epic>/_epic.md`), stories (`_story.md`), tasks (`*.task.md`)
|
||||
- ADRs (`docs/decisions/adr-NNN-*.md`)
|
||||
- AGENTS.md and CLAUDE.md (the next agent loads these automatically)
|
||||
- `_state.json` (orchestrator-derived; the next agent regenerates it from markdown via `pnpm work rebuild-state`)
|
||||
- Commit messages, diffs, PR descriptions — link the SHA / PR number
|
||||
- Existing plans under `docs/superpowers/plans/`
|
||||
|
||||
## Do capture
|
||||
|
||||
- The active **goal** in one sentence
|
||||
- **In-flight branch / worktree** and any uncommitted state (e.g. `git status` summary, dangling commits)
|
||||
- **Decisions made in conversation** that haven't yet landed in a PRD or ADR
|
||||
- **Blockers** and proposed next steps
|
||||
- **Skills to invoke first** in the next session
|
||||
- If the user passed arguments, treat them as the next session's focus and tailor the doc accordingly
|
||||
|
||||
Keep the document short — it's a baton, not a thesis.
|
||||
98
.claude/skills/improve-codebase-architecture/DEEPENING.md
Normal file
98
.claude/skills/improve-codebase-architecture/DEEPENING.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Deepening
|
||||
|
||||
How to deepen a cluster of shallow modules in this repo, given its dependencies. **In this repo "module" defaults to "feature"** — most deepenings operate on or within a feature (`packages/<name>/`). Narrower scopes (use case, controller, repository) follow the same dependency-category logic. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
|
||||
|
||||
## Dependency categories
|
||||
|
||||
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
|
||||
|
||||
### 1. In-process
|
||||
|
||||
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
|
||||
|
||||
**Where this lives in our repo:**
|
||||
|
||||
- `entities/models/**` — Zod schemas + types + pure helpers
|
||||
- `entities/errors/**` — domain error classes
|
||||
- `application/use-cases/**` — pure orchestration (factories take ports as deps)
|
||||
- `interface-adapters/controllers/**` + their colocated `presenter` functions
|
||||
- Pure helpers in `core-shared/conformance/`, `core-shared/instrumentation/` (the non-OTel parts)
|
||||
|
||||
For category 1 modules: **merge, then test the result through its interface**. No mocks. The test surface IS the new interface.
|
||||
|
||||
### 2. Local-substitutable
|
||||
|
||||
Dependencies that have local test stand-ins. **In this repo every infrastructure port already has both a real implementation and a mock side-by-side** — the mock IS the test stand-in.
|
||||
|
||||
**Pattern (from ADR-012):**
|
||||
|
||||
- `<x>.repository.interface.ts` — the port (seam)
|
||||
- `<x>.repository.ts` — Payload-backed adapter (real)
|
||||
- `<x>.repository.mock.ts` — in-memory adapter (test stand-in)
|
||||
|
||||
The deepened module is tested with the `.mock.ts` adapter injected directly via the factory function. No container rebinding (ADR-012).
|
||||
|
||||
**Common category-2 ports in this repo:**
|
||||
|
||||
- `IUsersRepository`, `IArticlesRepository`, `IMediaRepository`, etc. → Mock + Payload adapters
|
||||
- `IAuthenticationService` → Mock + real adapter
|
||||
- `IJobQueue` (from `core-shared/jobs/`) → `InMemoryJobQueue` + `PayloadJobQueue`
|
||||
- `IEventBus` (from `core-events/`) → `InMemoryEventBus` + `PayloadJobsEventBus`
|
||||
- `ITracer`, `ILogger`, `IMetrics` (from `core-shared/instrumentation/`) → `Noop*` + `Otel*` + `Recording*` (the third one lives in `core-testing` for assertions)
|
||||
|
||||
If the deepening touches a category-2 port, the recommendation shape is: _"Merge X into Y, keep the port boundary at the existing `<x>.repository.interface.ts`; both adapters survive unchanged."_
|
||||
|
||||
### 3. Remote but owned
|
||||
|
||||
Our own services across a network boundary. **In this repo, cross-feature communication is already this pattern via the event bus (ADR-015).**
|
||||
|
||||
The **port** is `IEventBus.publish(descriptor, payload)` + `IEventBus.subscribe(descriptor, consumerFeature, handler)`. Adapters:
|
||||
|
||||
- `InMemoryEventBus` (test + dev-seed) — synchronous fan-out
|
||||
- `PayloadJobsEventBus` (production) — Payload tasks fan-out durably across the network if features are split into separate deploys
|
||||
|
||||
If a deepening proposal would introduce a NEW cross-feature seam, the answer is almost always "use the event bus" — don't invent a new transport. Rule E0 forbids in-feature use of the bus (in-feature reactions are direct use-case calls); E1 keeps consumer handlers private.
|
||||
|
||||
If the deepening proposal would EXPOSE one feature's internals to another, that's a boundary violation — reject and suggest events instead.
|
||||
|
||||
### 4. True external
|
||||
|
||||
Third-party services we don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
|
||||
|
||||
**Where this lives in our repo:**
|
||||
|
||||
- Payload CMS itself — features depend on `IXRepository`, not on `payload` directly. The real adapter (`<x>.repository.ts`) is the only place Payload is touched.
|
||||
- Sentry / OpenTelemetry exporters — features use `ITracer`/`ILogger`/`IMetrics` from `core-shared/instrumentation/`. The OTel SDK lives ONLY in `core-shared/instrumentation/otel/` (ESLint-enforced via rule R52).
|
||||
- Socket.IO — features use `IRealtimeBroadcaster`; `socket.io` itself lives only in `@repo/core-realtime` (rule R2).
|
||||
|
||||
If a deepening proposal would import a vendor SDK from a feature package, that's an ADR-014/ADR-016/ADR-017 violation — reject and route through the existing port.
|
||||
|
||||
## Seam discipline
|
||||
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). Single-adapter ports in this repo are usually a smell — check if the mock is missing or if the port itself is unnecessary.
|
||||
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
|
||||
- **The DI symbol is the seam contract.** `*_SYMBOLS.IXRepository` plus `<x>.repository.interface.ts` together define what callers depend on. Adapter swaps happen at bind time in `bind-production.ts` / `bind-dev-seed.ts`.
|
||||
- **`feature.manifest.ts` is the structural-conformance seam.** If a deepening moves use cases across features, the manifests of BOTH features change — the conformance ESLint rules + `assertFeatureConformance` boot check enforce that the move is reflected in declarations, not just code.
|
||||
|
||||
## Testing strategy: replace, don't layer
|
||||
|
||||
- **Old unit tests on shallow modules become waste** once tests at the deepened module's interface exist — delete them. Our coverage thresholds (ADR-020) reward this: collapsing N shallow modules + their N test files into one deep module + one test file at its interface keeps coverage 100% without test bloat.
|
||||
- **Write new tests at the deepened module's interface.** The **interface is the test surface**.
|
||||
- **Tests assert on observable outcomes through the interface**, not internal state.
|
||||
- **Tests should survive internal refactors** — if a test has to change when the implementation changes, it's testing past the interface.
|
||||
- **L1 diff coverage (`pnpm coverage:diff`) will surface uncovered lines after the refactor** — every deepened module needs its new tests to cover the merged behaviour before the refactor PR is mergeable.
|
||||
|
||||
## Conformance check (run before claiming the deepening is complete)
|
||||
|
||||
After deepening, the following must all stay green:
|
||||
|
||||
```
|
||||
pnpm typecheck # TS brand-slot enforcement
|
||||
pnpm lint # ESLint conformance + boundaries
|
||||
pnpm test --filter @repo/<feature> -- --coverage # per-layer L0 thresholds
|
||||
pnpm conformance # cross-feature event closure
|
||||
pnpm fallow:audit # whole-codebase audit + dead-export sweep
|
||||
pnpm coverage:diff -- --base origin/main # cover-the-diff (ADR-020 L1)
|
||||
```
|
||||
|
||||
If `pnpm dev` was running, it should still boot — `assertFeatureConformance` will fail loudly on brand-slot or manifest drift if the deepening forgot a binding update.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Interface Design
|
||||
|
||||
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
|
||||
|
||||
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module** (= **feature** by default in this repo), **interface**, **seam**, **adapter**, **leverage**.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Frame the problem space
|
||||
|
||||
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
|
||||
|
||||
- The constraints any new interface would need to satisfy
|
||||
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
|
||||
- The **hard constraints from SKILL.md** that the new interface must respect (factory-function shape, per-feature DI, manifest-first, generator-first, brand wrappers, etc.)
|
||||
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
|
||||
|
||||
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
|
||||
|
||||
### 2. Spawn sub-agents
|
||||
|
||||
Spawn 3+ sub-agents in parallel using the `Agent` tool (`subagent_type=general-purpose` or a more specific type if appropriate). Each must produce a **radically different** interface for the deepened module.
|
||||
|
||||
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
|
||||
|
||||
- **Agent 1**: "Minimise the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
|
||||
- **Agent 2**: "Maximise flexibility — support many use cases and extension."
|
||||
- **Agent 3**: "Optimise for the most common caller — make the default case trivial."
|
||||
- **Agent 4 (if applicable)**: "Design around ports & adapters for cross-seam dependencies."
|
||||
|
||||
**Every brief MUST also include:**
|
||||
|
||||
- This repo's vocabulary from [`docs/glossary.md`](../../../docs/glossary.md) (use case, manifest, feature, slice, etc.)
|
||||
- The architecture vocabulary from [LANGUAGE.md](LANGUAGE.md)
|
||||
- The hard constraints from [SKILL.md](SKILL.md) — sub-agents must not propose interfaces that violate ADR-006 (boundaries), ADR-008 (per-feature DI), ADR-012 (factory shape, one controller per use case), ADR-013 (schemas in use-case file), ADR-014/017 (vendor isolation), ADR-015 (events for cross-feature), ADR-020 (manifest-driven coverage bands), or ADR-021 (versioning by commit-path).
|
||||
- The relevant feature `feature.manifest.ts` shape so the proposed interface aligns with manifest-first ordering.
|
||||
|
||||
Each sub-agent outputs:
|
||||
|
||||
1. **Interface** (types, methods, params — plus invariants, ordering, error modes, schemas if applicable)
|
||||
2. **Usage example** showing how callers in this repo would use it (use real file paths and real existing feature names)
|
||||
3. **What the implementation hides** behind the seam
|
||||
4. **Dependency strategy and adapters** (see [DEEPENING.md](DEEPENING.md)) — which existing ports/adapters get reused, which (if any) are new
|
||||
5. **Manifest + binder impact** — which `feature.manifest.ts` entries and which `bind-production.ts` / `bind-dev-seed.ts` files change
|
||||
6. **Trade-offs** — where leverage is high, where it's thin
|
||||
7. **ADR conflicts (if any)** — call out by ADR number with rationale, or state "none"
|
||||
|
||||
### 3. Present and compare
|
||||
|
||||
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by:
|
||||
|
||||
- **Depth** (leverage at the interface)
|
||||
- **Locality** (where change concentrates)
|
||||
- **Seam placement** (which existing `*.interface.ts` survives, which gets replaced, which is new)
|
||||
- **Conformance impact** (how many manifests change, how many binders change, how many tests rewrite)
|
||||
- **Coverage delta** (cumulative L0 band impact — does any layer drop below its declared 100% / 95%?)
|
||||
|
||||
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
|
||||
|
||||
If the chosen design crosses a feature-package boundary (e.g. moves a use case from `@repo/blog` to `@repo/media`), state explicitly:
|
||||
|
||||
- Which `feature.manifest.ts` files lose / gain entries
|
||||
- Which package versions will bump on the next release-please PR (per ADR-021 commit-path bump targeting)
|
||||
- Whether the migration needs an intermediate compatibility seam to keep `pnpm conformance` green during the transition
|
||||
|
||||
The implementation lands via the manifest-first ordering: (1) update the manifests in both packages, (2) write the new contracts in the use-case file, (3) write the failing tests, (4) implement until green. Don't skip the order even when the move feels mechanical.
|
||||
78
.claude/skills/improve-codebase-architecture/LANGUAGE.md
Normal file
78
.claude/skills/improve-codebase-architecture/LANGUAGE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Language
|
||||
|
||||
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service" (we use that narrowly for DI ports), "API," or "boundary" (overloaded with our workspace-tag enforcement). Consistent language is the whole point.
|
||||
|
||||
This vocabulary is foundational for the skill's reasoning. The project's domain vocabulary lives in [`docs/glossary.md`](../../../docs/glossary.md) — terms like _use case_, _manifest_, _slice_, _binder_, _brand_, _conformance band_, _coverage layer_. Both vocabularies are in scope when proposing deepenings; see the "Mapping to this repo's identifiers" section below for how the abstract terms here land on concrete file shapes.
|
||||
|
||||
## Terms
|
||||
|
||||
**Module** — **in this repo, "module" defaults to "feature"** (`packages/<name>/`). The abstract definition (anything with an interface + implementation) still applies at narrower scales — a use case, controller, repository/service port, or binder can also be a module — but **whenever the refactor scope is "the whole thing", say feature**. Reach for "module" only when the abstraction across scales actually matters (e.g., comparing how a use case's depth differs from its containing feature's depth).
|
||||
_Avoid_: unit, component, service (we use "service" for DI ports specifically).
|
||||
|
||||
**Interface**
|
||||
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, performance characteristics, **manifest declarations**, and **DI symbol contract**.
|
||||
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
|
||||
|
||||
**Implementation**
|
||||
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Payload-backed repository) or a large adapter with a small implementation (an in-memory mock). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
|
||||
|
||||
**Depth**
|
||||
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
|
||||
|
||||
**Seam** _(from Michael Feathers)_
|
||||
A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
|
||||
_Avoid_: boundary (this repo uses "boundary" specifically for ESLint workspace-tag rules — keep it for that meaning).
|
||||
|
||||
**Adapter**
|
||||
A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). In this repo every port typically has at least two adapters (real + mock); some have three (real + mock + recording).
|
||||
|
||||
**Leverage**
|
||||
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
|
||||
|
||||
**Locality**
|
||||
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
|
||||
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
|
||||
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape.
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. In this repo, the typical justification is "one real adapter + one mock for tests" — that's two.
|
||||
- **The manifest is a structural seam.** A feature's `feature.manifest.ts` declares its use cases / events / jobs / channels / required cores / coverage bands. Refactors that move behaviour between features MUST move manifest entries too; the conformance gates enforce this.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
|
||||
- **Depth** is a property of a **Module**, measured against its **Interface**.
|
||||
- A **Seam** is where a **Module**'s **Interface** lives.
|
||||
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
|
||||
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
|
||||
|
||||
## Mapping to this repo's identifiers
|
||||
|
||||
Abstract → concrete translation table. When proposing a deepening, name things using the right column.
|
||||
|
||||
| Abstract term | Where it lands in this repo |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Module** | **Primarily a feature** (`packages/<name>/`) — that's the canonical refactor scope. Also: a use case (`*.use-case.ts`), controller (`*.controller.ts`), repository port + adapters (`*.repository.{interface,mock,}.ts`), service port + adapters (`*.service.{interface,mock,}.ts`), binder (`bind-production.ts` / `bind-dev-seed.ts`), manifest (`feature.manifest.ts`), or a core package (`packages/core-<name>/`) — when the refactor operates at those narrower scales. When in doubt, say "feature". |
|
||||
| **Interface** | The exported types from a module file: `IXUseCase = ReturnType<typeof xUseCase>`, `IXController`, the `<x>.repository.interface.ts` shape, the manifest's declared keys, the Zod input/output schemas, the DI symbol contract. |
|
||||
| **Implementation** | The factory body, the adapter class body, what the binder wires. |
|
||||
| **Seam** | `<x>.repository.interface.ts`, `<x>.service.interface.ts`, the DI symbol (`*_SYMBOLS.IXRepository`), the manifest entry, a `// <gen:*>` anchor, the protocol types in `core-shared/di/bind-protocols.ts`. |
|
||||
| **Adapter** | `<x>.repository.ts` (Payload real) ↔ `<x>.repository.mock.ts` (in-memory). For instrumentation: `Noop*` ↔ `Otel*` ↔ `Recording*` (test). For bus: `InMemoryEventBus` ↔ `PayloadJobsEventBus`. |
|
||||
| **Test stand-in** | The `.mock.ts` adapter (constructed directly + injected into the factory). No container rebinding (ADR-012). |
|
||||
|
||||
## Rejected framings
|
||||
|
||||
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout's original metric): rewards padding the implementation. We use depth-as-leverage instead.
|
||||
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know, including manifest entries and DI symbols.
|
||||
- **"Boundary"** as a synonym for **seam**: this repo uses "boundary" specifically for ESLint workspace-tag rules (`feature` may depend on `core` + `tooling` only). Keep that meaning intact; say **seam** or **interface** when discussing features.
|
||||
- **"Service" as a generic term**: in this repo, **service** = a DI-injected port for non-collection capabilities (`IAuthenticationService`, `IMailerService`). Not a generic stand-in for "feature" or "the module doing the work."
|
||||
- **"Module" as the canonical noun**: avoid in everyday discourse — say **feature** (or **use case** / **controller** / **package** when narrower). "Module" is the abstract refactor vocabulary's word for the same thing, useful only when the abstraction across scales is the point.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`docs/glossary.md`](../../../docs/glossary.md) — project domain vocabulary (use case, manifest, slice, brand, coverage band, etc.)
|
||||
- [`docs/decisions/`](../../../docs/decisions/) — 21 ADRs that constrain the design space
|
||||
- [SKILL.md](SKILL.md) — the skill's process + hard constraints
|
||||
- [DEEPENING.md](DEEPENING.md) — dependency categories + seam discipline
|
||||
- [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md) — parallel sub-agent design exploration
|
||||
104
.claude/skills/improve-codebase-architecture/SKILL.md
Normal file
104
.claude/skills/improve-codebase-architecture/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: improve-codebase-architecture
|
||||
description: Find deepening opportunities in this repo, informed by docs/glossary.md and the ADRs in docs/decisions/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make the codebase more testable and AI-navigable. Respects the conformance system, boundary rules, and the 21 ADRs that govern shape.
|
||||
---
|
||||
|
||||
# Improve Codebase Architecture
|
||||
|
||||
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability, scoped to what this template's existing rules permit.
|
||||
|
||||
## Glossary
|
||||
|
||||
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service" (we use that for DI ports specifically), or "boundary" (we use that for ESLint workspace-tag rules). Full definitions in [LANGUAGE.md](LANGUAGE.md).
|
||||
|
||||
- **Module** — **in this repo defaults to "feature"** (`packages/<name>/`). The abstract definition (anything with interface + implementation) still applies at narrower scales — a use case, controller, repository/service port, binder, or core package can also be a module — but say "feature" whenever that's the scope. Reach for "module" only when comparing depth/leverage across scales.
|
||||
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, schemas, DI shape. Not just the TypeScript type signature.
|
||||
- **Implementation** — the code inside.
|
||||
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
|
||||
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. In this repo seams take a concrete shape: `*.interface.ts` files, DI symbols, manifest declarations, and `<gen:*>` anchors.
|
||||
- **Adapter** — a concrete thing satisfying an interface at a seam. In this repo: `<x>.repository.ts` (Payload real impl) vs `<x>.repository.mock.ts` vs `Recording*` test doubles.
|
||||
- **Leverage** — what callers get from depth.
|
||||
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
|
||||
|
||||
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
|
||||
|
||||
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
|
||||
- **The interface is the test surface.**
|
||||
- **One adapter = hypothetical seam. Two adapters = real seam.**
|
||||
|
||||
This skill is **informed by** the project's domain model and architecture decisions. Read [`docs/glossary.md`](../../../docs/glossary.md) for project vocabulary and the relevant ADR(s) in [`docs/decisions/`](../../../docs/decisions/) before proposing anything in their territory.
|
||||
|
||||
## Hard constraints (do not propose violations)
|
||||
|
||||
These are settled decisions — propose deepening WITHIN them, never against them:
|
||||
|
||||
- **Factory-function use cases & controllers** (ADR-012, ADR-013) — every use case is `(deps) => async (input) => output`; every controller is one verb-noun pair per file with a co-located `presenter`.
|
||||
- **Schemas in the use-case file** (ADR-013) — `xInputSchema`/`xOutputSchema` colocate with the factory; don't propose moving them to a separate module.
|
||||
- **Per-feature DI containers** (ADR-008) — don't propose a single global container.
|
||||
- **Five boundary tags + the dependency-direction matrix** (ADR-006, ADR-010) — features may depend only on `core` + `tooling`; cross-feature reactions go through `IEventBus` (ADR-015).
|
||||
- **Manifest-first ordering** (ADR-012, ADR-020) — new use cases land manifest → contracts → tests → impl; don't propose collapsing the steps.
|
||||
- **Brand-based conformance** — `Instrumented` / `Captured` / `Audited` are attached at DI bind time via `withSpan` / `withCapture` / `withAudit`; don't propose moving the wrapping elsewhere.
|
||||
- **Generator-first** — `pnpm turbo gen <kind>` is the entry point for new features/events/jobs/realtime channels. Don't propose hand-rolled scaffolding.
|
||||
- **Conventional Commits** (CLAUDE.md Key Conventions) — any refactor lands as conventional-commit messages.
|
||||
- **Hybrid versioning** (ADR-021) — refactors that move code between feature packages have version + CHANGELOG implications.
|
||||
|
||||
If a proposed deepening **would** violate an ADR, surface it explicitly with the ADR number and a "worth reopening because…" justification — but only if the friction is real enough. Most should be silently scoped out.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Read [`docs/glossary.md`](../../../docs/glossary.md) and any ADRs in the area you're touching first. Then walk the codebase noting friction. The primary unit of attention is the **feature** (`packages/<name>/`); narrower units (use cases, controllers, repositories) get attention when the friction lives at that scale.
|
||||
|
||||
- Where does understanding one concept require bouncing between many small files across `entities/`, `application/`, `infrastructure/`, `interface-adapters/` inside a single feature?
|
||||
- Where is a feature **shallow** — its public surface (the `.` + `./ui` + `./api` exports) nearly as complex as its internal implementation? Or where inside a feature is a smaller unit shallow:
|
||||
- A `service.interface.ts` with one method that wraps a single repository call.
|
||||
- A `presenter` that's just `(x) => x`.
|
||||
- A controller body that's just `useCase(parsed.data)` with no transformation.
|
||||
- A repository wrapping another repository.
|
||||
- A use case wrapping another use case.
|
||||
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
|
||||
- Where do tightly-coupled features leak across their seams? (e.g. a feature reaches into another feature's internals via deep import — though ESLint should catch this.)
|
||||
- Which parts are untested, or hard to test through their current interface? `pnpm coverage:diff` and `pnpm fallow` surface candidates.
|
||||
|
||||
Useful exploration shortcuts in this repo:
|
||||
|
||||
- `pnpm fallow` — dead exports, dupes, complexity hotspots, circular deps
|
||||
- `pnpm fallow:audit` — the AI-change audit; surfaces drift across recent edits
|
||||
- `git log --oneline --follow -- <path>` — change frequency is a depth signal
|
||||
- `cat packages/<feature>/src/feature.manifest.ts` — declared surface of a feature
|
||||
- `pnpm turbo boundaries` — workspace dependency graph
|
||||
|
||||
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
|
||||
|
||||
### 2. Present candidates
|
||||
|
||||
Present a numbered list of deepening opportunities. For each candidate:
|
||||
|
||||
- **Files** — which files / features / smaller units are involved (give exact paths)
|
||||
- **Problem** — why the current architecture is causing friction
|
||||
- **Solution** — plain English description of what would change
|
||||
- **Benefits** — explained in terms of **locality** and **leverage**, plus how tests would improve
|
||||
- **ADR impact** — any ADR this touches. If the proposed change conflicts with a current ADR, mark it explicitly: _"contradicts ADR-NNN — worth reopening because…"_ (only when the friction warrants it).
|
||||
- **Manifest impact** — if the change moves use cases / events / jobs / channels across features, the `feature.manifest.ts` of each feature involved will need an update; flag this so the user knows the conformance gates will require manifest edits before code edits (manifest-first ordering).
|
||||
|
||||
**Use [`docs/glossary.md`](../../../docs/glossary.md) vocabulary for the domain (use case, manifest, slice, feature, etc.) and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture (module, seam, adapter, depth, leverage, locality).**
|
||||
|
||||
Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
|
||||
|
||||
### 3. Grilling loop
|
||||
|
||||
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||
|
||||
Side effects happen inline as decisions crystallize:
|
||||
|
||||
- **Naming a deepened module after a concept not in [`docs/glossary.md`](../../../docs/glossary.md)?** Add the term to the glossary right there — same discipline as the `grill-with-docs` skill. Pick the appropriate section (Packages / Architecture layers / Feature building blocks / Conformance / Cross-feature / Instrumentation / Workflow / Releasing).
|
||||
- **Sharpening a fuzzy term during the conversation?** Update the glossary inline.
|
||||
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as ADR-NNN so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future agent to avoid re-suggesting the same thing. The next ADR number is `001 + max(existing)` (currently `ADR-022`). Our ADR shape: `Context → Decision → Alternatives considered → Consequences → Related`.
|
||||
- **Refactor will move code between feature packages?** Flag the release-please impact: both affected packages will bump versions on the next release PR.
|
||||
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
|
||||
|
||||
## Related skills
|
||||
|
||||
- `grill-with-docs` (`.claude/skills/grill-with-docs/`) — stress-tests plans against ADRs + glossary + manifests; share the same glossary-update discipline.
|
||||
- `to-prd` — if the deepening is large enough to merit a multi-task epic, materialize it as a PRD.
|
||||
108
.claude/skills/to-prd/SKILL.md
Normal file
108
.claude/skills/to-prd/SKILL.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: to-prd
|
||||
description: Turn the current conversation context into a PRD and write it to docs/work/prds/. Use when the user wants to materialize the discussion into a draft PRD that feeds the pnpm work pipeline.
|
||||
---
|
||||
|
||||
This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know. If you need to interview first, invoke `grill-with-docs` instead.
|
||||
|
||||
The PRD lives on the filesystem (this repo does not use an issue tracker for work). The downstream pipeline is `pnpm work decompose` → epic + stories → tasks → sandcastle dispatch (see `docs/architecture/agent-first-workflow-and-conformance.md`).
|
||||
|
||||
## Process
|
||||
|
||||
1. **Explore the repo if you haven't already.** Use the project's domain vocabulary throughout (check `docs/glossary.md` if it exists, otherwise lift terms from `docs/architecture/vertical-feature-spec.md` §6 and the feature packages' `feature.manifest.ts`). Respect any ADRs in the area you're touching — they're at `docs/decisions/adr-NNN-<slug>.md`. Use `pnpm work status` to see in-flight epics.
|
||||
|
||||
2. **Sketch the major modules / packages.** Identify which existing packages (`packages/<feature>/`, `packages/core-*/`) you'll modify and which new ones — if any — you'll create. Actively look for **deep modules**: small interface, deep implementation, rarely-changing surface. The vertical-feature-package shape (entities → application → infrastructure → DI) is the default unit; resist scaffolding new core packages unless required.
|
||||
|
||||
Check with the user that this module sketch matches their expectations. Confirm which modules they want tests written for. (The conformance system already mandates tests for every use case + controller; this question is about extra coverage — repository contract suites, integration tests, etc.)
|
||||
|
||||
3. **Pick a slug** for the PRD filename: `docs/work/prds/<kebab-slug>.prd.md`. No date prefix in the slug — the `created:` timestamp in frontmatter carries the date. Future task-tracker IDs (e.g. ClickUp) will land as `<task-id>-<kebab-slug>` once that integration ships; until then, bare slug only.
|
||||
|
||||
4. **Write the PRD using the template below**, then save it. Status starts at `draft`. The decomposer (`pnpm work decompose`) refuses to run on `draft` PRDs — the human flips it to `approved` after review.
|
||||
|
||||
<prd-template>
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: <kebab-slug>
|
||||
title: <Human-readable title>
|
||||
type: prd
|
||||
status: draft
|
||||
author: <user>
|
||||
elicitation-session: <agent-session-id-or-omit>
|
||||
created: <ISO-8601-UTC-timestamp, e.g. 2026-05-14T19:23:45Z>
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
What's broken or missing today? Who hurts because of it? Frame it from the user's perspective (where "user" may be a developer using the template, an end-user of an app built on it, or an AI agent operating in the codebase).
|
||||
|
||||
## Goal
|
||||
|
||||
What state are we trying to reach? One or two sentences.
|
||||
|
||||
## In scope
|
||||
|
||||
- Bullets of what this PRD covers.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bullets of what's explicitly excluded. The explicit no-s are as valuable as the yes-s.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Non-negotiables: existing ADRs to respect, conformance rules, performance budgets, compliance requirements, etc.
|
||||
- Reference ADRs by ID: `ADR-014`, `ADR-017`, etc.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Verifiable outcomes. "Feature X passes `pnpm typecheck && pnpm test && pnpm conformance` green" is concrete; "feature X is great" is not.
|
||||
|
||||
## User stories
|
||||
|
||||
A numbered list. Cover all aspects of the feature, including edge cases.
|
||||
|
||||
1. As a `<actor>`, I want `<capability>`, so that `<benefit>`.
|
||||
2. ...
|
||||
|
||||
## Implementation decisions
|
||||
|
||||
Decisions captured here so the decomposer (and downstream agents) don't re-litigate them. Include:
|
||||
|
||||
- Modules to be built / modified (by package or feature name — no file paths; those rot fast)
|
||||
- Interface shapes (Zod schemas, TypeScript types, tRPC procedures) — describe in prose; inline only if a snippet encodes the decision more precisely than prose (e.g., a Zod schema, a discriminated union, a state machine)
|
||||
- Architectural choices (DI factory shape, withSpan/withCapture wrapping, manifest entries, anchor placements)
|
||||
- Schema changes (Payload collections, database migrations)
|
||||
- Cross-feature interactions (event publish/consume pairs, realtime channels, audit emissions)
|
||||
- Optional-core requirements (does this feature require `core-events`? `core-realtime`? `core-audit`?)
|
||||
|
||||
Do NOT include specific file paths or full code snippets — they go stale quickly. Prefer prose plus inline contracts (schemas, types) where they tighten the decision.
|
||||
|
||||
## Testing decisions
|
||||
|
||||
- What "good test" means for this feature (behavior through public interfaces, not implementation details)
|
||||
- Which modules get repository contract suites (any new `IXRepository`)
|
||||
- Which modules get use-case unit tests (every use case — that's a conformance rule)
|
||||
- Integration / e2e coverage: which apps, which Playwright specs
|
||||
- Prior art in the codebase: pointers to similar test patterns to mirror
|
||||
|
||||
## Open questions
|
||||
|
||||
- Q1: `<question>` — `<recommended answer>`
|
||||
- Q2: ...
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
Things that are tempting to include but should be a separate PRD.
|
||||
|
||||
## Further notes
|
||||
|
||||
Anything else: stakeholders, related PRDs (`Builds on <prd-id>`, `Supersedes <prd-id>`), external references.
|
||||
```
|
||||
|
||||
</prd-template>
|
||||
|
||||
## After writing
|
||||
|
||||
- Verify the file lives at `docs/work/prds/<slug>.prd.md`.
|
||||
- Tell the user the path and remind them to review and flip `status: draft → approved` before running `pnpm work decompose`.
|
||||
- If new domain terms were introduced or sharpened during synthesis, append them to `docs/glossary.md` (lazy-create if missing) — same rules as `grill-with-docs`.
|
||||
50
.claude/skills/work-decompose/SKILL.md
Normal file
50
.claude/skills/work-decompose/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: work-decompose
|
||||
description: Use when an approved PRD must be broken into an epic with story and task files under docs/work/. Triggers — the user asks to decompose a PRD, invokes /work-decompose, or wants a PRD turned into the work tree.
|
||||
---
|
||||
|
||||
# work-decompose
|
||||
|
||||
Decompose an `approved` PRD into an epic + story files under `docs/work/epics/`, by dispatching a **decomposer sub-agent** whose role is defined by the existing Sandcastle prompt.
|
||||
|
||||
This is the in-session, skill form of `pnpm work decompose --execute`. It adds a path; it changes nothing about `.sandcastle/` or `pnpm work`.
|
||||
|
||||
## Single source of truth — do not copy the prompt
|
||||
|
||||
The decomposer's role is defined in **`.sandcastle/decomposer.prompt.md`** — the same file `pnpm work decompose --execute` consumes. This skill **reads that file at dispatch time and passes it verbatim**. Never paraphrase, summarise, or inline it here. If this skill and the prompt ever disagree, **the prompt wins** — fix this skill, not the prompt.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Resolve the PRD.** The user names a PRD (slug or path); otherwise list `docs/work/prds/*.prd.md` and ask which. Read the file.
|
||||
|
||||
2. **Refuse drafts.** If the PRD's frontmatter `status:` is not `approved`, **stop** — tell the user to flip it after review. The decomposer refuses drafts; catch it early.
|
||||
|
||||
3. **Build the prompt.** Read `.sandcastle/decomposer.prompt.md`. Substitute its `{{PRD_FILE_CONTENT}}` placeholder with the full PRD file contents.
|
||||
|
||||
4. **Dispatch the decomposer sub-agent.** Use the Agent tool, `general-purpose`. Its instructions are the substituted prompt, followed by this environment-adaptation note (the note adapts the environment — it is not a prompt edit):
|
||||
|
||||
> **Environment:** you are a Claude Code sub-agent, not running inside Sandcastle. Ignore the `<promise>COMPLETE</promise>` marker instruction — there is no iteration loop; just return your final summary. Write the epic + story files to `docs/work/epics/`. **Do not commit** — leave the files for the human to review and commit, per your own "offer them a chance to review + edit" step.
|
||||
|
||||
5. **Report.** Relay the epic folder path the sub-agent created. Remind the user to review/edit the stories, then commit, and that `pnpm work rebuild-state` (or the pre-commit hook) refreshes `_state.json`. The next step in the pipeline is `/work-dispatch`.
|
||||
|
||||
## Why a sub-agent
|
||||
|
||||
Decomposition is a self-contained, read-heavy job — the whole PRD, the slice-rule reasoning, the file-writing. Running it in a sub-agent keeps all of that out of the main session; you get back only the epic path.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| | |
|
||||
| ----------------- | ------------------------------------------------------------------ |
|
||||
| Input | an `approved` PRD in `docs/work/prds/` |
|
||||
| Role prompt | `.sandcastle/decomposer.prompt.md` — read, never copied |
|
||||
| Sub-agent | one `general-purpose` agent |
|
||||
| Output | `docs/work/epics/<epic-id>/` — `_epic.md` + `NN-<story>/_story.md` |
|
||||
| Upstream | `to-prd` / `grill-with-docs` produce the PRD |
|
||||
| Downstream | `/work-dispatch` runs the tasks |
|
||||
| Sandcastle parity | mirrors `pnpm work decompose --execute` |
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Copying the prompt into this skill.** `.sandcastle/decomposer.prompt.md` is the source of truth — read it at dispatch time, every time.
|
||||
- **Decomposing a `draft` PRD.** Check `status: approved` first.
|
||||
- **Letting the sub-agent commit.** It writes files; the human reviews and commits.
|
||||
76
.claude/skills/work-dispatch/SKILL.md
Normal file
76
.claude/skills/work-dispatch/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: work-dispatch
|
||||
description: Use when a task in the docs/work/ tree should be implemented and reviewed. Triggers — the user asks to dispatch a task, run the next task, run the implement-review loop, or invokes /work-dispatch.
|
||||
---
|
||||
|
||||
# work-dispatch
|
||||
|
||||
Run one work-tree task through the **implement → review loop**, using two separate sub-agents whose roles are defined by the existing Sandcastle prompts.
|
||||
|
||||
This is the in-session, skill form of `pnpm work dispatch --execute`. It adds a path; it changes nothing about `.sandcastle/` or `pnpm work`.
|
||||
|
||||
## Single source of truth — do not copy the prompts
|
||||
|
||||
Two role definitions, two files, read at dispatch time — **never copied**:
|
||||
|
||||
- implementer role → **`.sandcastle/implementer.prompt.md`**
|
||||
- reviewer role → **`.sandcastle/reviewer.prompt.md`**
|
||||
|
||||
`pnpm work dispatch --execute` (Sandcastle) and this skill consume the **same** files. Never paraphrase or inline them. If a prompt and this skill disagree, **the prompt wins**. This skill is only the _wiring_: pick the task, substitute placeholders, dispatch the sub-agents, run the loop.
|
||||
|
||||
## Separate sub-agents — non-negotiable
|
||||
|
||||
The implementer and the reviewer are **distinct sub-agents**, dispatched separately:
|
||||
|
||||
- The **implementer** writes code. Dispatch a `general-purpose` Agent with `isolation: "worktree"` — it works on an isolated git worktree + branch, so bad output never touches your main tree.
|
||||
- The **reviewer** must be a _different_ agent and must _not_ write. Dispatch an `Explore` Agent (read-only by construction) — it cannot Edit/Write the repo even by accident; it only verifies the diff.
|
||||
|
||||
Never collapse the two into one agent. An agent that writes code and then grades its own work has not been reviewed.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Resolve the task.** Default: run `pnpm work next`, then read the first unchecked task file of that story (`docs/work/epics/<epic>/<story>/NN-<slug>.task.md`). Or the user names a task id. Read the task file; note its `max-attempts` frontmatter (default 3).
|
||||
|
||||
2. **Dispatch the implementer.** Read `.sandcastle/implementer.prompt.md`, substitute `{{TASK_FILE_CONTENT}}` with the task file. Dispatch a `general-purpose` Agent with `isolation: "worktree"`; instructions = the substituted prompt + this environment-adaptation note:
|
||||
|
||||
> **Environment:** a Claude Code sub-agent in a fresh, isolated git worktree — NOT a Sandcastle Docker sandbox. Run `pnpm install` as your first step (the worktree has no `node_modules`). Commit your slice on a branch. **Report that branch name** in the `notes` field of your output JSON. Ignore the `<promise>COMPLETE</promise>` marker — there is no iteration loop; just return the structured JSON as your final message.
|
||||
|
||||
Read the returned JSON: `status`, `commit_sha`, `files_changed`, `notes` (which carries the branch name).
|
||||
|
||||
3. **Handle the implementer's status.** `complete` → step 4. `blocked` or `needs-clarification` → surface the `notes` to the user and stop; do not proceed to review.
|
||||
|
||||
4. **Compute the diff.** `git diff main...<task-branch>` from the main tree — git worktrees share `.git`, so the implementer's branch is visible.
|
||||
|
||||
5. **Dispatch the reviewer.** Read `.sandcastle/reviewer.prompt.md`, substitute `{{TASK_FILE_CONTENT}}` and `{{DIFF}}`. Dispatch an `Explore` Agent (read-only); instructions = the substituted prompt + this environment-adaptation note:
|
||||
|
||||
> **Environment:** a Claude Code sub-agent reviewing a LOCAL branch — there is no pull request or CI run yet. Where the prompt says to trust Sandcastle's CI step: the implementer has already run and reported the five conformance gates + coverage as its commit precondition — verify AC coverage, out-of-scope discipline, and slice discipline by **reading the diff**, as your checks describe. Run the library-trace check directly (`node scripts/library-decisions/check.mjs`). The Socket and CodeQL steps require a CI run — note them as "deferred to CI", do not block on them. Ignore the `<promise>COMPLETE</promise>` marker; return the JSON decision.
|
||||
|
||||
Read the returned JSON: `decision`, `scope_violations`, `notes`.
|
||||
|
||||
6. **Run the loop.**
|
||||
- **`approve`** → merge `<task-branch>` into `main`; remove the worktree. Then run `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance` once on `main` as a post-merge safety check. Print the suggested task-checkbox / `_state.json` mutation for the user to apply — **do not write state yourself.**
|
||||
- **`reject`** → re-dispatch the implementer (step 2) with the reviewer's `notes` appended to its instructions. Repeat until `approve`, or until the task's `max-attempts` is reached — then stop and surface the last reviewer notes.
|
||||
|
||||
## Why this shape
|
||||
|
||||
State mutation stays manual (the skill suggests, the human applies) — exactly as Sandcastle's v1 orchestrator does, and consistent with the implementer prompt's "the orchestrator handles state writes." Worktree isolation gives the implementer a clean room without Docker. The read-only reviewer makes "the reviewer does not modify the repo" a property of the tool, not a promise.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| | |
|
||||
| ----------------- | ------------------------------------------------------------------------------------- |
|
||||
| Implementer | `.sandcastle/implementer.prompt.md` · `general-purpose` · `isolation: "worktree"` |
|
||||
| Reviewer | `.sandcastle/reviewer.prompt.md` · `Explore` (read-only) |
|
||||
| Placeholders | implementer: `{{TASK_FILE_CONTENT}}` · reviewer: `{{TASK_FILE_CONTENT}}` + `{{DIFF}}` |
|
||||
| Loop cap | the task's `max-attempts` frontmatter (default 3) |
|
||||
| State writes | suggested to the user, never applied by the skill |
|
||||
| Upstream | `/work-decompose` produces the tasks |
|
||||
| Sandcastle parity | mirrors `pnpm work dispatch --execute` |
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Copying a prompt into this skill.** The `.sandcastle/*.prompt.md` files are the source of truth — read them at dispatch time.
|
||||
- **One agent for both roles.** Implementer and reviewer are separate sub-agents; the reviewer is `Explore` (read-only).
|
||||
- **Writing `_state.json` or ticking the checkbox.** The skill prints the suggested mutation; the human applies it.
|
||||
- **Skipping the environment-adaptation note.** Without it the sub-agent follows Sandcastle-only instructions — the `<promise>` marker, "trust CI" — that do not apply in-session.
|
||||
- **Reviewing before the implementer says `complete`.** A `blocked` status stops the loop; do not review a partial slice.
|
||||
90
.env.example
Normal file
90
.env.example
Normal file
@@ -0,0 +1,90 @@
|
||||
# =============================================================================
|
||||
# Environment variables — copy this file to .env and fill in your values.
|
||||
# See docs/guides/runbook.md for the full reference.
|
||||
# =============================================================================
|
||||
|
||||
# --- Required for `pnpm dev` ---
|
||||
|
||||
# Postgres connection. Matches `docker compose up -d` default.
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/template
|
||||
|
||||
# Payload CMS encryption key. Any random 32+ char string in dev.
|
||||
PAYLOAD_SECRET=replace-with-a-random-32-char-string
|
||||
|
||||
# --- Optional: app URLs (defaults work in dev) ---
|
||||
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
CMS_URL=http://localhost:3001
|
||||
|
||||
# Force dev-seed binders (mock repos) regardless of NODE_ENV. Useful for
|
||||
# running pnpm dev without Payload booted.
|
||||
# USE_DEV_SEED=true
|
||||
|
||||
# --- Optional: Sentry observability ---
|
||||
# Leaving these unset → instrumentation falls back to the no-op tracer/logger.
|
||||
# Set the DSN for any app you want OTel + Sentry on.
|
||||
|
||||
# WEB_NEXT_SENTRY_DSN=
|
||||
# NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN=
|
||||
# CMS_SENTRY_DSN=
|
||||
# WEB_TANSTACK_SENTRY_DSN=
|
||||
# VITE_WEB_TANSTACK_SENTRY_DSN=
|
||||
|
||||
# Source-map upload at build time (production only).
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# SENTRY_ORG=
|
||||
# SENTRY_PROJECT_WEB_NEXT=
|
||||
# SENTRY_PROJECT_CMS=
|
||||
# SENTRY_PROJECT_WEB_TANSTACK=
|
||||
|
||||
# OTel trace sample rate (0.0 = none, 1.0 = all). 0.1 recommended in dev.
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
# SENTRY_ENVIRONMENT=development
|
||||
|
||||
# --- Optional: git commit SHA for releases ---
|
||||
|
||||
# VERCEL_GIT_COMMIT_SHA=
|
||||
# NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA=
|
||||
# VITE_GIT_COMMIT_SHA=
|
||||
|
||||
# --- Optional: core-audit (only when `gen core-package audit` is scaffolded) ---
|
||||
|
||||
# Salt for GDPR pseudonymisation. PRODUCTION MUST set this to a stable secret.
|
||||
# AUDIT_PSEUDONYM_SALT=
|
||||
|
||||
# --- Optional: sandcastle dispatch (only when running `pnpm work dispatch --execute`) ---
|
||||
|
||||
# Auth (pick one — subscription is preferred):
|
||||
#
|
||||
# 1. Subscription mode (recommended for Pro/Max subscribers):
|
||||
# Run `claude login` on the host once. Sandcastle bind-mounts ~/.claude/
|
||||
# into the sandbox so the container's Claude Code CLI uses your session.
|
||||
# Zero per-task token spend. No env var needed.
|
||||
#
|
||||
# 2. API-key mode (fallback when no host creds available):
|
||||
# ANTHROPIC_API_KEY=
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Override the path to host Claude Code creds (default: ~/.claude/)
|
||||
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||
|
||||
# GitHub access (optional — for orchestrator-created PRs)
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Sandbox provider (default: docker; alternatives: podman, vercel, daytona)
|
||||
# SANDCASTLE_PROVIDER=docker
|
||||
|
||||
# Agent iteration budgets. Sandcastle's `run()` cuts the agent off after N
|
||||
# iterations (one iteration = one tool-use + response round-trip). The
|
||||
# repo's defaults are tuned for typical work; bump if an agent gets cut
|
||||
# mid-commit (you'll see "Reached max iterations" in .sandcastle/logs/).
|
||||
#
|
||||
# SANDCASTLE_DECOMPOSE_ITERATIONS=10 # decompose: read PRD, write epic + stories, commit
|
||||
# SANDCASTLE_IMPLEMENTER_ITERATIONS=30 # implementer: full TDD slice (red test → green impl → gates → commit)
|
||||
# SANDCASTLE_REVIEWER_ITERATIONS=10 # reviewer: read diff + task, return decision
|
||||
|
||||
# Reject-cycle cap. After this many reviewer rejects on the same slice, the
|
||||
# dispatch loop gives up on that slice and exits 1 with the last rejection
|
||||
# notes printed. Bump for tricky slices; lower for fast-feedback iteration.
|
||||
#
|
||||
# SANDCASTLE_MAX_ATTEMPTS=3
|
||||
64
.fallowrc.json
Normal file
64
.fallowrc.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"ignorePatterns": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
"**/.turbo/**",
|
||||
"**/storybook-static/**",
|
||||
"**/__snapshots__/**",
|
||||
"**/turbo/generators/templates/**",
|
||||
"**/*.generated.ts",
|
||||
"**/*.d.ts"
|
||||
],
|
||||
"dynamicallyLoaded": [
|
||||
"packages/**/__factories__/**",
|
||||
"packages/**/__seeds__/**",
|
||||
"apps/**/instrumentation.ts",
|
||||
"apps/**/instrumentation-client.ts",
|
||||
"apps/storybook/test-runner.config.ts",
|
||||
"scripts/**/*.mjs"
|
||||
],
|
||||
"publicPackages": ["@repo/core-*"],
|
||||
"ignoreDependencies": [
|
||||
"@payloadcms/ui",
|
||||
"sass",
|
||||
"sharp",
|
||||
"@tanstack/react-query",
|
||||
"@trpc/server",
|
||||
"superjson",
|
||||
"@repo/blog",
|
||||
"@repo/core-api",
|
||||
"@repo/marketing-pages",
|
||||
"@repo/navigation",
|
||||
"@repo/core-testing",
|
||||
"http-server",
|
||||
"wait-on",
|
||||
"@opentelemetry/api-logs",
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"@testing-library/user-event",
|
||||
"zod",
|
||||
"@eslint/js",
|
||||
"@opentelemetry/sdk-node",
|
||||
"@sentry/opentelemetry",
|
||||
"@stryker-mutator/core",
|
||||
"@stryker-mutator/vitest-runner"
|
||||
],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"rules": {
|
||||
"unused-files": "warn",
|
||||
"unused-exports": "warn",
|
||||
"unused-types": "off",
|
||||
"unused-class-members": "warn",
|
||||
"unused-dependencies": "warn",
|
||||
"unused-dev-dependencies": "warn",
|
||||
"unlisted-dependencies": "warn",
|
||||
"circular-dependencies": "error",
|
||||
"duplicate-code": "warn"
|
||||
},
|
||||
"health": {
|
||||
"maxCyclomatic": 25,
|
||||
"maxCognitive": 30,
|
||||
"maxCrap": 400
|
||||
}
|
||||
}
|
||||
52
.github/renovate.json
vendored
Normal file
52
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:base",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":separateMajorReleases",
|
||||
":automergeMinor",
|
||||
":automergePatch"
|
||||
],
|
||||
"dependencyDashboard": true,
|
||||
"dependencyDashboardLabels": ["renovate/dashboard"],
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"major": {
|
||||
"commitMessagePrefix": "chore(deps-major):"
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "Sentry packages",
|
||||
"matchPackagePatterns": ["^@sentry/"],
|
||||
"schedule": ["on monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"groupName": "OpenTelemetry packages",
|
||||
"matchPackagePatterns": ["^@opentelemetry/"],
|
||||
"schedule": ["on monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"groupName": "tRPC packages",
|
||||
"matchPackagePatterns": ["^@trpc/"],
|
||||
"schedule": ["on monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"groupName": "Payload packages",
|
||||
"matchPackagePatterns": ["^payload"],
|
||||
"schedule": ["on monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"groupName": "Inversify packages",
|
||||
"matchPackagePatterns": ["^inversify"],
|
||||
"schedule": ["on monday"],
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"dockerfile": {
|
||||
"enabled": true,
|
||||
"fileMatch": ["^\\.sandcastle/Dockerfile$"]
|
||||
}
|
||||
}
|
||||
162
.github/workflows/ci.yml
vendored
Normal file
162
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
# CI workflow — runs on every push to main and every pull request.
|
||||
#
|
||||
# TURBO_TOKEN / TURBO_TEAM: set these in your repository secrets/variables
|
||||
# to enable Turborepo remote caching. Without them the workflow still works,
|
||||
# just without the remote-cache speedup.
|
||||
#
|
||||
# PAYLOAD_SECRET: the value used here is a throwaway test secret. Do NOT
|
||||
# reuse it in production. Set a real secret for your deployed environments.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: typecheck + lint + boundaries + test + build
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: cms_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so coverage:diff can resolve `origin/<base-ref>...HEAD`
|
||||
# against the PR's base branch (ADR-020 L1).
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Audit package signatures
|
||||
run: pnpm audit signatures --audit-level=high
|
||||
- name: Socket supply-chain scan
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
if git diff --name-only origin/${{ github.base_ref }}...HEAD \
|
||||
| grep -qE '(^|/)package\.json$|(^|/)pnpm-lock\.yaml$'; then
|
||||
npx --yes socket-cli@latest scan .
|
||||
else
|
||||
echo "No package.json or pnpm-lock.yaml changes — skipping Socket scan."
|
||||
fi
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm conformance
|
||||
- name: Compliance manifest drift check
|
||||
run: |
|
||||
pnpm compliance:emit-all --check || {
|
||||
echo ""
|
||||
echo "Compliance artifacts are out of date."
|
||||
echo "Run \`pnpm compliance:emit-all\` locally and commit the updated files."
|
||||
exit 1
|
||||
}
|
||||
- name: Fallow whole-codebase analysis
|
||||
run: pnpm fallow --format annotations
|
||||
- run: pnpm turbo boundaries
|
||||
- name: Test with coverage
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
||||
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
||||
run: pnpm test -- --coverage
|
||||
# L2 — merge per-package lcovs to coverage/lcov.info + emit
|
||||
# coverage/summary.json (ADR-020). Runs even on test failure so the
|
||||
# artifact still captures what was produced.
|
||||
- name: Coverage — aggregate (L2)
|
||||
if: always()
|
||||
run: pnpm coverage:aggregate
|
||||
# L1 — cover-the-diff gate. Only meaningful on PRs (push-to-main has
|
||||
# no base ref to diff against). Compares against the PR's base branch.
|
||||
- name: Coverage — diff (L1)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: pnpm coverage:diff -- --base origin/${{ github.base_ref }}
|
||||
- run: pnpm build
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage
|
||||
path: |
|
||||
coverage/lcov.info
|
||||
coverage/summary.json
|
||||
**/coverage/lcov.info
|
||||
retention-days: 7
|
||||
|
||||
e2e:
|
||||
name: Playwright e2e
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: cms_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Run e2e
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
||||
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
||||
run: pnpm test:e2e
|
||||
|
||||
storybook:
|
||||
name: Storybook smoke tests + visual regression
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Build Storybook
|
||||
run: pnpm --filter @repo/storybook build:storybook
|
||||
- run: pnpm test:stories
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
- name: Visual regression
|
||||
run: pnpm test:visual
|
||||
44
.github/workflows/codeql.yml
vendored
Normal file
44
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# CodeQL static analysis — javascript-typescript.
|
||||
#
|
||||
# Runs on every push to main, every pull request, and weekly on Wednesday
|
||||
# at 02:00 UTC (staggered from the trace-revalidation cron on Monday 06:30).
|
||||
#
|
||||
# NOTE (consumers): CodeQL is free for public repositories and GitHub Free
|
||||
# plans. For *private* repositories it requires GitHub Advanced Security
|
||||
# (available on GitHub Enterprise Cloud/Server or as an add-on). If you are
|
||||
# using this template with a private repo and do not have Advanced Security
|
||||
# enabled, remove or disable this workflow — it will fail at the "Initialize
|
||||
# CodeQL" step with a licensing error.
|
||||
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
# 02:00 UTC every Wednesday
|
||||
- cron: "0 2 * * 3"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
# Uses the default query suite (security-and-quality). To restrict
|
||||
# to security-only queries, set:
|
||||
# queries: security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
72
.github/workflows/coverage-snapshot.yml
vendored
Normal file
72
.github/workflows/coverage-snapshot.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# Coverage snapshot — commits coverage/summary.json back to main after each
|
||||
# merge so the trend history accumulates in `git log -- coverage/summary.json`
|
||||
# (ADR-020 L2). This is the only workflow that needs `contents: write`.
|
||||
#
|
||||
# Skipped if summary.json hasn't changed since the previous commit.
|
||||
|
||||
name: Coverage snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Avoid two snapshot runs racing each other on rapid-fire merges.
|
||||
concurrency:
|
||||
group: coverage-snapshot
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: cms_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Token with write scope so we can push the snapshot back.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Test with coverage
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
||||
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
||||
run: pnpm test -- --coverage
|
||||
- name: Aggregate
|
||||
run: pnpm coverage:aggregate
|
||||
- name: Commit summary.json if changed
|
||||
run: |
|
||||
if git diff --quiet --exit-code coverage/summary.json; then
|
||||
echo "No summary.json change; skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add coverage/summary.json
|
||||
git commit -m "chore(coverage): snapshot ${GITHUB_SHA::7}
|
||||
|
||||
Auto-generated by .github/workflows/coverage-snapshot.yml.
|
||||
|
||||
[skip ci]"
|
||||
git push origin HEAD:main
|
||||
110
.github/workflows/mutation-nightly.yml
vendored
Normal file
110
.github/workflows/mutation-nightly.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
# Mutation testing (L3) — nightly run + on-demand. ADR-020.
|
||||
#
|
||||
# Stryker is slow (~minutes per feature) so it's NOT part of the default
|
||||
# CI loop. This workflow runs nightly (and on manual dispatch) across every
|
||||
# feature with a stryker.config.json, then uploads the HTML + JSON
|
||||
# mutation reports as artifacts.
|
||||
#
|
||||
# On a meaningful score drop (>5%) it opens a tracking issue.
|
||||
|
||||
name: Mutation testing (nightly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 02:30 UTC nightly
|
||||
- cron: "30 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
filter:
|
||||
description: "Feature filter (e.g. @repo/auth). Empty = all features."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
mutate:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: cms_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Run mutation testing
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
|
||||
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
|
||||
run: |
|
||||
if [ -n "${{ inputs.filter }}" ]; then
|
||||
pnpm mutate -- --filter "${{ inputs.filter }}"
|
||||
else
|
||||
pnpm mutate
|
||||
fi
|
||||
continue-on-error: true
|
||||
- name: Upload mutation reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutation-reports
|
||||
path: packages/*/reports/mutation/
|
||||
retention-days: 30
|
||||
- name: Open tracking issue on >5% score drop
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const reports = [];
|
||||
const pkgsDir = path.join(process.cwd(), 'packages');
|
||||
if (fs.existsSync(pkgsDir)) {
|
||||
for (const pkg of fs.readdirSync(pkgsDir)) {
|
||||
const json = path.join(pkgsDir, pkg, 'reports', 'mutation', 'mutation.json');
|
||||
if (fs.existsSync(json)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(json, 'utf8'));
|
||||
const score = data.thresholds?.high && data.systemUnderTestMetrics?.metrics?.mutationScore;
|
||||
if (typeof score === 'number') {
|
||||
reports.push({ pkg, score: score.toFixed(2) });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reports.length === 0) return;
|
||||
const body = [
|
||||
'Nightly mutation testing run flagged failures. Latest scores:',
|
||||
'',
|
||||
...reports.map(r => `- **${r.pkg}**: ${r.score}%`),
|
||||
'',
|
||||
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
].join('\n');
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `Mutation score drop — ${new Date().toISOString().slice(0, 10)}`,
|
||||
body,
|
||||
labels: ['mutation-testing', 'automated'],
|
||||
});
|
||||
83
.github/workflows/release-please.yml
vendored
Normal file
83
.github/workflows/release-please.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
# Release Please — automated changelog + version bumps on merge to main.
|
||||
#
|
||||
# How it works:
|
||||
# 1. On every push to main, release-please scans conventional commits since
|
||||
# the last release tag for each tracked package.
|
||||
# 2. It opens (or updates) a single rolling "release PR" containing:
|
||||
# - version bumps in each affected package.json
|
||||
# - new CHANGELOG.md entries grouped by section (Features / Bug Fixes
|
||||
# / Performance / Refactoring / Documentation / Reverts)
|
||||
# - updated .release-please-manifest.json
|
||||
# 3. Merging that PR triggers tag creation (`template-vN.N.N`, `auth-vN.N.N`,
|
||||
# etc.) and GitHub release notes.
|
||||
#
|
||||
# Hybrid versioning (ADR-021): root template versions independently from the
|
||||
# 5 feature packages. Tags use the per-package component prefix so they don't
|
||||
# collide (e.g. `template-v0.2.0` vs `auth-v0.1.1`).
|
||||
#
|
||||
# Tracked packages, manifest baseline, and changelog sections live in
|
||||
# `release-please-config.json` + `.release-please-manifest.json`.
|
||||
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# A second push to main while a release PR is open shouldn't fight with the
|
||||
# first invocation — release-please-action already updates the rolling PR
|
||||
# idempotently, but concurrency keeps the audit trail clean.
|
||||
concurrency:
|
||||
group: release-please
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The steps below run only when release-please actually cut a release.
|
||||
# pnpm dlx avoids adding @cyclonedx/cyclonedx-npm to the lockfile (CI-only
|
||||
# tool per ADR-022); SHA-pinned action follows ADR-023 §1 Renovate pattern.
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate CycloneDX SBOM
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
run: >
|
||||
pnpm dlx @cyclonedx/cyclonedx-npm
|
||||
--output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json
|
||||
--output-format json
|
||||
--ignore-npm-errors
|
||||
|
||||
- name: Attach SBOM to GitHub release
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json
|
||||
28
.github/workflows/sentry-pii-guard.yml
vendored
Normal file
28
.github/workflows/sentry-pii-guard.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# R31 — block sendDefaultPii: true from ever landing.
|
||||
#
|
||||
# This is a defense-in-depth gate: the privacy posture is also enforced by
|
||||
# the centralized init helpers in core-shared/instrumentation/sentry/, but
|
||||
# this grep makes any drift impossible to merge.
|
||||
|
||||
name: Sentry PII guard (R31)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
pii-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify sendDefaultPii is never true
|
||||
run: |
|
||||
if grep -RIn --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --include='*.js' \
|
||||
--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist --exclude-dir=.turbo \
|
||||
-E 'sendDefaultPii\s*:\s*true' \
|
||||
packages/ apps/; then
|
||||
echo "::error::R31 violation — sendDefaultPii: true is forbidden anywhere in the repo."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — no sendDefaultPii: true detected."
|
||||
38
.github/workflows/trace-revalidation-weekly.yml
vendored
Normal file
38
.github/workflows/trace-revalidation-weekly.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Library trace revalidation — weekly run + on-demand. ADR-022.
|
||||
#
|
||||
# Walks every approved + pre-shipped trace in docs/library-decisions/,
|
||||
# re-runs each trace's verification-commands, classifies divergence as
|
||||
# soft (minor drift → rolling dashboard issue) or hard (re-evaluation
|
||||
# warranted → per-dep issue), and opens/updates/closes GitHub issues
|
||||
# accordingly. Runs in parallel to main — does NOT gate deployments.
|
||||
|
||||
name: Library trace revalidation (weekly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 06:30 UTC every Monday
|
||||
- cron: "30 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
revalidate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Revalidate library traces
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/library-decisions/revalidate.mjs
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# pnpm's content-addressable store (only present when a misconfigured
|
||||
# pnpm install places the store inside the project rather than at the
|
||||
# global default ~/.local/share/pnpm/store). Always ignored — the store
|
||||
# is pnpm's cache, not source.
|
||||
.pnpm-store/
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
storybook-static
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Per-user local overrides (never commit — .claude/settings.local.json, etc.)
|
||||
*.local
|
||||
*.local.*
|
||||
**/settings.local.json
|
||||
|
||||
# Testing — per-package coverage output (vitest)
|
||||
packages/*/coverage/
|
||||
apps/*/coverage/
|
||||
# Aggregated coverage output (pnpm coverage:aggregate): ignore everything
|
||||
# under /coverage/ EXCEPT summary.json (committed for trend history per
|
||||
# ADR-020) and .gitkeep markers.
|
||||
/coverage/*
|
||||
!/coverage/summary.json
|
||||
!/coverage/.gitkeep
|
||||
# Keep the source scripts at scripts/coverage/ tracked (allows files
|
||||
# named "coverage" elsewhere)
|
||||
!/scripts/coverage/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Superpowers brainstorm sessions
|
||||
.superpowers/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Template setup history — preserved locally, invisible to fresh clones.
|
||||
.archive/
|
||||
|
||||
# Scratch / working notes — local only, never committed.
|
||||
.tmp/
|
||||
14
.gitleaks.toml
Normal file
14
.gitleaks.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Gitleaks configuration for this monorepo.
|
||||
# Docs: https://github.com/gitleaks/gitleaks#configuration
|
||||
|
||||
title = "gitleaks config"
|
||||
|
||||
[extend]
|
||||
# Use the upstream default ruleset as the base.
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Test fixtures in __seeds__ directories use token-shaped dummy strings that are not real credentials."
|
||||
paths = [
|
||||
'''__seeds__/''',
|
||||
]
|
||||
38
.husky/pre-commit
Executable file
38
.husky/pre-commit
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Pre-commit gates — fast checks only. Slow checks (full conformance, full
|
||||
# test, full typecheck) stay in CI.
|
||||
|
||||
# 1. lint-staged: format + lint staged files
|
||||
pnpm exec lint-staged || exit 1
|
||||
|
||||
# 2. Stamp the `updated:` frontmatter field on every staged docs/work/ md file.
|
||||
node scripts/work/bump-updated-timestamps.mjs || exit 1
|
||||
|
||||
# 3. If any docs/work/ markdown is staged, regenerate _state.json + re-stage it
|
||||
if git diff --cached --name-only | grep -qE '^docs/work/.*\.md$'; then
|
||||
pnpm work rebuild-state
|
||||
git add docs/work/_system/_state.json
|
||||
fi
|
||||
|
||||
# 3. Run the state-sync guard: refuses to commit if _state.json is
|
||||
# staged but doesn't match what rebuild-state would produce. Catches the case
|
||||
# where someone hand-edits _state.json without going through rebuild-state.
|
||||
node scripts/work/state-sync-guard.mjs || exit 1
|
||||
|
||||
# 4. Check library decision traces for new runtime deps in feature/core packages.
|
||||
node scripts/library-decisions/check.mjs || exit 1
|
||||
|
||||
# 5. If any staged file touches Payload configs, library traces, or compliance
|
||||
# artifacts, regenerate compliance YAMLs and auto-stage them.
|
||||
if git diff --cached --name-only | grep -qE '^(packages/[^/]+/src/integrations/cms/|docs/library-decisions/|compliance/)'; then
|
||||
pnpm compliance:emit-all || exit 1
|
||||
git add compliance/
|
||||
fi
|
||||
|
||||
# 6. Scan staged changes for secrets (skip gracefully if gitleaks is not installed).
|
||||
if command -v gitleaks > /dev/null 2>&1; then
|
||||
gitleaks protect --staged --redact || exit 1
|
||||
else
|
||||
echo "gitleaks not found in \$PATH — skipping secret scan (install via brew install gitleaks or https://github.com/gitleaks/gitleaks)"
|
||||
fi
|
||||
13
.mcp.json
Normal file
13
.mcp.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"storybook": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:6006/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["@anthropic-ai/playwright-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Generated compliance artifacts — do not reformat
|
||||
compliance/*.yml
|
||||
8
.release-please-manifest.json
Normal file
8
.release-please-manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
".": "0.1.0",
|
||||
"packages/auth": "0.1.0",
|
||||
"packages/blog": "0.1.0",
|
||||
"packages/media": "0.1.0",
|
||||
"packages/marketing-pages": "0.1.0",
|
||||
"packages/navigation": "0.1.0"
|
||||
}
|
||||
20
.sandcastle/.env.example
Normal file
20
.sandcastle/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# .sandcastle/.env — runtime tokens for sandcastle dispatch.
|
||||
# Copy to .sandcastle/.env (gitignored) and fill what you need.
|
||||
#
|
||||
# Most developers don't need ANY of these if they've run `claude login` on
|
||||
# the host — sandcastle mounts ~/.claude/ into the sandbox by default.
|
||||
|
||||
# Anthropic API key (fallback when no host Claude Code session exists)
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
# OpenAI / Codex (alternative)
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# GitHub access for orchestrator-created PRs
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Override Claude creds path (default: ~/.claude/)
|
||||
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||
|
||||
# Sandbox provider (docker / podman / vercel / daytona)
|
||||
SANDCASTLE_PROVIDER=docker
|
||||
3
.sandcastle/.gitignore
vendored
Normal file
3
.sandcastle/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
*.log
|
||||
.cache/
|
||||
52
.sandcastle/Dockerfile
Normal file
52
.sandcastle/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Sandcastle sandbox image — runs the implementer + reviewer + decomposer
|
||||
# agents. Shape required by @ai-hero/sandcastle: a non-root `agent` user
|
||||
# (UID/GID aligned with the host so bind-mounted files share owner), Claude
|
||||
# Code CLI on PATH, and a long-running ENTRYPOINT so the container survives
|
||||
# the gap between sandcastle creating it and exec'ing into it.
|
||||
#
|
||||
# Authenticates via the host's mounted ~/.claude/ session (subscription
|
||||
# mode — sandcastle issue #191 workaround, our primary flow). Falls back
|
||||
# to ANTHROPIC_API_KEY when no host credentials are present.
|
||||
|
||||
FROM node:22-bookworm
|
||||
|
||||
# System deps — git for worktree ops, curl for the Claude installer, jq for
|
||||
# JSON tooling agents use, plus ca-certificates implicit in the base image.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pnpm via corepack (matches the repo's packageManager version).
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Build-args for UID/GID alignment: `sandcastle docker build-image` passes
|
||||
# the host user's UID/GID by default so image-built files and bind-mounted
|
||||
# files share an owner without runtime chown.
|
||||
ARG AGENT_UID=1000
|
||||
ARG AGENT_GID=1000
|
||||
|
||||
# Rename the base image's "node" user to "agent" and align UID/GID.
|
||||
# `-o` (non-unique) is required because the host's GID may collide with a
|
||||
# pre-existing system group in the base image (e.g. macOS UID:501 GID:20
|
||||
# collides with Debian's `dialout` group at GID 20). Allowing a duplicate
|
||||
# GID is safe here — only one user occupies the sandbox.
|
||||
RUN groupmod -o -g $AGENT_GID node && \
|
||||
usermod -o -u $AGENT_UID -g $AGENT_GID -d /home/agent -m -l agent node
|
||||
|
||||
USER ${AGENT_UID}:${AGENT_GID}
|
||||
|
||||
# Claude Code CLI — used by sandcastle's claudeCode() agent provider.
|
||||
# The CLI reads credentials from ~/.claude/ inside the container; the host
|
||||
# mounts its ~/.claude/ over that path at sandbox start.
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
ENV PATH="/home/agent/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /home/agent
|
||||
|
||||
# In worktree sandbox mode, sandcastle bind-mounts the git worktree at
|
||||
# ${SANDBOX_REPO_DIR} and overrides the working directory to that path at
|
||||
# container start. The Dockerfile's WORKDIR is just the default home.
|
||||
ENTRYPOINT ["sleep", "infinity"]
|
||||
57
.sandcastle/README.md
Normal file
57
.sandcastle/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# .sandcastle/
|
||||
|
||||
This directory holds prompt templates that the future orchestrator
|
||||
(`pnpm work dispatch` in the `sandcastle-dispatch-v1` epic) feeds to
|
||||
[sandcastle](https://github.com/mattpocock/sandcastle) when dispatching
|
||||
agents.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
| File | Role | Variables |
|
||||
| ------------------------ | ----------------------------------------- | ----------------------------------- |
|
||||
| `prd-eliciter.prompt.md` | Interview a human to produce a PRD draft | `{{INITIAL_BRIEF}}` |
|
||||
| `adr-eliciter.prompt.md` | Interview a human to produce an ADR draft | `{{INITIAL_PROPOSAL}}` |
|
||||
| `decomposer.prompt.md` | Turn a PRD into epic + story files | `{{PRD_FILE_CONTENT}}` |
|
||||
| `implementer.prompt.md` | Execute a single task | `{{TASK_FILE_CONTENT}}` |
|
||||
| `reviewer.prompt.md` | Review the implementer's diff | `{{TASK_FILE_CONTENT}}`, `{{DIFF}}` |
|
||||
|
||||
## Convention: every prompt enforces "generators first"
|
||||
|
||||
Each prompt template starts with the same non-negotiable rule: **the agent
|
||||
must prefer `pnpm turbo gen <kind>` over hand-rolled scaffolding.** This
|
||||
applies to feature packages, events, jobs, realtime channels, optional
|
||||
core packages, and atomic-design components. Hand-rolled code is only
|
||||
acceptable when the generator's output doesn't cover the case — and even
|
||||
then, the agent runs the generator first and modifies its output rather
|
||||
than starting from scratch.
|
||||
|
||||
## Environment
|
||||
|
||||
Configure runtime tokens via `.env` (gitignored). Copy `.env.example`
|
||||
and fill values for the providers you use.
|
||||
|
||||
## Build the sandbox image (one-time)
|
||||
|
||||
Sandcastle dispatches into a Docker image tagged `sandcastle:<root-package-name>`.
|
||||
Build it once per clone before `pnpm work dispatch --execute` or
|
||||
`pnpm work decompose <id> --execute` will work:
|
||||
|
||||
```bash
|
||||
pnpm exec sandcastle docker build-image
|
||||
# Tags: sandcastle:template-vertical
|
||||
```
|
||||
|
||||
Rebuild after editing this `Dockerfile`:
|
||||
|
||||
```bash
|
||||
pnpm exec sandcastle docker remove-image
|
||||
pnpm exec sandcastle docker build-image
|
||||
```
|
||||
|
||||
See [`docs/guides/runbook.md` → Using Sandcastle → Prerequisites](../docs/guides/runbook.md#using-sandcastle-for-agent-dispatch) for the full setup.
|
||||
|
||||
## Manual usage
|
||||
|
||||
Until the orchestrator ships, these templates are usable manually: copy
|
||||
the relevant `.prompt.md` content into a Claude / Codex / other agent
|
||||
session, fill the `{{VARIABLE}}` placeholders by hand, and run.
|
||||
61
.sandcastle/adr-eliciter.prompt.md
Normal file
61
.sandcastle/adr-eliciter.prompt.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# ADR Elicitation Agent
|
||||
|
||||
You are an Architecture Decision Record (ADR) elicitation agent for the template-vertical monorepo. Your job is to interview a human (one question at a time) and produce a complete ADR that captures the trade-offs of a proposed infrastructure decision.
|
||||
|
||||
## Use generators first (non-negotiable)
|
||||
|
||||
When the ADR concerns adopting infrastructure that has a generator path, the ADR's "Decision" section MUST reference the generator:
|
||||
|
||||
- **New optional core package** (cache, email, feature-flags, etc.) → `pnpm turbo gen core-package <name>`
|
||||
- **Atomic-design component library** → `pnpm turbo gen core-ui-component <name>` to seed
|
||||
- **Feature package as part of the integration** → `pnpm turbo gen feature <name>`
|
||||
|
||||
If the ADR is about adopting a package that has a generator and you describe the integration as hand-rolled, you have failed.
|
||||
|
||||
## Input
|
||||
|
||||
The human's initial proposal:
|
||||
|
||||
```
|
||||
{{INITIAL_PROPOSAL}}
|
||||
```
|
||||
|
||||
## Interview rules
|
||||
|
||||
1. Ask ONE question at a time.
|
||||
2. **Push the human to articulate alternatives.** If they only describe one option, your next question is "What other options did you consider and reject?" — ADRs without alternatives are weak.
|
||||
3. Topics, in order:
|
||||
- **Context**: what's the situation? What problem is forcing a decision?
|
||||
- **Drivers**: what's making this decision urgent (timeline, cost, deprecation, …)?
|
||||
- **Considered options**: enumerate ALL alternatives, minimum 2. For each, pros + cons.
|
||||
- **Decision**: which option, and why. Reference generators if applicable.
|
||||
- **Consequences**: positive + negative + follow-up work (PRDs).
|
||||
4. Minimum 5 substantive answers before drafting.
|
||||
|
||||
## Output
|
||||
|
||||
Write the ADR to `docs/adr/NNN-<slug>.md` (use the next available NNN number; check `docs/adr/` for existing ADRs).
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: NNN
|
||||
title: <decision title>
|
||||
status: proposed
|
||||
date: <today>
|
||||
supersedes: []
|
||||
superseded-by: null
|
||||
related-prds: []
|
||||
---
|
||||
```
|
||||
|
||||
Body: Context, Drivers, Considered options, Decision, Consequences (Positive / Negative / Follow-up work).
|
||||
|
||||
Tell the human the file path. Tell them to review and flip `status: proposed` → `status: accepted` (or `rejected` / `superseded`) before any downstream PRDs are decomposed.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't accept a single-option ADR. Push for alternatives.
|
||||
- Don't skip the generator check.
|
||||
- Don't write code or PRDs.
|
||||
87
.sandcastle/decomposer.prompt.md
Normal file
87
.sandcastle/decomposer.prompt.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Decomposer Agent
|
||||
|
||||
You are the decomposer agent. Given an approved PRD, you produce the epic file + one story file per requirement under `docs/work/epics/<epic-slug>/`. Folder names use the **bare slug** — no date prefix; the `created:` timestamp in frontmatter carries the date. Each story has its own checkbox-driven Tasks list — where **every checkbox is a vertical slice**.
|
||||
|
||||
## The slice rule (non-negotiable)
|
||||
|
||||
**slice = task = PR = commit.** Every task you write MUST satisfy ALL of:
|
||||
|
||||
1. **One green commit.** After the task lands, `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass. No task may leave the repo in a broken state.
|
||||
2. **Exercises a layer.** The task either creates a NEW piece of vertical capability (manifest entry + contracts + test + impl + DI wiring + integration, end-to-end for one slice), OR completes a self-contained refactor (e.g. "wire feature X's binder through the new helper") that keeps the slice green.
|
||||
3. **Independently meaningful.** Reading the task description, an implementer can know what "done" looks like without reading the next checkbox.
|
||||
|
||||
## Tasks that are FORBIDDEN
|
||||
|
||||
- **"Read X file"** — reading is part of doing the work, not a separate task. The implementer reads what it needs to read.
|
||||
- **"Write the test"** as a standalone task when the implementation hasn't landed (the test gate is red between this checkbox and the next — violates rule 1). Same for "write the implementation" without the test.
|
||||
- **"Run typecheck"** / **"Run pnpm test"** / **"Run lint"** as separate tasks — these gates are part of the implementer's done-criteria for every task, not their own checkboxes.
|
||||
- **"Export X from index.ts"** as a standalone task when the export's consumer also lands in this story — combine them. (Standalone export is fine only when it's the entire payload of a slice; rare.)
|
||||
- **Sub-step decomposition of a single slice** ("Step 1: scaffold the file. Step 2: implement the body. Step 3: add tests.") — that's one task, not three.
|
||||
|
||||
## Tasks that are CORRECT
|
||||
|
||||
- **`Run pnpm turbo gen <kind> <args>`** — generator scaffolds an entire slice (manifest + contracts + tests + impl + DI wiring) in one shot. Always the FIRST task for any story that creates new feature/event/job/realtime/core-package/component code.
|
||||
- **`Add use case <name> to <feature>`** — one full vertical slice: manifest entry + contracts (input/output schemas, IXUseCase type) + red test + green impl + DI binding + (if cross-feature) event wiring. All in one commit; the implementer follows the manifest-first ordering inside the task.
|
||||
- **`Migrate <feature>'s binders to <helper>`** — for refactor stories: replace the inline wrapping in `bind-production.ts` + `bind-dev-seed.ts` of one feature, keep the feature's tests green. One commit per feature, NOT one per binder file.
|
||||
- **`Add audit emission to <use-case>`** — manifest's `audits: [...]` declaration + `auditLog.record(...)` call site + test asserting the audit, all in one commit.
|
||||
- **`Wire <feature> into apps/<app>/bindAll()`** — single binding integration point landing with its test.
|
||||
|
||||
## Manifest-first ordering INSIDE a task
|
||||
|
||||
When a single task creates a new use case, the implementer's INTERNAL ordering is (1) manifest entry → (2) contracts → (3) red test → (4) green impl — but this is one task that lands as one commit. The four steps don't become four separate checkboxes; they're the work done inside a single slice. The reviewer verifies the slice is whole, not that the implementer wrote things in a specific order.
|
||||
|
||||
## Use generators first (non-negotiable)
|
||||
|
||||
When decomposing requirements into stories + tasks, your first task in every story that creates a feature / event / job / realtime / core-package / component MUST be `Run \`pnpm turbo gen <kind> <name>\``. Do not write a story whose first task is "hand-write src/foo.ts" when a generator can produce src/foo.ts. The generators are:
|
||||
|
||||
- `pnpm turbo gen feature <name>` — feature scaffold (manifest, contracts, binders, controllers, tests)
|
||||
- `pnpm turbo gen event` — event contract (publish) or handler (consume)
|
||||
- `pnpm turbo gen job` — background job
|
||||
- `pnpm turbo gen realtime` — realtime channel or inbound handler
|
||||
- `pnpm turbo gen core-package <name>` — optional core package
|
||||
- `pnpm turbo gen core-ui-component <name>` — atomic-design component
|
||||
|
||||
For each requirement, ask: "is there a generator for this?" If yes, the first task is the generator invocation; subsequent tasks customise the generator's output (add use-case behaviours, declare audits/publishes, etc.).
|
||||
|
||||
## Input
|
||||
|
||||
The approved PRD:
|
||||
|
||||
```
|
||||
{{PRD_FILE_CONTENT}}
|
||||
```
|
||||
|
||||
## Your job
|
||||
|
||||
1. Read the PRD. Extract: epic id (kebab-slug from title — **no date prefix**; the `created:` timestamp carries the date), story list (one per Requirement), dependency edges (from "depends on" hints in the PRD), out-of-scope items. The epic id should match the PRD's `id:` field exactly.
|
||||
2. Write `docs/work/epics/<epic-id>/_epic.md` with frontmatter: `id`, `prd` (path to the PRD file), `title`, `type: epic`, `status: in-progress`, `features`, `created: <ISO-8601-UTC-timestamp>` (use the current timestamp). The pre-commit hook adds `updated:` automatically — do NOT set it yourself.
|
||||
3. For each Requirement, write `docs/work/epics/<epic-id>/<NN>-<story-slug>/_story.md`:
|
||||
- Frontmatter: `id`, `epic`, `title`, `type: technical-story | user-story`, `status: in-progress` (for the first) or `todo` (subsequent), `feature`, `depends-on` (array, may reference other stories in this epic by id), `blocks`, `created: <ISO-8601-UTC-timestamp>`. The pre-commit hook stamps `updated:` — do NOT set it yourself.
|
||||
- Sections: Goal, Why, Done when, In scope, Out of scope, Tasks (checkbox list).
|
||||
- **Each story's Tasks list:** every checkbox MUST satisfy the slice rule above — one green commit per checkbox. If a generator is applicable, list the generator invocation as the FIRST checkbox; subsequent checkboxes customise the generator's output and each one lands its own green commit (e.g. "Add audit emission to use case X", "Wire event publish from X into bus").
|
||||
|
||||
## Output
|
||||
|
||||
Do not implement anything. Do not write code. Do not invent requirements not in the PRD. Each story should be a thin descriptor; the implementer fills in details when it picks up each task.
|
||||
|
||||
When done, tell the human the epic folder path and offer them a chance to review + edit before invoking the implementer.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Stay literal to the PRD. The decomposer's judgment is about structure (which requirement becomes which story, what depends-on edges look like), not content.
|
||||
- If a Requirement is too broad for one story, split it into multiple stories with clear depends-on chains. Don't merge unrelated Requirements into one story.
|
||||
- If the PRD's status is not `approved`, refuse to decompose and tell the human to flip it first.
|
||||
- **Slice discipline:** prefer FEWER but FATTER tasks (one per vertical slice) over MANY thinner sub-steps. If you're tempted to write more than ~5 checkboxes for a story, ask: "is each one really an independent vertical slice that lands as its own green commit?" If not, collapse the sub-steps into a single task and trust the implementer to follow the manifest-first ordering internally.
|
||||
- **Self-check before writing each Tasks list:** for each checkbox, imagine the commit it would produce. Would `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm coverage:diff` all pass on that commit alone? If no, the checkbox isn't a slice — merge it with its neighbours.
|
||||
|
||||
## Signal completion (required)
|
||||
|
||||
When the epic folder + story files are written and committed (or you have determined the work is truly done — including the case where you decided not to write anything and reported the reason), emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
|
||||
|
||||
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
|
||||
|
||||
Do NOT emit the marker if:
|
||||
|
||||
- You still have files to write, gates to run, or commits to make.
|
||||
- You returned a partial result and intend the next iteration to continue.
|
||||
- You hit an error you want sandcastle to surface as "max iterations reached" rather than "complete."
|
||||
112
.sandcastle/implementer.prompt.md
Normal file
112
.sandcastle/implementer.prompt.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Implementer Agent
|
||||
|
||||
You are the implementer agent. You execute ONE task at a time, identified by the task description below. Your output is a single green commit (or a series of commits squashed at merge time).
|
||||
|
||||
## Use generators first (non-negotiable)
|
||||
|
||||
Before writing any code: if your task description includes `pnpm turbo gen <kind> ...`, run that command FIRST and use its output as your starting point. Even if the generator only emits half of what you need, customising generator output is always preferred over hand-rolling.
|
||||
|
||||
Available generators:
|
||||
|
||||
- `pnpm turbo gen feature <name>` — full feature scaffold
|
||||
- `pnpm turbo gen event` — event contract or handler
|
||||
- `pnpm turbo gen job` — background job
|
||||
- `pnpm turbo gen realtime` — realtime channel or handler
|
||||
- `pnpm turbo gen core-package <name>` — optional core package
|
||||
- `pnpm turbo gen core-ui-component <name>` — atomic-design component
|
||||
|
||||
If your task's first checkbox is a generator invocation, that's your first action. Do not skip ahead.
|
||||
|
||||
## Task
|
||||
|
||||
```
|
||||
{{TASK_FILE_CONTENT}}
|
||||
```
|
||||
|
||||
## Manifest-first ordering
|
||||
|
||||
For any new use case, the order is non-negotiable:
|
||||
|
||||
1. **Manifest entry** — add to `feature.manifest.ts`
|
||||
2. **Contracts** — `xInputSchema`, `xOutputSchema`, `IXUseCase` exports in the use-case file (factory body throws `not implemented` initially)
|
||||
3. **Tests (red)** — write the failing test
|
||||
4. **Implementation (green)** — fill the factory body until tests pass
|
||||
|
||||
The generator handles step 1 + 2 for you when scaffolding a new feature.
|
||||
|
||||
## Conformance gates (run before declaring done)
|
||||
|
||||
```
|
||||
pnpm typecheck # TS brand-slot enforcement, 0s
|
||||
pnpm lint # ESLint rules incl. conformance/* — <1s
|
||||
pnpm test --filter @repo/<feature> -- --coverage # tests + per-layer thresholds for the feature you touched
|
||||
pnpm conformance # cross-feature event closure
|
||||
pnpm fallow:audit # whole-codebase analysis: dead exports, dupes, circular deps, complexity
|
||||
```
|
||||
|
||||
All five pass before you commit. If any fail, fix or report BLOCKED — do not paper over.
|
||||
|
||||
## Coverage gates (ADR-020 — run after the conformance gates)
|
||||
|
||||
The coverage architecture has its own multi-layer enforcement that's distinct from the conformance gates above. Run all of these before declaring done:
|
||||
|
||||
```
|
||||
pnpm test -- --coverage # L0 — per-layer thresholds (100% on entities/use-cases/controllers)
|
||||
pnpm coverage:aggregate # L2 — merges per-package lcovs to coverage/lcov.info + coverage/summary.json
|
||||
pnpm coverage:diff -- --base <base-ref> # L1 — cover-the-diff: every changed line must be exercised
|
||||
```
|
||||
|
||||
Treat `pnpm coverage:diff` output as machine-readable:
|
||||
|
||||
- Exit 0 → pass; the JSON stdout has `status: "pass"`
|
||||
- Exit 1 → fail; the JSON stdout's `uncovered` array lists each `{ file, line, kind }` hit
|
||||
- `kind: "uncovered"` → write the missing test
|
||||
- `kind: "no-coverage-data"` → entire file isn't in lcov; you shipped untested code (a sibling test file is missing)
|
||||
|
||||
Fix every hit before reporting `complete`. If you legitimately can't (e.g., the line is genuinely unreachable), extend the allowlist in `scripts/coverage/diff.mjs` AND add a test in `scripts/coverage/diff.test.mjs` — don't silently bypass.
|
||||
|
||||
See `docs/guides/coverage.md` for the full architecture (4 layers) and the troubleshooting section. The base ref is usually `origin/main` for PR work; for in-session iteration use `HEAD~N`.
|
||||
|
||||
## Commit message format
|
||||
|
||||
`<type>(<scope>): <imperative subject>`
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat(auth): hash password before persisting`
|
||||
- `test(blog): assert article not found error`
|
||||
- `feat(scripts): conformance drift gate + tests`
|
||||
|
||||
Subject line ≤72 chars. Body explains WHY if non-obvious.
|
||||
|
||||
## When you're stuck
|
||||
|
||||
Report status `BLOCKED` (don't silently produce work you're unsure about). State specifically: what you tried, what's unclear, what kind of help you need (more context / different model / smaller task / plan is wrong).
|
||||
|
||||
## Output format
|
||||
|
||||
When done, return structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "complete" | "blocked" | "needs-clarification",
|
||||
"ac_satisfied": [0, 1, 2],
|
||||
"files_changed": ["packages/..."],
|
||||
"commit_sha": "abc123",
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT modify the task markdown or `_state.json` yourself — the orchestrator handles state writes.
|
||||
|
||||
## Signal completion (required)
|
||||
|
||||
After you have committed the slice (or returned a terminal `blocked` / `needs-clarification` status), emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
|
||||
|
||||
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
|
||||
|
||||
Do NOT emit the marker if:
|
||||
|
||||
- The five conformance gates haven't all passed yet.
|
||||
- You still have files to write, fixes to apply, or commits to make.
|
||||
- You returned a partial result and intend the next iteration to continue.
|
||||
64
.sandcastle/prd-eliciter.prompt.md
Normal file
64
.sandcastle/prd-eliciter.prompt.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# PRD Elicitation Agent
|
||||
|
||||
You are a PRD elicitation agent for the template-vertical monorepo. Your job is to interview a human (one question at a time) and produce a complete, agent-ready PRD that the decomposer can turn into stories.
|
||||
|
||||
## Use generators first (non-negotiable)
|
||||
|
||||
When the human's idea maps to creating any of these, the PRD's "Requirements" section must explicitly reference the generator that will produce the artefact:
|
||||
|
||||
- **Feature package** → `pnpm turbo gen feature <name>`
|
||||
- **Event contract / handler** → `pnpm turbo gen event`
|
||||
- **Background job** → `pnpm turbo gen job`
|
||||
- **Realtime channel / handler** → `pnpm turbo gen realtime`
|
||||
- **Optional core package** → `pnpm turbo gen core-package <name>`
|
||||
- **Atomic-design component** → `pnpm turbo gen core-ui-component <name>`
|
||||
|
||||
If a requirement could be satisfied by a generator and you write it instead as a hand-rolled file list, you have failed. Always check first whether a generator covers the requirement.
|
||||
|
||||
## Input
|
||||
|
||||
The human's initial brief:
|
||||
|
||||
```
|
||||
{{INITIAL_BRIEF}}
|
||||
```
|
||||
|
||||
## Interview rules
|
||||
|
||||
1. Ask ONE question at a time. Never bundle multiple questions in one turn.
|
||||
2. Prefer multiple-choice when the answer space is small. Open-ended only when the answer is genuinely open.
|
||||
3. Topics to cover, in order:
|
||||
- **Problem**: what's broken or missing today; who hurts because of it?
|
||||
- **Goal**: what state are we trying to reach?
|
||||
- **In scope** / **Out of scope**: the explicit fence.
|
||||
- **Constraints**: what existing APIs / performance budgets / SLAs must we preserve?
|
||||
- **Success criteria**: how do we observe success?
|
||||
- **Requirements**: numbered list (R1, R2, …). For each, identify the generator that produces it if applicable.
|
||||
- **Open questions**: decisions you couldn't resolve in the interview.
|
||||
4. After enough information is gathered (you decide; minimum 6 substantive answers), draft the PRD and present it to the human for review. The PRD's `status` is `draft` until the human flips it to `approved`.
|
||||
|
||||
## Output
|
||||
|
||||
When you've gathered enough, write the PRD to `docs/work/prds/<YYYY-MM-DD>-<slug>.prd.md` with this frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: <YYYY-MM-DD>-<slug>
|
||||
title: <one-line title>
|
||||
type: prd
|
||||
status: draft
|
||||
author: <human's name or "human">
|
||||
elicitation-session: <this session's id>
|
||||
created: <today>
|
||||
---
|
||||
```
|
||||
|
||||
And the body sections (in order): Problem, Goal, In scope, Out of scope, Constraints, Success criteria, Requirements (numbered), Open questions.
|
||||
|
||||
Tell the human the file path. Tell them to review and flip `status: draft` → `status: approved` before invoking the decomposer.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't decompose into stories — that's the decomposer's job
|
||||
- Don't write code or tests
|
||||
- Don't skip the generator check on each requirement
|
||||
129
.sandcastle/reviewer.prompt.md
Normal file
129
.sandcastle/reviewer.prompt.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Reviewer Agent
|
||||
|
||||
You are the reviewer agent. You verify the implementer's diff against the task's AC + scope. You do NOT modify the repo.
|
||||
|
||||
## Generator-first check (verify, don't bypass)
|
||||
|
||||
If the task's first checkbox was a generator invocation, verify the implementer actually ran the generator. Signs the generator was run:
|
||||
|
||||
- The diff includes files at canonical generator paths (e.g., `packages/<name>/src/feature.manifest.ts`, `packages/<name>/src/di/bind-production.ts`, etc.)
|
||||
- The generator's anchor comments (`// <gen:event-handlers>`, `// <gen:jobs>`, etc.) are present
|
||||
- The file shapes match what `pnpm turbo gen <kind>` would produce
|
||||
|
||||
If you suspect the implementer hand-rolled what should have been generator output, reject. Tell them to delete what they wrote and run the generator.
|
||||
|
||||
## Task
|
||||
|
||||
```
|
||||
{{TASK_FILE_CONTENT}}
|
||||
```
|
||||
|
||||
## Diff
|
||||
|
||||
```
|
||||
{{DIFF}}
|
||||
```
|
||||
|
||||
## Your checks
|
||||
|
||||
1. **AC coverage** (acceptance criteria, not test coverage): every checkbox in the task's AC list is verifiably satisfied by the diff. Verify by reading the actual code, not by trusting the implementer's report.
|
||||
2. **Out-of-scope discipline**: the diff does NOT touch anything listed under the task's "Out of scope" (or anything not related to the AC). Over-engineering / drive-by refactors are rejection causes.
|
||||
3. **Manifest-first ordering**: if a new use case landed, the manifest was updated; tests exist; the factory was wrapped at bind time.
|
||||
4. **Conformance gates**: the diff's tests + lint + typecheck pass. (You don't run them yourself; sandcastle's CI step does. Trust the CI status, reject if it's red.)
|
||||
5. **Generator-first**: see the section above. Hand-rolled code that should have been generated is a rejection.
|
||||
6. **Fallow audit**: verify the implementer ran `pnpm fallow:audit` and it passed. If their diff increases dead exports / dupes / circular deps / complexity beyond the baseline, that's a rejection cause unless the implementer's notes explicitly justify it.
|
||||
7. **Coverage gates** (ADR-020): the implementer must have run `pnpm coverage:diff` and gotten status `pass`. The CI surfaces this as the "Coverage — diff (L1)" step; if it's red, reject. Additionally, check:
|
||||
- **Per-layer thresholds (L0)**: any new code under `entities/`, `application/use-cases/`, or `interface-adapters/controllers/` is bound to 100%/100%/95%/100% bands. If the test run produced threshold errors, that's a rejection.
|
||||
- **No silent allowlist expansion**: if `scripts/coverage/diff.mjs`'s `ALLOWED_GLOBS` grew, the implementer's notes must explain why (and the matching test fixture must exist in `scripts/coverage/diff.test.mjs`).
|
||||
- **Manifest coverage band drift**: if `feature.manifest.ts` was edited, its `coverage:` section must match `DEFAULT_COVERAGE_BANDS` from `@repo/core-shared/conformance/coverage` (or carry an explicit override the implementer's notes justify).
|
||||
8. **Slice discipline** (slice = task = PR = commit): the task represented ONE vertical slice that lands as ONE green commit. Reject if:
|
||||
- The implementer broke the work into multiple commits where any intermediate commit would leave the repo with red gates (test failing, typecheck failing, lint failing).
|
||||
- The diff is shaped like sub-steps that should have been their own tasks ("scaffold a file" + "implement the body" + "add tests" = three commits, three task tickets, not one task with three sub-commits).
|
||||
- The slice is incomplete — e.g., a use case landed without its DI binding, an event was declared in the manifest but no publish site exists, a controller was added without wiring into a router. The slice is whole or it's a rejection.
|
||||
|
||||
## Epic close-out: PRD status flip
|
||||
|
||||
After approving a task, check `docs/work/_system/_state.json` for the `needs_prd_ship` array (rebuilt automatically by the pre-commit state-sync hook). Each entry has shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"epic": "<epic-slug>",
|
||||
"prd": "<prd-id>",
|
||||
"prd_status": "approved",
|
||||
"action": "pnpm work prd-ship <prd-id> --auto-commits"
|
||||
}
|
||||
```
|
||||
|
||||
If the task you just approved was the FINAL task of an epic (i.e., the epic transitioned to `status: done`) and that epic appears in `needs_prd_ship`, the orchestrator must run the suggested `action` command before declaring the epic closed. The `prd-ship` command:
|
||||
|
||||
- Refuses to flip `draft` PRDs (must go through human review first)
|
||||
- Idempotent — won't double-flip an already `shipped` PRD
|
||||
- Writes `status: shipped`, `shipped: <today>`, and `shipping-commits: [...]` to the PRD frontmatter
|
||||
- Auto-derives the shipping-commits list from `git log` of the linked epic folder when `--auto-commits` is passed
|
||||
|
||||
Include the PRD-ship outcome in your review notes when applicable.
|
||||
|
||||
## Output format
|
||||
|
||||
Return structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "approve" | "reject",
|
||||
"ac_verified": [0, 1, 2],
|
||||
"scope_violations": ["files touched that weren't in scope"],
|
||||
"generator_skipped": false,
|
||||
"prd_shipped": "<prd-id>" | null,
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
If you reject, the orchestrator passes your notes back to the implementer for a fix-up cycle (up to the task's `max-attempts`, default 3).
|
||||
|
||||
## Library-trace check
|
||||
|
||||
Before issuing your verdict, run:
|
||||
|
||||
```bash
|
||||
node scripts/library-decisions/check.mjs --staged-against <base-branch>
|
||||
```
|
||||
|
||||
where `<base-branch>` is the PR's base branch (typically `main`). If the command exits non-zero, **reject** the slice: a new runtime dependency in a feature- or core-tier package is missing an approved library-decision trace. The implementer must run the evaluate-library skill (`.claude/skills/evaluate-library/SKILL.md`) and add the resulting `docs/library-decisions/*.md` trace before the slice can be approved.
|
||||
|
||||
## CI security checks
|
||||
|
||||
Before issuing your verdict, retrieve the CI run logs for the PR and scan for security findings:
|
||||
|
||||
```bash
|
||||
gh run view <run-id> --log
|
||||
```
|
||||
|
||||
where `<run-id>` is the most recent workflow run for the PR's head commit (find it via `gh pr checks <pr-number>`).
|
||||
|
||||
**Socket — critical findings:**
|
||||
Scan the log output for any Socket security finding with severity `critical`. These appear in the "Socket Security" check step output. If any `critical` finding is present:
|
||||
|
||||
- **Reject** the slice.
|
||||
- Name the specific finding (package name + finding label) in your notes.
|
||||
- Cite the failure-mode hierarchy in `docs/guides/ci-security.md` for remediation guidance.
|
||||
|
||||
Example rejection note: `"Socket reports critical finding 'protestware' on package foo@1.2.3. See docs/guides/ci-security.md for the failure-mode hierarchy."`
|
||||
|
||||
**CodeQL — error-severity findings:**
|
||||
Scan the log output for any CodeQL finding with severity `error`. These appear in the "CodeQL" check step output (also surfaced as SARIF alerts on the PR). If any `error`-severity finding is present:
|
||||
|
||||
- **Reject** the slice.
|
||||
- Name the specific finding (rule ID + file + line) in your notes.
|
||||
- Cite the failure-mode hierarchy in `docs/guides/ci-security.md` for remediation guidance.
|
||||
|
||||
Example rejection note: `"CodeQL reports error-severity finding 'js/sql-injection' at src/foo.ts:42. See docs/guides/ci-security.md for the failure-mode hierarchy."`
|
||||
|
||||
These checks compose with the library-trace check above: **all three must pass** (library-trace clean, no Socket `critical`, no CodeQL `error`) for the slice to be approved.
|
||||
|
||||
## Signal completion (required)
|
||||
|
||||
After you have returned the structured JSON decision, emit the literal string `<promise>COMPLETE</promise>` as the final line of your response.
|
||||
|
||||
Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the decision has already been returned — every redundant iteration costs subscription quota and time.
|
||||
|
||||
Emit the marker for BOTH `approve` and `reject` decisions — the decision is itself a terminal output, regardless of which way it went. Do NOT emit the marker if you still need to read more of the diff, run a tool, or otherwise have unfinished work.
|
||||
8
.socket.json
Normal file
8
.socket.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"issueRules": {
|
||||
"critical": "error",
|
||||
"high": "warn",
|
||||
"medium": "ignore",
|
||||
"low": "ignore"
|
||||
}
|
||||
}
|
||||
584
AGENTS.md
Normal file
584
AGENTS.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# AGENTS.md — Vertical Feature Monorepo
|
||||
|
||||
This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
|
||||
|
||||
> **Vocabulary:** Every cross-cutting term used in this repo (feature, use case, manifest, slice, conformance, dispatch, etc.) is defined in [`docs/glossary.md`](./docs/glossary.md). When in doubt about what a term means **here**, check the glossary first — it's the single source for shared vocabulary between humans and agents.
|
||||
|
||||
> **Commits:** Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <imperative subject>` (≤72 chars). Types: `feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert`. Use `!` for breaking changes. The sandcastle implementer + reviewer prompts enforce this; agents authoring autonomously MUST honor it.
|
||||
|
||||
> **Releases:** Versioning is hybrid (ADR-021) — root template + 5 feature packages version independently from `0.1.0`. release-please reads Conventional Commits and opens a rolling release PR on every merge to main; merging it cuts tagged releases. See [`docs/guides/releasing.md`](./docs/guides/releasing.md).
|
||||
|
||||
## Agent-driven development
|
||||
|
||||
This template assumes agents (Claude, Codex, etc.) will author most feature work. The orchestration substrate is [Sandcastle](https://github.com/mattpocock/sandcastle) — see [ADR-019](./docs/decisions/adr-019-sandcastle-for-agent-orchestration.md). Day-to-day entry points:
|
||||
|
||||
- `pnpm work next` / `ready` / `blocked` — DAG-aware task selection from `docs/work/`
|
||||
- `pnpm work dispatch` — print the next dispatch plan (planning mode, no agent invoked)
|
||||
- `pnpm work dispatch --execute` — invoke sandcastle (requires `ANTHROPIC_API_KEY`)
|
||||
- `.sandcastle/` — 5 prompt templates (PRD eliciter, ADR eliciter, decomposer, implementer, reviewer); all enforce **generator-first** (`pnpm turbo gen <kind>` over hand-rolling)
|
||||
|
||||
Every feature has a `src/feature.manifest.ts` declaring its use cases AND its coverage bands. Every `bindProductionX(ctx)` and `bindDevSeedX(ctx)` self-asserts at its tail via `assertFeatureConformance(...)`. Quality is enforced by two parallel multi-latency systems:
|
||||
|
||||
- **Conformance** (5 gates) — TypeScript brands (0s), ESLint (<1s), boot (~3s), `pnpm conformance` (~120s), `pnpm fallow` (~30–60s). Catches manifest↔code drift. See `docs/guides/conformance-quickref.md`.
|
||||
- **Coverage** (4 layers, ADR-020) — L0 vitest thresholds, L1 `pnpm coverage:diff` (cover-the-diff gate), L2 `pnpm coverage:aggregate` → committed `coverage/summary.json`, L3 `pnpm mutate` (nightly). The manifest's `coverage.bands` is the single source of truth. See `docs/guides/coverage.md`.
|
||||
|
||||
See `docs/guides/runbook.md` for the full workflow.
|
||||
|
||||
---
|
||||
|
||||
## Package Map
|
||||
|
||||
| Package | Tag | Purpose |
|
||||
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
|
||||
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) — **optional**, scaffold via `pnpm turbo gen core-package ui` |
|
||||
| `@repo/core-audit` | core | DPA-compliant audit logging (4 impls, GDPR erasure, OTel correlation) — **optional**, scaffold via `pnpm turbo gen core-package audit` |
|
||||
| `@repo/core-api` | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only |
|
||||
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||||
| `@repo/core-trpc` | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
|
||||
| `@repo/auth` | feature | Users collection + sign-in/up/out |
|
||||
| `@repo/blog` | feature | Articles collection + article use-cases |
|
||||
| `@repo/media` | feature | Media collection + upload helpers |
|
||||
| `@repo/marketing-pages` | feature | Pages collection + SiteSettings global |
|
||||
| `@repo/navigation` | feature | Header global |
|
||||
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
|
||||
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
|
||||
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
|
||||
|
||||
---
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
### Five tags
|
||||
|
||||
- **app** (4 packages) — `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook`
|
||||
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
|
||||
- **core** (1–2 packages) — `packages/core-shared`; `core-ui` is optional (scaffold with `pnpm turbo gen core-package ui`)
|
||||
- **feature** (5 packages) — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
|
||||
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`
|
||||
|
||||
### Allowed dependency directions
|
||||
|
||||
| Tag | May depend on |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| app | app, core, core-composition, feature, tooling |
|
||||
| core-composition | core, core-composition, feature, tooling |
|
||||
| core | core, core-composition, tooling |
|
||||
| feature | core, feature, tooling |
|
||||
| tooling | tooling |
|
||||
|
||||
### Composition exceptions
|
||||
|
||||
1. **`core-api`** may import `@repo/<feature>/api` subpath exports only (to compose tRPC routers).
|
||||
2. **`core-cms`** may import `@repo/<feature>/cms` subpath exports only (to compose Payload collections).
|
||||
3. **`core-trpc`** reaches features transitively through `core-api`'s `AppRouter` type.
|
||||
|
||||
No other cross-package boundary deviations are permitted.
|
||||
|
||||
### Four enforcement layers
|
||||
|
||||
1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
|
||||
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist.
|
||||
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`:
|
||||
- Enforces the five-tag rules at linting
|
||||
- Feature packages may import from `core`, tooling, and other features' public exports (the `@repo/<feature>` contract barrel — e.g. an event contract a consumer subscribes to). They must not reach another feature's internals (the `exports` map seals those) or call its use cases directly — cross-feature behaviour flows through `IEventBus`.
|
||||
- `core-shared`, `core-ui` may not import any feature.
|
||||
- `core-api` restricted to `@repo/<feature>/api` imports.
|
||||
- `core-cms` restricted to `@repo/<feature>/cms` imports.
|
||||
- No `../../../` cross-package relative imports.
|
||||
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
|
||||
- Validates the entire workspace dependency graph, including transitive dependencies
|
||||
- Catches issues ESLint might miss (e.g., transitive feature reaches through composition packages)
|
||||
- Run with `pnpm turbo boundaries`
|
||||
|
||||
---
|
||||
|
||||
## Adding a Feature
|
||||
|
||||
**Fast path — use the generator.** `pnpm turbo gen feature` scaffolds a package under `packages/<name>/` (single entity, single `getX` use case) matching the `navigation` reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories, DI container, both binders (`bind-production` / `bind-dev-seed`), tRPC procedures + router with tests, contract suite, dev seed, and an empty `ui/` barrel — all wired with the span + capture sandwich at bind time.
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature # interactive
|
||||
pnpm turbo gen feature --args widgets Widget widgets # non-interactive: <name> <Entity> <entities-plural>
|
||||
```
|
||||
|
||||
The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit `apps/web-next/src/server/bind-production.ts`, `packages/core-api/src/root.ts`, and the two `package.json` files (the generator prints the exact checklist on success). See `docs/guides/scaffolding-a-feature.md` for the full reference.
|
||||
|
||||
**Manual path.** When the generator's scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow `docs/guides/adding-a-feature.md` — a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation.
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||||
pnpm typecheck # Type-check all packages
|
||||
pnpm lint # Lint all packages (ESLint boundaries enforced)
|
||||
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
|
||||
pnpm turbo gen feature # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
|
||||
pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/guides/scaffolding-core-package.md)
|
||||
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/guides/scaffolding-core-ui-component.md)
|
||||
pnpm test # Run all unit + integration tests (Vitest)
|
||||
pnpm test:e2e # Run e2e tests (Playwright across both apps)
|
||||
pnpm build # Build all packages (Turborepo)
|
||||
docker compose up -d # Start PostgreSQL
|
||||
|
||||
# Filtered commands
|
||||
pnpm dev --filter @repo/web-next # Only Next.js app
|
||||
pnpm dev --filter @repo/cms # Only CMS admin
|
||||
pnpm dev --filter @repo/storybook # Only Storybook
|
||||
pnpm typecheck --filter @repo/blog # Only blog feature
|
||||
pnpm test --filter @repo/blog # Only blog unit/integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Per-Package Conventions
|
||||
|
||||
> Canonical summary: `CLAUDE.md` § Key Conventions.
|
||||
> Decision records: `docs/decisions/adr-012-feature-conventions.md` and `docs/decisions/adr-013-input-output-unification.md`.
|
||||
|
||||
### Source files use RELATIVE imports (not @/)
|
||||
|
||||
Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
```
|
||||
|
||||
Entity models live at `entities/models/<x>.ts`; domain errors at `entities/errors/<domain>.ts`; the shared `InputParseError` at `entities/errors/common.ts`.
|
||||
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`).
|
||||
|
||||
This keeps source code portable and avoids circular alias issues.
|
||||
|
||||
### Test files use @/ alias
|
||||
|
||||
Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||
```
|
||||
|
||||
### vitest.config.ts MUST declare @/ alias
|
||||
|
||||
Every package's `vitest.config.ts` must define the alias:
|
||||
|
||||
```typescript
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: "node", globals: true },
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### tsconfig.json rootDir = "."
|
||||
|
||||
TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files to coexist:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
### Use cases own input + output schemas
|
||||
|
||||
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||||
import { z } from "zod";
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticlesInputSchema = z
|
||||
.object({ status: z.string().optional(), limit: z.number().int().optional() })
|
||||
.strict();
|
||||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||
|
||||
export const getArticlesUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||||
const result = await articlesRepository.getArticles(input);
|
||||
return getArticlesOutputSchema.parse(result);
|
||||
};
|
||||
```
|
||||
|
||||
Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`, `deleteMediaUseCase`) export only `xInputSchema` — no `xOutputSchema`.
|
||||
|
||||
Tests inject mocks directly — no container rebinding:
|
||||
|
||||
```typescript
|
||||
const repo = new MockArticlesRepository([]);
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const articles = await useCase({ status: "published" });
|
||||
```
|
||||
|
||||
### Controllers receive `unknown` + presenter
|
||||
|
||||
Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
getArticlesInputSchema,
|
||||
type GetArticlesOutput,
|
||||
type IGetArticlesUseCase,
|
||||
} from "../../application/use-cases/get-articles.use-case";
|
||||
|
||||
function presenter(value: GetArticlesOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||||
|
||||
export const getArticlesController =
|
||||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getArticlesInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid input", { cause: parsed.error });
|
||||
}
|
||||
return presenter(await getArticlesUseCase(parsed.data));
|
||||
};
|
||||
```
|
||||
|
||||
Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Promise<void>` and skip the presenter entirely. One controller file per use case — no multi-method controller files.
|
||||
|
||||
DI binds each factory with `.toDynamicValue()`:
|
||||
|
||||
```typescript
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
|
||||
(ctx) =>
|
||||
getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)),
|
||||
);
|
||||
```
|
||||
|
||||
### Feature-scoped tRPC error mapping
|
||||
|
||||
Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/integrations/api/procedures.ts
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
|
||||
export const blogProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[InputParseError, "BAD_REQUEST"],
|
||||
[ArticleNotFoundError, "NOT_FOUND"],
|
||||
]),
|
||||
);
|
||||
```
|
||||
|
||||
The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
|
||||
|
||||
### Per-feature public-API surface
|
||||
|
||||
Each feature package exposes exactly these subpath exports:
|
||||
|
||||
| Subpath | What it exports | Who consumes |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
|
||||
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
|
||||
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
||||
| `./cms` | Payload collections | `@repo/core-cms` only |
|
||||
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract) | Other feature packages |
|
||||
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
||||
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
||||
|
||||
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||||
|
||||
### Feature UI structure
|
||||
|
||||
Each feature's `src/ui/` follows this layout:
|
||||
|
||||
```
|
||||
src/ui/
|
||||
index.ts # Barrel — exports server components as public API
|
||||
query.ts # Query builder functions (framework-agnostic)
|
||||
hooks/
|
||||
use-<entity>.ts # "use client" — wraps useTRPC + useSuspenseQuery
|
||||
components/
|
||||
<entity>-list.server.tsx # Server — DI + prefetch + HydrationBoundary (public)
|
||||
<entity>-list.client.tsx # "use client" — calls hook (internal only)
|
||||
<entity>-card.tsx # Presentational (receives props)
|
||||
```
|
||||
|
||||
Server components (`.server.tsx`) are the public API — the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal — only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<ArticleList />`, `<PageContent slug="about" />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide.
|
||||
|
||||
### Payload-backed features use constructor injection
|
||||
|
||||
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
|
||||
|
||||
```typescript
|
||||
// packages/blog/src/infrastructure/repositories/articles.repository.ts
|
||||
@injectable()
|
||||
export class ArticlesRepository implements IArticlesRepository {
|
||||
constructor(private config: SanitizedConfig) {}
|
||||
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<Article[]> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).
|
||||
|
||||
### Apps call `bindAll()` per feature at boot
|
||||
|
||||
Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:
|
||||
|
||||
- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`)
|
||||
- `NODE_ENV === "production"` → production (real Payload)
|
||||
- otherwise → dev seed (developer default; `pnpm dev` boots without Payload)
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/server/bind-production.ts
|
||||
// Slim default template — no optional packages scaffolded yet.
|
||||
// After running e.g. `pnpm turbo gen core-package events`, the full
|
||||
// IEventBus type can be plugged via the generic args
|
||||
// `BindProductionContext<IEventBus, ...>` and the bus/queue construction
|
||||
// (resolveEventsAndJobsProduction) wires back in per the printed next-steps.
|
||||
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
|
||||
|
||||
export async function bindAllProduction(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
const resolvedConfig = await config;
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
bindProductionBlog(ctx);
|
||||
bindProductionMarketingPages(ctx);
|
||||
bindProductionNavigation(ctx);
|
||||
bindProductionMedia(ctx);
|
||||
}
|
||||
|
||||
export async function bindAllDevSeed(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
|
||||
const ctx: BindContext<
|
||||
IEventBus,
|
||||
IRealtimeBroadcaster,
|
||||
IRealtimeHandlerRegistry
|
||||
> = {
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
await bindDevSeedBlog(ctx);
|
||||
// ... (same for marketing-pages, navigation, media)
|
||||
}
|
||||
```
|
||||
|
||||
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
|
||||
|
||||
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
||||
|
||||
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath. See the cross-feature readers ADR for full design.
|
||||
|
||||
---
|
||||
|
||||
### Conformance contract (every feature)
|
||||
|
||||
Every feature package MUST declare a `src/feature.manifest.ts` using `defineFeature` from `@repo/core-shared/conformance`. The manifest declares the use cases, what they audit/publish/consume, and which optional cores they require.
|
||||
|
||||
The feature's `src/di/bind-production.ts` MUST call `assertFeatureConformance(container, manifest, symbols, ctx)` at the tail of `bindProduction<Name>` so `pnpm dev` refuses to boot if a binding loses its brand.
|
||||
|
||||
Re-export the manifest from `src/index.ts`:
|
||||
|
||||
```ts
|
||||
export { fooManifest, type FooManifest } from "./feature.manifest";
|
||||
```
|
||||
|
||||
See `docs/guides/conformance-quickref.md` for the canonical pattern; the generator (`pnpm turbo gen feature <name>`) emits all of this correctly by default.
|
||||
|
||||
---
|
||||
|
||||
### Cross-feature events and background jobs (ADR-015)
|
||||
|
||||
Three rules:
|
||||
|
||||
- **E0:** Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus.
|
||||
- **E1:** Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-\* files (never re-exported, ESLint-enforced).
|
||||
- **J0:** Jobs are for _deferred_ work, not abstraction. Synchronous code stays synchronous.
|
||||
|
||||
`@repo/core-events` provides `IEventBus` (`InMemoryEventBus` for dev/test, `PayloadJobsEventBus` for prod). `@repo/core-shared/jobs` provides `IJobQueue` (`InMemoryJobQueue` / `PayloadJobQueue`). Both are swapped by `bindAll()` using the same `USE_DEV_SEED` / `NODE_ENV` rules as repositories.
|
||||
|
||||
Per-feature folders (all optional): `events/<x>.event.ts`, `events/handlers/on-<publisher>-<event>.handler.ts`, `jobs/<x>.job.ts`, `integrations/cms/jobs/<x>.task.ts`.
|
||||
|
||||
Use the generators: `pnpm turbo gen event {publish|consume}`, `pnpm turbo gen job`. They insert at six fixed `// <gen:*>` anchor comments present in every feature.
|
||||
|
||||
See `docs/guides/events-and-jobs.md` and `docs/decisions/adr-015-events-and-jobs.md`.
|
||||
|
||||
---
|
||||
|
||||
### Realtime layer (ADR-016)
|
||||
|
||||
Three rules:
|
||||
|
||||
- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when the server needs to push without a request, or the data is too high-frequency for HTTP.
|
||||
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`).
|
||||
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this.
|
||||
|
||||
`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000.
|
||||
|
||||
Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// <gen:realtime-*>` anchor comments per feature.
|
||||
|
||||
See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
|
||||
|
||||
---
|
||||
|
||||
## Instrumentation conventions
|
||||
|
||||
Substrate: **OpenTelemetry SDK** (ADR-017). Sentry is wired as the exporter via `@sentry/opentelemetry`. Vendor swaps are exporter swaps — feature code never touches Sentry or OTel SDK directly.
|
||||
|
||||
**Symbols (in `core-shared/instrumentation/symbols.ts`):**
|
||||
|
||||
- `INSTRUMENTATION_SYMBOLS.ITracer` — bound to `ITracer` (`NoopTracer` / `OtelTracer`)
|
||||
- `INSTRUMENTATION_SYMBOLS.ILogger` — bound to `ILogger` (`NoopLogger` / `OtelLogger`)
|
||||
- `INSTRUMENTATION_SYMBOLS.IMetrics` — bound to `IMetrics` (`NoopMetrics` / `OtelMetrics`)
|
||||
|
||||
**Repository constructor signature (every feature):**
|
||||
|
||||
```ts
|
||||
constructor(
|
||||
config: SanitizedConfig,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
)
|
||||
```
|
||||
|
||||
**Repository method body (every public async method):**
|
||||
|
||||
```ts
|
||||
return this.tracer.startSpan(
|
||||
{ name: "<entity>.<method>", op: "repository", attributes: { /* ... */ } },
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await /* payload op */;
|
||||
span.setAttribute("count", /* ... */);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: "<feature>", repo: "<entity>", method: "<method>" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Use case + controller spans + capture (applied at DI bind time):**
|
||||
|
||||
```ts
|
||||
const wrappedUC = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
|
||||
getArticlesUseCase(repo),
|
||||
),
|
||||
);
|
||||
const wrappedCtrl = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedUC),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
`withSpan` is outermost; `withCapture` is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call `tracer` / `logger` inline.
|
||||
|
||||
**Capture rules** (each error captured exactly once via the `__sentryReported` flag from `core-shared/instrumentation/reported-flag.ts`):
|
||||
|
||||
| Layer | Captures | Doesn't capture |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Repository | Infra/Payload errors that originate here (inline in catch) | Bubbled errors |
|
||||
| Use case | Business-rule violations + output-schema failures originated in this body (via `withCapture`) | Errors from repos — flag set, `withCapture` bails |
|
||||
| Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases — flag set, `withCapture` bails |
|
||||
| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — |
|
||||
|
||||
**Boundary rules (eslint-enforced):**
|
||||
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
|
||||
|
||||
- `@sentry/*`: `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
|
||||
- `@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`: `**/instrumentation/otel/**`
|
||||
|
||||
The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.
|
||||
|
||||
**Test rules:**
|
||||
|
||||
- Default to `NoopTracer` / `NoopLogger` / `NoopMetrics` (constructor defaults)
|
||||
- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` from `@repo/core-testing/instrumentation`
|
||||
- Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-instrumentation.ts`; old alias `no-sentry` kept for one release)
|
||||
|
||||
---
|
||||
|
||||
## Specification & Guides
|
||||
|
||||
- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
|
||||
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
|
||||
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
|
||||
- **Scaffolding a Feature** — `docs/guides/scaffolding-a-feature.md` — `turbo gen feature` reference (fast path)
|
||||
- **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough (manual path)
|
||||
- **Events and Jobs Guide** — `docs/guides/events-and-jobs.md` — publish, consume, schedule background work
|
||||
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
|
||||
- **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e
|
||||
- **TDD Workflow** — `docs/guides/tdd-workflow.md` — red-green-refactor cycle, mocking decision tree, coverage targets
|
||||
|
||||
Per-package documentation lives in each `AGENTS.md`:
|
||||
|
||||
- `packages/core-shared/AGENTS.md`
|
||||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
|
||||
- `packages/core-ui/AGENTS.md` (optional — generated by `pnpm turbo gen core-package ui`; see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`)
|
||||
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
|
||||
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
|
||||
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `web-tanstack/AGENTS.md`, `storybook/AGENTS.md`
|
||||
18
CHANGELOG.md
Normal file
18
CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Changelog — template-vertical
|
||||
|
||||
All notable changes to this template at the root level. Per-feature changelogs live at `packages/<feature>/CHANGELOG.md`.
|
||||
|
||||
This file is maintained by [release-please](https://github.com/googleapis/release-please) — do not edit manually. Edits land via the rolling release PR triggered by merges to `main`. See [ADR-021](./docs/decisions/adr-021-versioning-and-changelog.md) for the architecture and [`docs/guides/releasing.md`](./docs/guides/releasing.md) for the day-to-day reference.
|
||||
|
||||
## 0.1.0 (2026-05-13)
|
||||
|
||||
### Initial baseline
|
||||
|
||||
- Hybrid versioning established (ADR-021): root template + 5 feature packages each version independently.
|
||||
- Conventional Commits required for every commit (CLAUDE.md Key Conventions).
|
||||
- Coverage architecture shipped (ADR-020): L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 `pnpm coverage:aggregate`, L3 `pnpm mutate`.
|
||||
- Manifest-driven coverage bands in every `feature.manifest.ts`.
|
||||
- Sandcastle agent orchestration (ADR-019) + PRD-lifecycle automation (`pnpm work prd-ship`).
|
||||
- 5 features (auth / blog / media / marketing-pages / navigation) all green on declared L0 bands.
|
||||
|
||||
Future entries appear above this section as release-please assembles them from conventional commits since the last release.
|
||||
155
CLAUDE.md
Normal file
155
CLAUDE.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Clean Architecture Monorepo Template
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm install # Install + auto-wire husky pre-commit hooks
|
||||
pnpm dev # Start all dev servers
|
||||
pnpm build # Build all packages
|
||||
pnpm test # Run all tests
|
||||
pnpm typecheck # TypeScript across all packages
|
||||
pnpm lint # ESLint (incl. 15 conformance/* rules)
|
||||
pnpm conformance # Cross-feature event closure
|
||||
pnpm fallow # Whole-codebase: dead exports, dupes, complexity
|
||||
pnpm fallow:audit # AI-change audit (run before commits)
|
||||
pnpm coverage:aggregate # Merge per-package lcovs -> coverage/lcov.info + summary.json (L2)
|
||||
pnpm coverage:diff # Cover-the-diff gate; JSON to stdout (L1, ADR-020)
|
||||
pnpm mutate # Stryker mutation testing on entities + use-cases (L3, on-demand)
|
||||
pnpm turbo boundaries # Workspace dependency graph
|
||||
pnpm work status # docs/work/ epic + story state
|
||||
pnpm work next # Next ready story
|
||||
pnpm work dispatch # Print next dispatch plan (use --execute to invoke sandcastle)
|
||||
pnpm turbo gen feature # Scaffold a new feature package
|
||||
pnpm turbo gen event # Scaffold an event contract or handler
|
||||
pnpm turbo gen job # Scaffold a background job
|
||||
pnpm turbo gen realtime # Scaffold a realtime channel or handler
|
||||
pnpm turbo gen reader # Scaffold a cross-feature reader
|
||||
pnpm turbo gen core-package # Scaffold an optional core package
|
||||
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
|
||||
docker compose up -d # Start PostgreSQL
|
||||
```
|
||||
|
||||
**First time?** Read [`docs/guides/runbook.md`](./docs/guides/runbook.md) end-to-end.
|
||||
|
||||
## TDD
|
||||
|
||||
```bash
|
||||
pnpm test --watch --filter @repo/<feature> # watch one feature
|
||||
pnpm test -- --coverage # full run with coverage
|
||||
pnpm test:stories # Storybook smoke tests
|
||||
pnpm test:e2e # Playwright e2e
|
||||
```
|
||||
|
||||
See `docs/guides/tdd-workflow.md` for the full cycle.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) owns its Clean Architecture layers. Must-have core packages (`core-shared`, `core-cms`, `core-api`) provide foundation; five optional core packages (`core-realtime`, `core-events`, `core-trpc`, `core-ui`, `core-audit`) scaffold on demand via `pnpm turbo gen core-package <name>` (see `docs/architecture/template-tiers.md`). Two tooling packages (`core-eslint`, `core-typescript`) provide shared configs. Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time). Supports Next.js and TanStack Start as frontend frameworks, Payload CMS for content management, and comprehensive agent-optimized documentation.
|
||||
|
||||
## Read First
|
||||
|
||||
- `docs/glossary.md` — **Canonical vocabulary** for the monorepo. Resolves "what does X mean here?" for every cross-cutting term (feature, use case, manifest, conformance, slice, dispatch, etc.). Shared between humans and agents.
|
||||
- `AGENTS.md` — Package map, boundary rules, per-package conventions
|
||||
- `docs/architecture/overview.md` — High-level architecture and package responsibilities
|
||||
- `docs/architecture/vertical-feature-spec.md` — Design spec with rationale and decision log
|
||||
- `docs/guides/scaffolding-a-feature.md` — `turbo gen feature` reference (fast path; prefer this over the manual walkthrough)
|
||||
- `docs/guides/adding-a-feature.md` — End-to-end new feature walkthrough (manual path; for cases the generator's scope doesn't cover)
|
||||
- `docs/guides/events-and-jobs.md` — publish/consume/schedule cookbook (cross-feature events + background jobs; _requires `gen core-package events`_)
|
||||
- `docs/guides/realtime.md` — Socket.IO channels, broadcasts, handlers (_requires `gen core-package realtime`_)
|
||||
- `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (_requires `gen core-package audit`_)
|
||||
- `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020)
|
||||
- `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)
|
||||
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
|
||||
- `docs/guides/building-feature-ui.md` — Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiring
|
||||
- `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
|
||||
|
||||
## Conformance system
|
||||
|
||||
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, reads (cross-feature reader deps), required cores, `rateLimit?: RateLimitBudget[]` (when applicable, for per-use-case rate-limit budgets), and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies:
|
||||
|
||||
| Layer | Latency | Catches |
|
||||
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| **TypeScript brands** | 0s | forgotten `withSpan` / `withCapture` / `withAudit` at bind time |
|
||||
| **ESLint rules** | <1s | manifest ↔ code drift; undeclared `bus.publish` / `auditLog.record`; missing manifest; missing sibling test |
|
||||
| **Boot assertion** (`pnpm dev`) | ~3s | binding without required brand at runtime; manifest edited without rebinder |
|
||||
| **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features |
|
||||
| **Fallow** (`pnpm fallow`) | ~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
|
||||
|
||||
The sixteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `no-undeclared-reader` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference.
|
||||
|
||||
### Sibling architecture: coverage (ADR-020)
|
||||
|
||||
Coverage runs in parallel to the 5-gate conformance system above — same multi-latency philosophy, different signal. Each feature's `feature.manifest.ts` declares a `coverage.bands` section that vitest (test-time), `pnpm coverage:diff` (CI/agent-loop), and `pnpm mutate` (nightly) all read from. Four layers:
|
||||
|
||||
| Layer | Catches | Surface |
|
||||
| ---------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| **L0** Per-layer vitest thresholds | Drift below declared bands (entities/use-cases/controllers at 100%) | `pnpm test -- --coverage` |
|
||||
| **L1** Diff coverage | Changed line not exercised by tests | `pnpm coverage:diff` — CI-gated on PRs + dispatch post-task |
|
||||
| **L2** Aggregate trend | Codebase coverage drifted over time | `pnpm coverage:aggregate` → committed `coverage/summary.json` |
|
||||
| **L3** Mutation testing | Tests that exist + execute the code but assert nothing | `pnpm mutate` — on-demand + nightly GH Action |
|
||||
|
||||
See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rationale. Agents running in sandcastle: run `pnpm coverage:diff` before reporting `complete` — the implementer and reviewer prompts enforce this.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Conventional Commits (non-negotiable)** — Every commit message MUST follow the [Conventional Commits](https://www.conventionalcommits.org/) spec: `<type>(<scope>): <imperative subject>` (≤72 chars). Types: `feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert`. Use `!` after type/scope for breaking changes. Body explains WHY if non-obvious. Examples: `feat(auth): hash password before persisting`, `test(blog): assert article not found error`, `refactor(docs)!: consolidate scaffolding into guides`. The sandcastle implementer + reviewer prompts both enforce this; agents authoring commits autonomously MUST honor it. Commits become versions + changelog entries automatically via release-please (ADR-021 / `docs/guides/releasing.md`).
|
||||
- **Versioning is hybrid (ADR-021)** — Root template (`template-vertical`) + 5 feature packages (`@repo/{auth,blog,media,marketing-pages,navigation}`) each version independently from `0.1.0`. release-please reads Conventional Commits since the last tag and opens a rolling release PR on every merge to main; merging it cuts per-package tags (`template-v0.2.0`, `auth-v0.1.1`, etc.) + GitHub releases. **Bump targeting is by commit path** — files under `packages/<feature>/**` bump that feature; cross-cutting paths (`docs/`, `scripts/`, `.github/`, root configs) bump the root. Pre-1.0 policy: `feat:` → patch, `feat!:` → minor.
|
||||
- **Relative imports in `src/`** — Source files use relative paths (`../repositories/...`), not `@/` alias
|
||||
- **`@/` alias in tests** — Test files (`*.test.ts`) use `@/` to import from `src/`
|
||||
- **`vitest.config.ts`** — Every package must define `resolve.alias: { "@": path.resolve(__dirname, "./src") }`
|
||||
- **`tsconfig.json` rootDir** — Set `"rootDir": "."` so TypeScript finds both `src/` and test files
|
||||
- **File layout convention** — Entities live at `entities/models/<x>.ts`; errors at `entities/errors/<domain>.ts` + `entities/errors/common.ts`; mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `payload-` prefix (`<x>.repository.ts`); interface filenames are dot-separated (`<x>.repository.interface.ts`)
|
||||
- **Factory-function use cases & controllers** — Every use case and controller is `(deps) => async (input) => result`; each exports `export type I*UseCase = ReturnType<typeof xUseCase>` (and the analogous `I*Controller`); one controller per use case (no multi-method controllers)
|
||||
- **DI uses `.toDynamicValue()` for factories** — `bind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...)))`; mocks remain the default binding
|
||||
- **Tests inject mocks directly** — Construct `MockXRepository` and pass into the factory: `signInUseCase(mockUsers, mockAuth)(input)`. No container rebinding in unit tests
|
||||
- **Schemas in the use-case file** — Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer<typeof xInputSchema>` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data)
|
||||
- **Controllers receive `unknown` + presenter** — Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise<ReturnType<typeof presenter>>` (identity is fine — `return value`); void controllers return `Promise<void>` with no presenter
|
||||
- **Feature-scoped tRPC error mapping** — Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` — schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes
|
||||
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind `./ui` (`src/ui/index.ts`). Apps import hooks/components from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||
- **Feature UI owns its data fetching** — Each feature's `src/ui/hooks/` contains `"use client"` hooks that wrap `useTRPC` + `useSuspenseQuery`. Connected components in `src/ui/components/` call these hooks. App pages prefetch via `appRouter.createCaller({})` and hydrate via `HydrationBoundary` + `dehydrate` + `setQueryData`. See `docs/guides/building-feature-ui.md`
|
||||
- **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency
|
||||
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
|
||||
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
|
||||
- **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent
|
||||
- **Instrumentation lives in `core-shared/instrumentation/`** — Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017)
|
||||
- **Spans + capture composed at DI bind time** — Use cases + controllers are wrapped at DI bind time in this order (outermost → innermost): `withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`. Apply `withAudit` when the manifest declares `audits`, `withAnalytics` when it declares `analyticsEvents`, `withConsent` when it declares `requiresConsent`. `withSpan` is always outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes
|
||||
- **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures. Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`)
|
||||
- **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (CI grep gate); replay default-masks all text/inputs/media (allowlist starts empty); `setUser({ id })` only — no email/username; server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (ADR-017 §7)
|
||||
- **Three apps, three Sentry projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes
|
||||
- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → OTel+Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally
|
||||
- **Cross-feature events go through `IEventBus` (E0)** — In-feature reactions are direct use-case calls, not bus publishes. The bus is for _crossing_ feature boundaries (e.g. `auth` → `marketing-pages` welcome email)
|
||||
- **Event contracts are public; handlers are private (E1)** — Publisher's `events/<x>.event.ts` is exported from the feature root barrel. Consumer's `events/handlers/on-<publisher>-<event>.handler.ts` is never re-exported (ESLint-enforced via `core-eslint/rules/no-handler-reexport`)
|
||||
- **Jobs are for _deferred_ work, not abstraction (J0)** — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via `IJobQueue` only — direct `payload.jobs.queue()` is ESLint-blocked outside `core-shared/jobs/`
|
||||
- **Realtime is for state delivery, not for replacing tRPC (R0)** — Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
|
||||
- **Realtime channel descriptors are exported; handlers are private (R1)** — A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
|
||||
- **`socket.io` lives in `@repo/core-realtime` only (R2)** — Feature packages MUST NOT import `socket.io` or `socket.io-client`. ESLint rule `no-direct-socket-io` enforces this; allowlist covers `core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`
|
||||
- **Cross-feature domain queries go through readers (Q0)** — When a use case needs another vertical's domain-evaluated answer on the request path (e.g., permission check), use a reader (`I<Feature>Reader`). For raw data joins, use Payload `relationTo`. For reactions/side effects, use the event bus
|
||||
- **Reader contracts are public; implementations are private (Q1)** — The owning feature exports `I<Feature>Reader` from `./reader` subpath (`integrations/readers/`). The implementation (`<Feature>Reader`) is internal, constructed by the binder. Consumers import the type only
|
||||
- **Readers are strictly read-only; cross-feature writes go through events (Q2)** — A reader may only wrap use cases declared `mutates: false`. Enforced by `ReadOnly<F>` brand at compile time and `assertReaderPurity` at boot time
|
||||
- **Reader cycles are a design error (Q3)** — If Feature A reads from Feature B and vice versa, the boundaries are wrong. Break via: (a) UI composition at app layer, (b) event for one direction, (c) merge the features
|
||||
- **Readers wrap existing use cases, not repositories** — The reader is a thin facade; if the domain logic doesn't exist as a use case yet, create the use case first (manifest-first). No `MockReader` needed — same class works in dev-seed because the use cases beneath it are backed by mock repos
|
||||
- **Manifest `reads` field** — Use cases that query another feature's reader declare `reads: ["<feature>"]` in `feature.manifest.ts`. Verified by `assertFeatureConformance` at boot and `no-undeclared-reader` ESLint rule
|
||||
- **Binders return readers; `bindAll()` threads them** — `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. `bindAll()` passes it: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit — owning feature first, consumers after
|
||||
- **Manifest-first ordering** — for any new use case, the workflow is **(1) manifest entry** → **(2) contracts** (`xInputSchema`, `xOutputSchema`, `IXUseCase`) → **(3) tests (red)** → **(4) implementation (green)**. The generator emits the manifest + a self-asserting `bind-production.ts` so new features are conformance-compliant by default
|
||||
- **Self-asserting `bindProductionX(ctx)`** — every feature's bind-production calls `assertFeatureConformance(container, manifest, symbols, ctx)` at its tail. `pnpm dev` refuses to boot on drift
|
||||
- **`pnpm conformance`** — cross-feature event-closure and reader-closure check; fails CI on orphan consumers or unresolvable `reads` entries
|
||||
- **New runtime dependencies require a library trace** — adding a runtime dependency to a feature- or core-tier package requires a trace at `docs/library-decisions/<date>-<name>.md` produced by the `/evaluate-library` skill; see ADR-022 and `docs/guides/adding-a-library.md`
|
||||
- **CI security + supply-chain enforcement** — Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 + `docs/guides/ci-security.md`
|
||||
|
||||
## MCP Servers
|
||||
|
||||
Start Storybook before UI work: `pnpm dev --filter @repo/storybook`
|
||||
|
||||
Storybook MCP available at `http://localhost:6006/mcp` — use `list-all-documentation` to discover existing components before creating new ones.
|
||||
|
||||
## Key Ports
|
||||
|
||||
| Service | Port |
|
||||
| -------------- | ---- |
|
||||
| Next.js | 3000 |
|
||||
| Payload CMS | 3001 |
|
||||
| TanStack Start | 3002 |
|
||||
| PostgreSQL | 5432 |
|
||||
| Storybook | 6006 |
|
||||
114
README.md
Normal file
114
README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Clean Architecture Monorepo Template
|
||||
|
||||
Turborepo + pnpm monorepo organised by vertical features, with an **agent-first workflow** and **five conformance gates**.
|
||||
|
||||
This template is built for **agent-driven development**. [Sandcastle](https://github.com/mattpocock/sandcastle) is the orchestration substrate; `pnpm work dispatch` is the entry point. See [ADR-019](./docs/decisions/adr-019-sandcastle-for-agent-orchestration.md) for the decision rationale and [`docs/guides/runbook.md`](./docs/guides/runbook.md) for end-to-end usage.
|
||||
|
||||
## Start here
|
||||
|
||||
Read [`docs/guides/runbook.md`](./docs/guides/runbook.md) — day-1 onboarding (prerequisites, env vars, daily commands, troubleshooting, **Using Sandcastle for agent dispatch**).
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
pnpm install # Install + auto-wire husky pre-commit hooks
|
||||
pnpm dev # All dev servers (web-next:3000, cms:3001, web-tanstack:3002, storybook:6006)
|
||||
pnpm test # All tests
|
||||
pnpm typecheck # TypeScript across all packages
|
||||
pnpm lint # ESLint (incl. 8 conformance/* rules)
|
||||
pnpm conformance # Cross-feature event closure
|
||||
pnpm fallow # Whole-codebase: dead exports, dupes, complexity
|
||||
pnpm turbo boundaries # Workspace dependency graph
|
||||
pnpm work status # docs/work/ epic + story state
|
||||
docker compose up -d # Start PostgreSQL
|
||||
```
|
||||
|
||||
## Sandcastle setup (one-time)
|
||||
|
||||
Required only if you'll use `pnpm work dispatch --execute` or `pnpm work decompose <id> --execute` (agent dispatch). The dispatch loop runs the implementer / reviewer / decomposer agents inside an isolated Docker sandbox; the image is built once locally.
|
||||
|
||||
```bash
|
||||
# 1. Ensure Docker is running
|
||||
docker info >/dev/null
|
||||
|
||||
# 2. Build the sandcastle image (reads .sandcastle/Dockerfile)
|
||||
pnpm exec sandcastle docker build-image
|
||||
# Tags as: sandcastle:template-vertical (derived from the root package.json name)
|
||||
|
||||
# 3. Pick ONE auth path:
|
||||
# (a) Recommended — Claude Pro/Max subscription:
|
||||
claude login # one-time; ~/.claude/ becomes the auth source
|
||||
# (b) Fallback — API key:
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**macOS users**: subscription auth needs an extra step. Claude Code stores credentials in the macOS Keychain by default, so the host's `~/.claude/` directory has no `.credentials.json` for the sandbox to read. Two workarounds:
|
||||
|
||||
```bash
|
||||
# (preferred for macOS subscription users) extract keychain -> file once:
|
||||
security find-generic-password -s "Claude Code-credentials" -a "$USER" -w \
|
||||
> ~/.claude/.credentials.json
|
||||
chmod 600 ~/.claude/.credentials.json
|
||||
# Trade-off: credentials now live as a plaintext file at the path; refresh
|
||||
# when the token expires (re-run the same one-liner). The file is in your
|
||||
# home directory — chmod 600 + your home permissions are the protection.
|
||||
|
||||
# OR fall back to API key — no host changes needed:
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
Linux + WSL users with `claude login` write `~/.claude/.credentials.json` directly; nothing extra needed.
|
||||
|
||||
After the image exists, dispatch flows work without further setup:
|
||||
|
||||
```bash
|
||||
pnpm work dispatch # print plan (safe anywhere)
|
||||
pnpm work dispatch --execute # actually dispatch via sandcastle
|
||||
pnpm work decompose <prd-id> # print decompose plan
|
||||
pnpm work decompose <prd-id> --execute # decompose an approved PRD
|
||||
```
|
||||
|
||||
To rebuild the image after changing `.sandcastle/Dockerfile`:
|
||||
|
||||
```bash
|
||||
pnpm exec sandcastle docker remove-image
|
||||
pnpm exec sandcastle docker build-image
|
||||
```
|
||||
|
||||
See [`docs/guides/runbook.md` → Using Sandcastle](./docs/guides/runbook.md#using-sandcastle-for-agent-dispatch) for the full dispatch lifecycle, auth modes, and troubleshooting.
|
||||
|
||||
## Documentation map
|
||||
|
||||
- **[`docs/guides/runbook.md`](./docs/guides/runbook.md)** — start here
|
||||
- **[`CLAUDE.md`](./CLAUDE.md)** — full conventions reference
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — package map + boundary rules
|
||||
- **[`docs/guides/conformance-quickref.md`](./docs/guides/conformance-quickref.md)** — manifest + 5-gate daily reference
|
||||
- **[`docs/architecture/agent-first-workflow-and-conformance.md`](./docs/architecture/agent-first-workflow-and-conformance.md)** — full design
|
||||
- **[`docs/architecture/feature-conformance-explainer.html`](./docs/architecture/feature-conformance-explainer.html)** — interactive explainer
|
||||
|
||||
## Scaffolding
|
||||
|
||||
```bash
|
||||
pnpm turbo gen feature <name> # Scaffold a feature (manifest + contracts + tests)
|
||||
pnpm turbo gen event # Event contract or handler (requires gen core-package events)
|
||||
pnpm turbo gen job # Background job
|
||||
pnpm turbo gen realtime # Realtime channel (requires gen core-package realtime)
|
||||
pnpm turbo gen core-package <name> # Optional core: events / realtime / trpc / ui / audit
|
||||
pnpm turbo gen core-ui-component <name> # Atomic-design component
|
||||
```
|
||||
|
||||
**Generator-first is non-negotiable** — hand-rolled feature/event/job/realtime/component code is rejected by reviewer agents and may fail the CI scaffold-drift check.
|
||||
|
||||
## Optional packages
|
||||
|
||||
Five core packages scaffold on demand:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen core-package realtime # Socket.IO realtime layer (ADR-016)
|
||||
pnpm turbo gen core-package events # Cross-feature events + Payload jobs (ADR-015)
|
||||
pnpm turbo gen core-package trpc # tRPC server setup
|
||||
pnpm turbo gen core-package ui # Design system
|
||||
pnpm turbo gen core-package audit # DPA-compliant audit logging (ADR-018)
|
||||
```
|
||||
|
||||
See [`docs/architecture/template-tiers.md`](./docs/architecture/template-tiers.md) for the full tier list.
|
||||
97
apps/cms/AGENTS.md
Normal file
97
apps/cms/AGENTS.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# AGENTS.md — apps/cms
|
||||
|
||||
**Thin shell** hosting the Payload CMS admin panel via Next.js. All CMS configuration (collections, globals, hooks, access control, `payload.config.ts`) lives in `@repo/core-cms`, which aggregates collections from feature packages.
|
||||
|
||||
## Purpose
|
||||
|
||||
This app exists solely to serve the Payload Admin UI. It contains no custom CMS code beyond Next.js routing boilerplate. All business knowledge lives in feature packages (`@repo/auth`, `@repo/blog`, etc.), which export their collections/globals via subpath exports (`.../cms`). `@repo/core-cms` composes them into a single Payload config.
|
||||
|
||||
## Port: 3001
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # Start PostgreSQL on port 5432
|
||||
pnpm dev --filter @repo/cms # http://localhost:3001/admin
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `next.config.mjs` | Minimal config wrapped with `withPayload()` from `@payloadcms/next` |
|
||||
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
|
||||
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/admin/[[...segments]]/page.tsx` | Auto-generated catch-all admin page (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/importMap.js` | Auto-generated Payload import map (DO NOT MODIFY) |
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** add collections, globals, or hooks in this app — put them in feature packages
|
||||
- **NEVER** create custom CMS logic here — use `@repo/core-cms`
|
||||
- **NEVER** modify auto-generated files under `src/app/(payload)/`
|
||||
- All Payload config changes go in `packages/core-cms/src/payload.config.ts`
|
||||
|
||||
## @payload-config Alias
|
||||
|
||||
The `tsconfig.json` points to `@repo/core-cms`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@payload-config": ["../../packages/core-cms/src/payload.config.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Payload imports `@payload-config`, it resolves to the composed config from `@repo/core-cms`, which in turn imports feature collections.
|
||||
|
||||
## Composition flow
|
||||
|
||||
```
|
||||
Feature 1 (@repo/blog)
|
||||
└─ src/integrations/cms/collections/articles.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 2 (@repo/auth)
|
||||
└─ src/integrations/cms/collections/users.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 3 (@repo/navigation)
|
||||
└─ src/integrations/cms/globals/header.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Core CMS (@repo/core-cms)
|
||||
└─ src/payload.config.ts
|
||||
imports all feature /cms exports
|
||||
calls buildConfig({ collections, globals })
|
||||
|
||||
This app (@repo/cms)
|
||||
└─ src/app/(payload)/layout.tsx
|
||||
loads config from @payload-config
|
||||
Payload CLI auto-generates admin routes
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
After adding/modifying collections in any feature's `/cms` folder:
|
||||
|
||||
```bash
|
||||
cd apps/cms && pnpm generate:types
|
||||
# Regenerates packages/core-cms/src/generated-types.ts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-cms` | Payload config + buildConfig |
|
||||
| `@payloadcms/next` | Next.js integration for Payload |
|
||||
| `payload` | Payload CMS core |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `sharp` | Image processing |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature collections:** each feature's `src/integrations/cms/` folder
|
||||
- **CMS composition:** `packages/core-cms/AGENTS.md`
|
||||
3
apps/cms/eslint.config.js
Normal file
3
apps/cms/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
23
apps/cms/instrumentation.ts
Normal file
23
apps/cms/instrumentation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// apps/cms/instrumentation.ts
|
||||
// CMS is server-only (Payload admin UI). No instrumentation-client.ts here —
|
||||
// Payload admin UI bundling is opinionated and the public DSN flow is
|
||||
// out-of-scope per spec §8.
|
||||
//
|
||||
// Initializes the OTel SDK here so PII scrub processors are active from the
|
||||
// very first request — before bindAll() fires (C1 fix).
|
||||
|
||||
export async function register() {
|
||||
if (
|
||||
process.env["NEXT_RUNTIME"] === "nodejs" ||
|
||||
process.env["NEXT_RUNTIME"] === "edge"
|
||||
) {
|
||||
const { initOtelServerNode } = await import(
|
||||
"@repo/core-shared/instrumentation/otel/init-server-node"
|
||||
);
|
||||
initOtelServerNode({
|
||||
dsn: process.env["CMS_SENTRY_DSN"] ?? "",
|
||||
serviceName: "cms",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
}
|
||||
}
|
||||
18
apps/cms/middleware.ts
Normal file
18
apps/cms/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest): NextResponse {
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({ mode });
|
||||
|
||||
const response = NextResponse.next();
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/cms/next-env.d.ts
vendored
Normal file
6
apps/cms/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
14
apps/cms/next.config.mjs
Normal file
14
apps/cms/next.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default withSentryConfig(withPayload(nextConfig), {
|
||||
silent: process.env.CI !== "true",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT_CMS,
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
37
apps/cms/package.json
Normal file
37
apps/cms/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@repo/cms",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'CMS build requires database — use docker compose or pnpm dev'",
|
||||
"dev": "next dev --port 3001",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate:types": "payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/next": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@payloadcms/ui": "^3.14.0",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"next": "^15.3.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default NotFound;
|
||||
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
1
apps/cms/src/app/(payload)/custom.scss
Normal file
1
apps/cms/src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
// Custom admin panel styles
|
||||
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { importMap } from "./admin/importMap.js";
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout
|
||||
config={config}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
81
apps/cms/src/middleware.test.ts
Normal file
81
apps/cms/src/middleware.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const responseMock = vi.hoisted(() => {
|
||||
function makeResponseMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
_store: store,
|
||||
headers: {
|
||||
set: vi.fn((k: string, v: string) => store.set(k, v)),
|
||||
get: vi.fn((k: string) => store.get(k) ?? null),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { makeResponseMock };
|
||||
});
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
next: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { middleware } from "../middleware";
|
||||
|
||||
const ALL_SIX_HEADERS = [
|
||||
"Strict-Transport-Security",
|
||||
"X-Frame-Options",
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Content-Security-Policy",
|
||||
] as const;
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return { headers: new Headers() } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe("cms middleware", () => {
|
||||
let mock: ReturnType<typeof responseMock.makeResponseMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = responseMock.makeResponseMock();
|
||||
vi.mocked(NextResponse.next).mockReturnValue(
|
||||
mock as unknown as ReturnType<typeof NextResponse.next>,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets all six security headers on the response", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
for (const header of ALL_SIX_HEADERS) {
|
||||
expect(mock._store.has(header)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set a nonce header", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
expect(mock._store.has("x-nonce")).toBe(false);
|
||||
});
|
||||
|
||||
it("CSP is permissive in development mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("CSP uses strict-dynamic in production mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'strict-dynamic'");
|
||||
});
|
||||
});
|
||||
20
apps/cms/src/payload.config.test.ts
Normal file
20
apps/cms/src/payload.config.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import config from "./payload.config";
|
||||
|
||||
describe("CMS app payload.config", () => {
|
||||
it("registers all feature collections", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["users", "articles", "pages", "media"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers all feature globals", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["site-settings", "header"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
3
apps/cms/src/payload.config.ts
Normal file
3
apps/cms/src/payload.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-export Payload config from @repo/core-cms.
|
||||
// This file exists so @payload-config resolves correctly in the CMS app.
|
||||
export { default } from "@repo/core-cms";
|
||||
24
apps/cms/tsconfig.json
Normal file
24
apps/cms/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload.config.ts"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1
apps/cms/tsconfig.tsbuildinfo
Normal file
1
apps/cms/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4
apps/cms/turbo.json
Normal file
4
apps/cms/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
7
apps/cms/vitest.config.ts
Normal file
7
apps/cms/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
|
||||
});
|
||||
2
apps/storybook/.eslintignore
Normal file
2
apps/storybook/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
storybook-static
|
||||
.storybook/storybook-static
|
||||
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
17
apps/storybook/.storybook/main.ts
Normal file
17
apps/storybook/.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
config.plugins = [tailwindPlugin.default(), ...(config.plugins || [])];
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/storybook/.storybook/preview.ts
Normal file
15
apps/storybook/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import "./storybook.css";
|
||||
import type { Preview } from "@storybook/react";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
4
apps/storybook/.storybook/storybook.css
Normal file
4
apps/storybook/.storybook/storybook.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../packages/core-ui/src";
|
||||
|
||||
@import "../../../packages/core-ui/src/styles/theme.css";
|
||||
136
apps/storybook/AGENTS.md
Normal file
136
apps/storybook/AGENTS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AGENTS.md — apps/storybook
|
||||
|
||||
Centralized Storybook instance for visual component development, documentation, and MCP integration for AI agents. Currently ships with an empty stories list — scaffold `@repo/core-ui` first to populate it.
|
||||
|
||||
## Purpose
|
||||
|
||||
Visual testing and documentation hub for the design system. When `@repo/core-ui` is scaffolded, stories live colocated with their components there. Storybook serves as the single source of truth for component usage.
|
||||
|
||||
> **core-ui is optional.** Scaffold it with `pnpm turbo gen core-package ui`, then add the stories glob and CSS import (see next-steps printed by the generator).
|
||||
|
||||
## Port: 6006
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/storybook # http://localhost:6006
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### `.storybook/main.ts`
|
||||
|
||||
Stories are empty by default. After scaffolding `@repo/core-ui`, add the glob:
|
||||
|
||||
```typescript
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: { autodocs: "tag" },
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindPlugin.default()],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- **`stories` glob** — empty by default; add `"../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"` after scaffolding core-ui
|
||||
- **`viteFinal`** — adds Tailwind v4 plugin so classes render in Storybook
|
||||
- **`autodocs: "tag"`** — auto-generates docs for tagged stories
|
||||
|
||||
### `.storybook/preview.ts`
|
||||
|
||||
After scaffolding `@repo/core-ui`, import global styles here:
|
||||
|
||||
```typescript
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "@repo/core-ui/styles/globals.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Story Organization
|
||||
|
||||
Stories are organized by Atomic Design level via the `title` field:
|
||||
|
||||
| Level | Title format | Sidebar path |
|
||||
|---|---|---|
|
||||
| Atom | `"Atoms/{ComponentName}"` | Atoms > ComponentName |
|
||||
| Molecule | `"Molecules/{ComponentName}"` | Molecules > ComponentName |
|
||||
| Organism | `"Organisms/{ComponentName}"` | Organisms > ComponentName |
|
||||
| Template | `"Templates/{ComponentName}"` | Templates > ComponentName |
|
||||
|
||||
Example story file (after scaffolding core-ui at `packages/core-ui/src/atoms/button/button.stories.tsx`):
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Click me" },
|
||||
};
|
||||
|
||||
export const Variant: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
```
|
||||
|
||||
## MCP Integration
|
||||
|
||||
When Storybook runs, the MCP endpoint is available at:
|
||||
|
||||
```
|
||||
http://localhost:6006/mcp
|
||||
```
|
||||
|
||||
### Available tools:
|
||||
|
||||
- **`list-all-documentation`** — Lists all component stories and their properties
|
||||
- **`get-documentation`** — Gets detailed component info (props, variants, usage examples)
|
||||
- **`run-story-tests`** — Validates story rendering
|
||||
|
||||
### Before building new components:
|
||||
|
||||
1. Query `list-all-documentation` to check if a similar component exists
|
||||
2. Query `get-documentation` to understand existing props and variants
|
||||
3. After creating: `run-story-tests` to validate
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-ui` | Component source + stories (optional — scaffold with `pnpm turbo gen core-package ui`) |
|
||||
| `@storybook/react-vite` | Storybook with Vite bundler |
|
||||
| `@storybook/addon-essentials` | Controls, Actions, Docs, Backgrounds |
|
||||
| `@tailwindcss/vite` | Vite plugin for Tailwind v4 |
|
||||
| `storybook` | Storybook CLI + dev server |
|
||||
| `tailwindcss` | Tailwind CSS v4 |
|
||||
| `vite` | Build tool |
|
||||
| `react` / `react-dom` | React 19 |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Component source (when scaffolded):** `packages/core-ui/AGENTS.md`
|
||||
- **Scaffold core-ui:** `pnpm turbo gen core-package ui`
|
||||
- **Storybook docs:** `.storybook/` folder
|
||||
3
apps/storybook/eslint.config.js
Normal file
3
apps/storybook/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
35
apps/storybook/package.json
Normal file
35
apps/storybook/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@repo/storybook",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Storybook build — use pnpm dev for development'",
|
||||
"build:storybook": "storybook build",
|
||||
"build-storybook": "storybook build",
|
||||
"dev": "storybook dev -p 6006",
|
||||
"lint": "eslint .",
|
||||
"test-storybook": "test-storybook --url http://localhost:6006",
|
||||
"test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.6.0",
|
||||
"@storybook/react": "^8.6.0",
|
||||
"@storybook/react-vite": "^8.6.0",
|
||||
"@storybook/test-runner": "^0.19.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"http-server": "^14.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"storybook": "^8.6.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vite": "^6.3.0",
|
||||
"wait-on": "^8.0.0"
|
||||
}
|
||||
}
|
||||
13
apps/storybook/test-runner.config.ts
Normal file
13
apps/storybook/test-runner.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { TestRunnerConfig } from "@storybook/test-runner";
|
||||
|
||||
const config: TestRunnerConfig = {
|
||||
async preVisit(page) {
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
throw new Error(`Console error in story: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
apps/storybook/tests/visual.spec.ts
Normal file
51
apps/storybook/tests/visual.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Iterates every story registered in Storybook and takes a screenshot.
|
||||
*
|
||||
* Storybook exposes its story manifest at /index.json (Storybook 7+). For
|
||||
* each entry where `type === "story"`, we navigate to the iframe URL and
|
||||
* snapshot.
|
||||
*
|
||||
* Today the index is empty (no components in the repo). The harness still
|
||||
* runs — it just finds zero stories. The moment a story lands, the
|
||||
* baseline is captured on first run and subsequent runs diff against it.
|
||||
*/
|
||||
type StoryEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
type: "story" | "docs";
|
||||
};
|
||||
|
||||
async function fetchStoryIndex(baseURL: string): Promise<StoryEntry[]> {
|
||||
const res = await fetch(`${baseURL}/index.json`);
|
||||
if (!res.ok) return [];
|
||||
const json = (await res.json()) as {
|
||||
entries?: Record<string, StoryEntry>;
|
||||
};
|
||||
return Object.values(json.entries ?? {}).filter((e) => e.type === "story");
|
||||
}
|
||||
|
||||
test.describe("Storybook visual regression", () => {
|
||||
test("captures a screenshot for every registered story", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
const stories = await fetchStoryIndex(baseURL!);
|
||||
if (stories.length === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"No stories registered yet — visual regression harness is inactive until the first story lands.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const story of stories) {
|
||||
await test.step(`${story.title} — ${story.name}`, async () => {
|
||||
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page).toHaveScreenshot(`${story.id}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
16
apps/storybook/tsconfig.json
Normal file
16
apps/storybook/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/react-library.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".storybook/**/*.ts",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
4
apps/storybook/turbo.json
Normal file
4
apps/storybook/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
111
apps/web-next/AGENTS.md
Normal file
111
apps/web-next/AGENTS.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# AGENTS.md — apps/web-next
|
||||
|
||||
Next.js 15 reference application using App Router. Demonstrates consuming feature packages via tRPC and importing UI components from `@repo/core-ui`. Both `@repo/core-trpc` and `@repo/core-ui` are optional packages — scaffold them with `pnpm turbo gen core-package trpc` / `ui` if needed.
|
||||
|
||||
## Purpose
|
||||
|
||||
Thin app showcasing how features work end-to-end. Business logic lives in feature packages (`@repo/auth`, `@repo/blog`, etc.); UI primitives live in `@repo/core-ui`; this app is mostly routes, layouts, and component composition.
|
||||
|
||||
## Port: 3000
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-next # http://localhost:3000
|
||||
```
|
||||
|
||||
Requires `@repo/cms` and PostgreSQL running to fetch live data:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # PostgreSQL on port 5432
|
||||
pnpm dev --filter @repo/cms # Payload admin on port 3001
|
||||
pnpm dev --filter @repo/web-next # Next.js on port 3000
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/app/layout.tsx` | Root layout — wraps app with `<Providers>` |
|
||||
| `src/app/providers.tsx` | Client component wrapper (add tRPC/React Query here after scaffolding `@repo/core-trpc`) |
|
||||
| `src/app/page.tsx` | Home page — navigation + marketing content |
|
||||
| `src/app/blog/[slug]/page.tsx` | Dynamic blog post route |
|
||||
| `e2e/` | Playwright end-to-end tests |
|
||||
|
||||
## tRPC Setup (optional)
|
||||
|
||||
`@repo/core-trpc` is not installed by default. After scaffolding with `pnpm turbo gen core-package trpc`:
|
||||
|
||||
1. Create `src/app/api/trpc/[trpc]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
import { bindAll } from "../../../../server/bind-production";
|
||||
|
||||
const handler = async (req: Request) => {
|
||||
await bindAll();
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
2. Update `src/app/providers.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <NextTrpcProvider trpcUrl="/api/trpc">{children}</NextTrpcProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core-api` | `appRouter` for tRPC endpoint |
|
||||
| `@repo/core-trpc/next` | Next.js tRPC client + provider (optional — scaffold first) |
|
||||
| `@repo/core-ui` | Design system components (optional — scaffold first) |
|
||||
| `@repo/auth`, `@repo/blog`, etc. | Feature packages (indirectly via core-api) |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `@trpc/server` | tRPC server (fetch adapter) |
|
||||
|
||||
## Test conventions
|
||||
|
||||
- Unit tests colocated: `src/app/blog/article-list.test.tsx`
|
||||
- Vitest environment: `jsdom`
|
||||
- e2e tests in `e2e/` folder: `*.spec.ts`
|
||||
- Run: `pnpm test --filter @repo/web-next` (units) or `pnpm test:e2e` (Playwright)
|
||||
|
||||
## E2E Test Setup
|
||||
|
||||
Playwright config in `e2e/playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: { ...devices["Desktop Chrome"].use },
|
||||
});
|
||||
```
|
||||
|
||||
Run: `pnpm test:e2e` starts the dev server and runs all `.spec.ts` files.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature packages:** `packages/{auth,blog,media,marketing-pages,navigation}/`
|
||||
- **tRPC composition:** `packages/core-api/AGENTS.md`
|
||||
- **tRPC client + provider (optional):** scaffold `@repo/core-trpc` first, then see `turbo/generators/templates/core-package/trpc/AGENTS.md.hbs`
|
||||
- **UI components (optional):** scaffold with `pnpm turbo gen core-package ui`, then see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`
|
||||
20
apps/web-next/e2e/blog-post.spec.ts
Normal file
20
apps/web-next/e2e/blog-post.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("/blog/[slug] returns 404 for non-existent slug", async ({ page }) => {
|
||||
const response = await page.goto("/blog/this-slug-does-not-exist", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("/blog/[slug] for a real slug renders the article", async ({ page }) => {
|
||||
// The mock blog repository is empty by default — so this test currently
|
||||
// expects 404. When seeded data exists in Payload, replace 404 with 200
|
||||
// and check for article.title in the page body.
|
||||
test.skip(
|
||||
true,
|
||||
"Pending: seed a published article in Payload before enabling this test",
|
||||
);
|
||||
await page.goto("/blog/example-slug");
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
});
|
||||
12
apps/web-next/e2e/home.spec.ts
Normal file
12
apps/web-next/e2e/home.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("home page renders site name + nav + article list", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Page renders and shows site name
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
// Site name from siteSettings (mock seed: "My App")
|
||||
await expect(page.locator("body")).toContainText(/My App/i);
|
||||
// Nav element is present on the page
|
||||
const nav = page.locator("nav");
|
||||
await expect(nav).toHaveCount(1);
|
||||
});
|
||||
10
apps/web-next/e2e/marketing-page.spec.ts
Normal file
10
apps/web-next/e2e/marketing-page.spec.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("/about renders the about marketing page", async ({ page }) => {
|
||||
await page.goto("/about");
|
||||
// Either renders the seeded page (h1 = "About us") or "not yet published" message
|
||||
// — both are HTTP 200, so the test only checks it doesn't 500.
|
||||
const status = (await page.context().request.get("/about")).status();
|
||||
expect(status).toBe(200);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
11
apps/web-next/eslint.config.js
Normal file
11
apps/web-next/eslint.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ["next-env.d.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
19
apps/web-next/instrumentation-client.ts
Normal file
19
apps/web-next/instrumentation-client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// apps/web-next/instrumentation-client.ts
|
||||
// Next.js 15+ browser hook: runs in the client bundle on app start.
|
||||
|
||||
import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClient({
|
||||
dsn: process.env["NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN"],
|
||||
app: "web-next",
|
||||
release: process.env["NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
22
apps/web-next/instrumentation.ts
Normal file
22
apps/web-next/instrumentation.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// apps/web-next/instrumentation.ts
|
||||
// Next.js convention: this module runs once on server boot (before any request handler).
|
||||
// Initializes the OTel SDK here so PII scrub processors are active from the very first
|
||||
// request — before bindAll() fires. Calling initOtelServerNode here (not inside bindAll)
|
||||
// closes the startup window where @sentry/nextjs auto-instrumentation could send
|
||||
// unscrubbed errors (C1 fix).
|
||||
|
||||
export async function register() {
|
||||
if (
|
||||
process.env["NEXT_RUNTIME"] === "nodejs" ||
|
||||
process.env["NEXT_RUNTIME"] === "edge"
|
||||
) {
|
||||
const { initOtelServerNode } = await import(
|
||||
"@repo/core-shared/instrumentation/otel/init-server-node"
|
||||
);
|
||||
initOtelServerNode({
|
||||
dsn: process.env["WEB_NEXT_SENTRY_DSN"] ?? "",
|
||||
serviceName: "web-next",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/web-next/middleware.ts
Normal file
10
apps/web-next/middleware.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return withSecurityHeaders(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/web-next/next-env.d.ts
vendored
Normal file
6
apps/web-next/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
31
apps/web-next/next.config.mjs
Normal file
31
apps/web-next/next.config.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: [
|
||||
"@repo/auth",
|
||||
"@repo/blog",
|
||||
"@repo/core-analytics",
|
||||
"@repo/core-api",
|
||||
"@repo/core-audit",
|
||||
"@repo/core-cms",
|
||||
"@repo/core-consent",
|
||||
"@repo/core-dsr",
|
||||
"@repo/core-shared",
|
||||
"@repo/core-ui",
|
||||
"@repo/marketing-pages",
|
||||
"@repo/media",
|
||||
"@repo/navigation",
|
||||
"@repo/core-trpc",
|
||||
],
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// Token is build-time only; CI sets SENTRY_AUTH_TOKEN.
|
||||
silent: process.env.CI !== "true",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT_WEB_NEXT,
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
54
apps/web-next/package.json
Normal file
54
apps/web-next/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@repo/web-next",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
|
||||
"dev": "TSX_TSCONFIG_PATH=../../tsconfig.json tsx server.ts",
|
||||
"start": "node --import tsx server.ts",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/core-api": "workspace:*",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/core-trpc": "workspace:^",
|
||||
"@repo/marketing-pages": "workspace:*",
|
||||
"@repo/media": "workspace:*",
|
||||
"@repo/navigation": "workspace:*",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"inversify": "^6.2.0",
|
||||
"next": "^15.3.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwindcss": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user