extends infer P
+ ? P extends readonly (string | number)[]
+ ? Join>
+ : never
+ : never;
+
+// Example usage
+interface User {
+ name: string;
+ address: {
+ street: string;
+ city: string;
+ };
+}
+
+type UserPaths = Paths; // "name" | "address" | "address.street" | "address.city"
+```
+
+### 4. Brand Type System Implementation
+
+```typescript
+declare const __brand: unique symbol;
+declare const __validator: unique symbol;
+
+interface Brand {
+ readonly [__brand]: B;
+ readonly [__validator]: (value: T) => boolean;
+}
+
+type Branded = T & Brand;
+
+// Specific branded types
+type PositiveNumber = Branded;
+type EmailAddress = Branded;
+type UserId = Branded;
+
+// Brand constructors with validation
+function createPositiveNumber(value: number): PositiveNumber {
+ if (value <= 0) {
+ throw new Error('Number must be positive');
+ }
+ return value as PositiveNumber;
+}
+
+function createEmailAddress(value: string): EmailAddress {
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
+ throw new Error('Invalid email format');
+ }
+ return value as EmailAddress;
+}
+
+// Usage prevents mixing of domain types
+function sendEmail(to: EmailAddress, userId: UserId, amount: PositiveNumber) {
+ // All parameters are type-safe and validated
+}
+
+// Error: cannot mix branded types
+// sendEmail('invalid@email', 'user123', -100); // Type errors
+```
+
+## Performance Optimization Strategies
+
+### 1. Type Complexity Analysis
+
+```bash
+# Generate type trace for analysis
+npx tsc --generateTrace trace --incremental false
+
+# Analyze the trace (requires @typescript/analyze-trace)
+npx @typescript/analyze-trace trace
+
+# Check specific type instantiation depth
+npx tsc --extendedDiagnostics | grep -E "Type instantiation|Check time"
+```
+
+### 2. Memory-Efficient Type Patterns
+
+```typescript
+// Prefer interfaces over type intersections for performance
+// Bad: Heavy intersection
+type HeavyType = TypeA & TypeB & TypeC & TypeD & TypeE;
+
+// Good: Interface extension
+interface LightType extends TypeA, TypeB, TypeC, TypeD, TypeE {}
+
+// Use discriminated unions instead of large unions
+// Bad: Large union
+type Status = 'a' | 'b' | 'c' | /* ... 100 more values */;
+
+// Good: Discriminated union
+type Status =
+ | { category: 'loading'; value: 'pending' | 'in-progress' }
+ | { category: 'complete'; value: 'success' | 'error' }
+ | { category: 'cancelled'; value: 'user' | 'timeout' };
+```
+
+## Validation Commands
+
+```bash
+# Type checking validation
+tsc --noEmit --strict
+
+# Performance validation
+tsc --extendedDiagnostics --incremental false | grep "Check time"
+
+# Memory usage validation
+node --max-old-space-size=8192 ./node_modules/typescript/lib/tsc.js --noEmit
+
+# Declaration file validation
+tsc --declaration --emitDeclarationOnly --outDir temp-types
+
+# Type coverage validation
+npx type-coverage --detail --strict
+```
+
+## Expert Resources
+
+### Official Documentation
+- [Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)
+- [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)
+- [Mapped Types](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html)
+- [TypeScript Performance](https://github.com/microsoft/TypeScript/wiki/Performance)
+
+### Advanced Learning
+- [Type Challenges](https://github.com/type-challenges/type-challenges) - Progressive type exercises
+- [Type-Level TypeScript](https://type-level-typescript.com) - Advanced patterns course
+- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/) - Comprehensive guide
+
+### Tools
+- [tsd](https://github.com/SamVerschueren/tsd) - Type definition testing
+- [type-coverage](https://github.com/plantain-00/type-coverage) - Coverage analysis
+- [ts-essentials](https://github.com/ts-essentials/ts-essentials) - Utility types library
+
+Always validate solutions with the provided diagnostic commands and ensure type safety is maintained throughout the implementation.
+
+## Code Review Checklist
+
+When reviewing TypeScript type definitions and usage, focus on:
+
+### Type Safety & Correctness
+- [ ] All function parameters and return types are explicitly typed
+- [ ] Generic constraints are specific enough to prevent invalid usage
+- [ ] Union types include all possible values and are properly discriminated
+- [ ] Optional properties use consistent patterns (undefined vs optional)
+- [ ] Type assertions are avoided unless absolutely necessary
+- [ ] any types are documented with justification and migration plan
+
+### Generic Design & Constraints
+- [ ] Generic type parameters have meaningful constraint boundaries
+- [ ] Variance is handled correctly (covariant, contravariant, invariant)
+- [ ] Generic functions infer types correctly from usage context
+- [ ] Conditional types provide appropriate fallback behaviors
+- [ ] Recursive types include depth limiting to prevent infinite instantiation
+- [ ] Brand types are used appropriately for nominal typing requirements
+
+### Utility Types & Transformations
+- [ ] Built-in utility types (Pick, Omit, Partial) are preferred over custom implementations
+- [ ] Mapped types transform object structures correctly
+- [ ] Template literal types generate expected string patterns
+- [ ] Conditional types distribute properly over union types
+- [ ] Type-level computation is efficient and maintainable
+- [ ] Custom utility types include comprehensive documentation
+
+### Type Inference & Narrowing
+- [ ] Type guards use proper type predicate syntax
+- [ ] Assertion functions are implemented correctly with asserts keyword
+- [ ] Control flow analysis narrows types appropriately
+- [ ] Discriminated unions include all necessary discriminator properties
+- [ ] Type narrowing works correctly with complex nested objects
+- [ ] Unknown types are handled safely without type assertions
+
+### Performance & Complexity
+- [ ] Type instantiation depth remains within reasonable limits
+- [ ] Complex union types are broken into manageable discriminated unions
+- [ ] Type computation complexity is appropriate for usage frequency
+- [ ] Recursive types terminate properly without infinite loops
+- [ ] Large type definitions don't significantly impact compilation time
+- [ ] Type coverage remains high without excessive complexity
+
+### Library & Module Types
+- [ ] Declaration files accurately represent runtime behavior
+- [ ] Module augmentation is used appropriately for extending third-party types
+- [ ] Global types are scoped correctly and don't pollute global namespace
+- [ ] Export/import types work correctly across module boundaries
+- [ ] Ambient declarations match actual runtime interfaces
+- [ ] Type compatibility is maintained across library versions
+
+### Advanced Patterns & Best Practices
+- [ ] Higher-order types are composed logically and reusably
+- [ ] Type-level programming uses appropriate abstractions
+- [ ] Index signatures are used judiciously with proper key types
+- [ ] Function overloads provide clear, unambiguous signatures
+- [ ] Namespace usage is minimal and well-justified
+- [ ] Type definitions support intended usage patterns without friction
\ No newline at end of file
diff --git a/back2base-container/defaults/profile-snippets/general.md b/back2base-container/defaults/profile-snippets/general.md
new file mode 100644
index 0000000..2ef1d1f
--- /dev/null
+++ b/back2base-container/defaults/profile-snippets/general.md
@@ -0,0 +1,12 @@
+## Profile: general
+
+A lean, general-purpose MCP set is loaded: filesystem, git, plus context7
+(library docs), fetch (HTTP), and github. You're seeing this either because the
+default `auto` profile found no language-specific signal in this workspace and
+fell back to `general`, or because you selected `general` explicitly. Either way
+it keeps the cached tool prefix small and your turns fast.
+
+If this repo is actually Go / TypeScript / Python / infra and you want the
+matching tool set, restart with `BACK2BASE_PROFILE=`,
+or `BACK2BASE_PROFILE=full` for everything. Do not try to add MCP servers
+mid-session — the tool list is locked at session start by design.
diff --git a/back2base-container/defaults/profiles.json b/back2base-container/defaults/profiles.json
index 88f9a6c..b15f08b 100644
--- a/back2base-container/defaults/profiles.json
+++ b/back2base-container/defaults/profiles.json
@@ -1,6 +1,11 @@
{
"core": ["filesystem", "git"],
"profiles": {
+ "auto": {
+ "description": "Auto-detect MCP servers from the workspace (recommended)",
+ "model": "claude-opus-4-8[1m]",
+ "servers": []
+ },
"full": {
"description": "All MCP servers (max capability, highest token cost)",
"model": "claude-opus-4-8[1m]",
@@ -41,6 +46,11 @@
"model": "claude-opus-4-8[1m]",
"servers": ["brave-search", "context7", "fetch", "github", "sequential-thinking"]
},
+ "general": {
+ "description": "General development baseline (unknown/unmatched workspace)",
+ "model": "claude-opus-4-8[1m]",
+ "servers": ["context7", "fetch", "github"]
+ },
"minimal": {
"description": "Bare minimum (filesystem, git only)",
"servers": []
diff --git a/back2base-container/docker-compose.yml b/back2base-container/docker-compose.yml
index 7ab647d..815460a 100644
--- a/back2base-container/docker-compose.yml
+++ b/back2base-container/docker-compose.yml
@@ -93,7 +93,7 @@ services:
- BACK2BASE_MODEL=${BACK2BASE_MODEL:-}
# MCP server profile — controls which servers are enabled
- - BACK2BASE_PROFILE=${BACK2BASE_PROFILE:-full}
+ - BACK2BASE_PROFILE=${BACK2BASE_PROFILE:-auto}
- BACK2BASE_OVERVIEW=${BACK2BASE_OVERVIEW:-0}
# Host mount-source path for /workspace. Used by entrypoint.sh as the
diff --git a/back2base-container/entrypoint.sh b/back2base-container/entrypoint.sh
index 34a223d..07ba51e 100644
--- a/back2base-container/entrypoint.sh
+++ b/back2base-container/entrypoint.sh
@@ -244,40 +244,55 @@ _phase2_5_seed_settings() {
_phase "Seeding settings + MCP defaults" _phase2_5_seed_settings
# ── MCP profile filtering ───────────────────────────────────────────────────
-# When BACK2BASE_PROFILE is set (and not "full"), filter .mcp.json to only
-# include servers in that profile. Core servers (filesystem, git, memory) are
-# always included. The profiles definition lives at /opt/back2base/defaults/profiles.json.
+# When BACK2BASE_PROFILE is set to a named profile (not "auto" or unset),
+# filter .mcp.json to only include servers in that profile. Core servers
+# (filesystem, git) are always included. When BACK2BASE_PROFILE is
+# unset OR set to "auto" (the default), auto-detects the profile from the
+# workspace fingerprint via detect-profile.py.
+# The profiles definition lives at /opt/back2base/defaults/profiles.json.
filter_mcp_by_profile() {
- local profile="${BACK2BASE_PROFILE:-full}"
local mcp_file="$HOME/.claude/.mcp.json"
local profiles_file="/opt/back2base/defaults/profiles.json"
-
- if [ "$profile" = "full" ]; then
- [ "${BACK2BASE_VERBOSE:-0}" = "1" ] && echo ":: MCP profile: full (all servers)"
- return 0
- fi
-
- if [ ! -f "$profiles_file" ] || [ ! -f "$mcp_file" ] || ! command -v jq &>/dev/null; then
- echo ":: ⚠ Cannot filter MCP servers (missing profiles.json, .mcp.json, or jq)"
- return 0
+ local profile allowed
+
+ if [ -n "${BACK2BASE_PROFILE:-}" ] && [ "${BACK2BASE_PROFILE}" != "auto" ]; then
+ # ── Explicit profile (unchanged behavior) ──
+ profile="$BACK2BASE_PROFILE"
+ if [ "$profile" = "full" ]; then
+ [ "${BACK2BASE_VERBOSE:-0}" = "1" ] && echo ":: MCP profile: full (all servers)"
+ return 0
+ fi
+ if [ ! -f "$profiles_file" ] || [ ! -f "$mcp_file" ] || ! command -v jq &>/dev/null; then
+ echo ":: ⚠ Cannot filter MCP servers (missing profiles.json, .mcp.json, or jq)"
+ return 0
+ fi
+ if ! jq -e --arg p "$profile" '.profiles[$p]' "$profiles_file" >/dev/null 2>&1; then
+ echo ":: ⚠ Unknown profile '$profile', using full server set"
+ return 0
+ fi
+ allowed=$(jq -r --arg p "$profile" '(.core + .profiles[$p].servers) | unique | .[]' "$profiles_file")
+ else
+ # ── Auto-detect from the workspace fingerprint (new default) ──
+ profile="auto"
+ if [ ! -f "$profiles_file" ] || [ ! -f "$mcp_file" ] || ! command -v jq &>/dev/null; then
+ echo ":: ⚠ Cannot filter MCP servers (missing profiles.json, .mcp.json, or jq)"
+ return 0
+ fi
+ allowed=$(python3 /opt/back2base/detect-profile.py \
+ --workspace "$PWD" --profiles "$profiles_file" 2>/dev/null)
+ if [ -z "$allowed" ]; then
+ echo ":: ⚠ auto-detect produced no servers, keeping full server set"
+ return 0
+ fi
fi
- # Validate profile exists
- if ! jq -e --arg p "$profile" '.profiles[$p]' "$profiles_file" >/dev/null 2>&1; then
- echo ":: ⚠ Unknown profile '$profile', using full server set"
+ # ── Shared filter: keep only allowed servers, sorted by key ──
+ local tmp
+ if ! tmp=$(mktemp "$mcp_file.XXXXXX" 2>/dev/null); then
+ echo ":: ⚠ MCP filtering failed (mktemp), keeping full server set"
return 0
fi
-
- # Build the allowed server list: core + profile servers
- local allowed
- allowed=$(jq -r --arg p "$profile" '
- (.core + .profiles[$p].servers) | unique | .[]
- ' "$profiles_file")
-
- # Filter .mcp.json to only include allowed servers, sorted by key
- local tmp
- tmp=$(mktemp "$mcp_file.XXXXXX")
- if jq --argjson allowed "$(echo "$allowed" | jq -R -s 'split("\n") | map(select(. != ""))')" \
+ if jq --argjson allowed "$(printf '%s\n' "$allowed" | jq -R -s 'split("\n") | map(select(. != ""))')" \
'{ mcpServers: (.mcpServers | to_entries | map(select(.key as $k | $allowed | index($k))) | sort_by(.key) | from_entries) }' \
"$mcp_file" > "$tmp" 2>/dev/null; then
mv -f "$tmp" "$mcp_file"
@@ -360,6 +375,19 @@ seed_skills_if_missing() {
fi
}
+# Agents live at ~/.claude/agents/ — seeded from image defaults the same way
+# skills are. Default ships the claudekit bundle (MIT). Offline-safe.
+seed_agents_if_missing() {
+ local defaults="${B2B_DEFAULTS_AGENTS:-/opt/back2base/defaults/agents}"
+ if [ ! -d "$HOME/.claude/agents" ] || [ -z "$(ls -A "$HOME/.claude/agents" 2>/dev/null)" ]; then
+ if [ -d "$defaults" ]; then
+ rm -rf "$HOME/.claude/agents"
+ cp -RL "$defaults" "$HOME/.claude/agents"
+ [ "${BACK2BASE_VERBOSE:-0}" = "1" ] && echo ":: seeded $HOME/.claude/agents from image defaults"
+ fi
+ fi
+}
+
# Commands live at ~/.claude/commands/ — seeded from image defaults on first
# install, then user-owned.
seed_commands_if_missing() {
@@ -394,6 +422,7 @@ seed_plugins_if_missing() {
# don't race on directories that might be populated by the pulled images.
_phase5_seed_image_defaults() {
seed_skills_if_missing
+ seed_agents_if_missing
seed_commands_if_missing
seed_plugins_if_missing
}
@@ -412,7 +441,10 @@ generate_claude_md() {
local out="$HOME/.claude/CLAUDE.md"
local mcp="$HOME/.claude/.mcp.json"
local commands_dir="$HOME/.claude/commands"
- local profile_snippet="/opt/back2base/defaults/profile-snippets/${BACK2BASE_PROFILE:-full}.md"
+ # auto (the default) has no snippet of its own — show the general guide.
+ local snippet_profile="${BACK2BASE_PROFILE:-general}"
+ [ "$snippet_profile" = "auto" ] && snippet_profile="general"
+ local profile_snippet="/opt/back2base/defaults/profile-snippets/${snippet_profile}.md"
python3 /opt/back2base/render-claude-md.py \
--template "$template" \
--mcp "$mcp" \
@@ -439,7 +471,7 @@ run_skill_preplan() {
|| echo ":: ⚠ skill-preplan exited non-zero; CLAUDE.md unchanged" >&2
}
-if [ "${BACK2BASE_SKILL_PREPLAN:-1}" != "0" ] && [ "${BACK2BASE_PROFILE:-full}" != "minimal" ]; then
+if [ "${BACK2BASE_SKILL_PREPLAN:-1}" != "0" ] && [ "${BACK2BASE_PROFILE:-auto}" != "minimal" ]; then
_phase "Fingerprinting workspace" run_skill_preplan
fi
diff --git a/back2base-container/lib/detect-profile.py b/back2base-container/lib/detect-profile.py
new file mode 100644
index 0000000..0b5a40f
--- /dev/null
+++ b/back2base-container/lib/detect-profile.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Auto-detect an MCP profile from a workspace fingerprint.
+
+Scans --workspace (root + one level deep) for language/tooling signals and
+maps each to a profile name defined in --profiles (profiles.json). Prints the
+UNION of every matched profile's servers with the always-on `core` set, one
+server name per line, in profiles.json declaration order. Zero matches prints
+the `general` profile's set.
+
+Tolerant by construction: any error prints the best available fallback
+(general, else core, else nothing-but-exit-0) with a stderr warning, so
+detection never blocks container startup.
+
+Usage:
+ detect-profile.py --workspace /workspace --profiles .../profiles.json
+"""
+
+import argparse
+import json
+import pathlib
+import sys
+
+# profile -> (exact filenames, file suffixes, directory names).
+# Order is for deterministic stderr summaries only; union is order-independent.
+SIGNALS = [
+ ("go", {"go.mod"}, {".go"}, set()),
+ ("frontend", {"package.json", "tsconfig.json"}, {".ts", ".tsx"}, set()),
+ ("python", {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"}, {".py"}, set()),
+ ("infra", {"Chart.yaml", "kustomization.yaml"}, {".tf", ".tfvars"}, {".terraform"}),
+ ("documentation", {"mkdocs.yml", "docusaurus.config.js", "antora.yml"}, set(), set()),
+]
+
+# Directories we never descend into when scanning one level deep. We still
+# match these names as signal directories (e.g. .terraform) before deciding
+# not to descend.
+SKIP_DESCEND = {".git", "node_modules", "vendor", "dist", "build",
+ "target", ".venv", "__pycache__", ".terraform"}
+
+
+def _scan_entries(d, exact, suffix, dirname, matched):
+ """Match the immediate children of directory `d`."""
+ try:
+ entries = list(d.iterdir())
+ except OSError:
+ return
+ for e in entries:
+ name = e.name
+ try:
+ is_dir = e.is_dir()
+ except OSError:
+ is_dir = False
+ if is_dir:
+ if name in dirname:
+ matched.update(dirname[name])
+ else:
+ if name in exact:
+ matched.update(exact[name])
+ dot = name.rfind(".")
+ # dot > 0 (not != -1) intentionally skips bare dotfiles like ".babelrc" / ".go" that have no extension beyond the leading dot
+ if dot > 0:
+ suf = name[dot:]
+ if suf in suffix:
+ matched.update(suffix[suf])
+
+
+def detect(workspace):
+ """Return the set of matched profile names for `workspace`."""
+ exact, suffix, dirname = {}, {}, {}
+ for prof, files, sufs, dirs in SIGNALS:
+ for f in files:
+ exact.setdefault(f, set()).add(prof)
+ for s in sufs:
+ suffix.setdefault(s, set()).add(prof)
+ for dn in dirs:
+ dirname.setdefault(dn, set()).add(prof)
+
+ matched = set()
+ # Root level.
+ _scan_entries(workspace, exact, suffix, dirname, matched)
+ # One level deep (skip noisy/large dirs).
+ try:
+ children = list(workspace.iterdir())
+ except OSError:
+ children = []
+ for child in children:
+ try:
+ if child.is_dir() and child.name not in SKIP_DESCEND:
+ _scan_entries(child, exact, suffix, dirname, matched)
+ except OSError:
+ continue
+ return matched
+
+
+def resolve(matched, profiles):
+ """Union core + matched profiles' servers, deduped, declaration order."""
+ core = profiles.get("core", []) or []
+ profs = profiles.get("profiles", {}) or {}
+ servers = []
+ for s in core:
+ if s not in servers:
+ servers.append(s)
+ if matched:
+ for name, spec in profs.items(): # declaration order
+ if name in matched:
+ for s in (spec.get("servers", []) or []):
+ if s not in servers:
+ servers.append(s)
+ else:
+ general = profs.get("general", {}) or {}
+ for s in (general.get("servers", []) or []):
+ if s not in servers:
+ servers.append(s)
+ return servers
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(description="Auto-detect MCP profile")
+ parser.add_argument("--workspace", required=True)
+ parser.add_argument("--profiles", required=True)
+ args = parser.parse_args(argv[1:])
+
+ try:
+ with open(args.profiles, encoding="utf-8") as f:
+ profiles = json.load(f)
+ except (OSError, ValueError) as exc:
+ print(f":: ⚠ detect-profile: unreadable profiles.json ({exc}); "
+ "using core fallback", file=sys.stderr)
+ # Minimal safe fallback: filesystem,git,memory.
+ for s in ("filesystem", "git", "memory"):
+ print(s)
+ return 0
+
+ workspace = pathlib.Path(args.workspace)
+ if not workspace.is_dir():
+ print(f":: ⚠ detect-profile: workspace missing ({workspace}); "
+ "using general set", file=sys.stderr)
+ matched = set()
+ else:
+ try:
+ matched = detect(workspace)
+ except Exception as exc: # never block boot
+ print(f":: ⚠ detect-profile: scan failed ({exc}); "
+ "using general set", file=sys.stderr)
+ matched = set()
+
+ servers = resolve(matched, profiles)
+ for s in servers:
+ print(s)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/back2base-container/lib/hooks/claude-md-audit-trigger.sh b/back2base-container/lib/hooks/claude-md-audit-trigger.sh
index 32a9153..3609564 100755
--- a/back2base-container/lib/hooks/claude-md-audit-trigger.sh
+++ b/back2base-container/lib/hooks/claude-md-audit-trigger.sh
@@ -10,6 +10,14 @@ set -u
b2b_hook_init "claude-md-audit-trigger"
b2b_hook_disabled "BACK2BASE_HOOK_CLAUDE_MD_AUDIT" && b2b_hook_passthrough
+# Cheap idle-turn gate: claude-md-audit.py reads edits.jsonl from
+# POWER_STEERING_DIR and needs accumulated edits before decide() can suggest
+# anything. With no edits tallied since the last drain, decide() is guaranteed
+# to return nothing — so skip the python3 spawn entirely. Keeps this
+# UserPromptSubmit hook ~zero-cost on turns with no edits.
+EDITS="${POWER_STEERING_DIR:-/run/back2base/power-steering}/edits.jsonl"
+[ -s "$EDITS" ] || b2b_hook_passthrough
+
AUDIT="${CLAUDE_MD_AUDIT_PY:-/opt/back2base/claude-md-audit.py}"
PENDING="${POWER_STEERING_PENDING:-/run/back2base/power-steering/pending.md}"
diff --git a/back2base-container/test/claude-md-audit-hooks.bats b/back2base-container/test/claude-md-audit-hooks.bats
index e82ab43..4dea653 100644
--- a/back2base-container/test/claude-md-audit-hooks.bats
+++ b/back2base-container/test/claude-md-audit-hooks.bats
@@ -75,6 +75,31 @@ teardown() { rm -rf "$TEST_TMP"; }
[ ! -s "$POWER_STEERING_PENDING" ]
}
+@test "trigger hook: skips python spawn when edits.jsonl is absent" {
+ # edits.jsonl does NOT exist in POWER_STEERING_DIR
+ SENTINEL="$TEST_TMP/audit-was-run"
+ fake_audit="$TEST_TMP/fake-audit.py"
+ printf '#!/usr/bin/env python3\nopen("%s","w").write("ran")\n' "$SENTINEL" > "$fake_audit"
+ chmod +x "$fake_audit"
+ run env CLAUDE_MD_AUDIT_PY="$fake_audit" bash "$TRIGGER_HOOK" <<<'{}'
+ [ "$status" -eq 0 ]
+ [ "$output" = "{}" ]
+ [ ! -f "$SENTINEL" ]
+}
+
+@test "trigger hook: does invoke python when edits.jsonl is non-empty" {
+ SENTINEL="$TEST_TMP/audit-was-run"
+ fake_audit="$TEST_TMP/fake-audit.py"
+ printf '#!/usr/bin/env python3\nopen("%s","w").write("ran")\n' "$SENTINEL" > "$fake_audit"
+ chmod +x "$fake_audit"
+ # Populate edits.jsonl so the gate passes
+ printf '{"ts":1,"tool":"Edit","file":"/repo/x.go"}\n' > "$POWER_STEERING_DIR/edits.jsonl"
+ run env CLAUDE_MD_AUDIT_PY="$fake_audit" bash "$TRIGGER_HOOK" <<<'{}'
+ [ "$status" -eq 0 ]
+ [ "$output" = "{}" ]
+ [ -f "$SENTINEL" ]
+}
+
@test "manifest: claude-md-audit-tally registered as PostToolUse" {
MANIFEST="$BATS_TEST_DIRNAME/../defaults/hooks.json"
run jq -r '.hooks[] | select(.name=="claude-md-audit-tally") | .event' "$MANIFEST"
diff --git a/back2base-container/test/detect-profile.bats b/back2base-container/test/detect-profile.bats
new file mode 100644
index 0000000..bdb4b2c
--- /dev/null
+++ b/back2base-container/test/detect-profile.bats
@@ -0,0 +1,139 @@
+#!/usr/bin/env bats
+
+# Tests for lib/detect-profile.py — fingerprints a workspace and prints the
+# union of matched profiles' MCP servers (or the `general` fallback set).
+
+setup() {
+ TEST_TMP="$(mktemp -d "${BATS_TMPDIR}/detect-profile.XXXXXX")"
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
+ DETECT="$REPO_ROOT/lib/detect-profile.py"
+ PROFILES="$REPO_ROOT/defaults/profiles.json"
+ WS="$TEST_TMP/ws"
+ mkdir -p "$WS"
+}
+
+teardown() { rm -rf "$TEST_TMP"; }
+
+run_detect() { run python3 "$DETECT" --workspace "$WS" --profiles "$PROFILES"; }
+
+@test "empty workspace falls back to general set" {
+ run_detect
+ [ "$status" -eq 0 ]
+ # general = core + context7,fetch,github (OSS core has no memory)
+ echo "$output" | grep -qx "filesystem"
+ echo "$output" | grep -qx "git"
+ echo "$output" | grep -qx "context7"
+ echo "$output" | grep -qx "fetch"
+ echo "$output" | grep -qx "github"
+ [ "$(echo "$output" | grep -c .)" -eq 5 ]
+}
+
+@test "go.mod selects the go profile servers" {
+ touch "$WS/go.mod"
+ run_detect
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -qx "godevmcp" # go-only server
+ echo "$output" | grep -qx "sequential-thinking"
+ echo "$output" | grep -qx "filesystem" # core always present
+ ! echo "$output" | grep -qx "terraform" # infra NOT pulled in
+}
+
+@test "package.json selects the frontend profile servers" {
+ echo '{}' > "$WS/package.json"
+ run_detect
+ echo "$output" | grep -qx "lsmcp" # frontend-only server
+ ! echo "$output" | grep -qx "godevmcp"
+}
+
+@test "a .py file selects the python profile" {
+ touch "$WS/app.py"
+ run_detect
+ echo "$output" | grep -qx "github"
+ echo "$output" | grep -qx "sequential-thinking"
+ echo "$output" | grep -qx "sqlite" # sqlite confirms python path (absent from frontend/infra/docs)
+}
+
+@test "a .tf file selects the infra profile" {
+ touch "$WS/main.tf"
+ run_detect
+ echo "$output" | grep -qx "terraform"
+ echo "$output" | grep -qx "kubernetes"
+}
+
+@test "multiple signals union their servers (go + frontend)" {
+ touch "$WS/go.mod"
+ echo '{}' > "$WS/package.json"
+ run_detect
+ echo "$output" | grep -qx "godevmcp" # from go
+ echo "$output" | grep -qx "lsmcp" # from frontend
+}
+
+@test "signals one level deep are detected" {
+ mkdir -p "$WS/service"
+ touch "$WS/service/go.mod"
+ run_detect
+ echo "$output" | grep -qx "godevmcp"
+}
+
+@test "node_modules is not descended into" {
+ mkdir -p "$WS/node_modules/leftpad"
+ touch "$WS/node_modules/leftpad/index.go" # stray .go must NOT trigger go
+ echo '{}' > "$WS/package.json"
+ run_detect
+ ! echo "$output" | grep -qx "godevmcp"
+}
+
+@test "result is always a subset of full and never empty" {
+ # NOTE: assumes profiles.full.servers is a superset of all other profiles' servers.
+ # If a new profile adds a server not in full, update full.servers or this test false-fails.
+ touch "$WS/go.mod" "$WS/main.tf" "$WS/app.py"
+ echo '{}' > "$WS/package.json"
+ run_detect
+ [ "$(echo "$output" | grep -c .)" -gt 0 ]
+ full="$(jq -r '.profiles.full.servers[]' "$PROFILES")"
+ while read -r s; do
+ [ -z "$s" ] && continue
+ echo "$full" | grep -qx "$s" || { echo "server $s not in full"; false; }
+ done <<< "$output"
+}
+
+@test "malformed profiles.json falls back to general core, exit 0" {
+ bad="$TEST_TMP/bad.json"
+ echo 'not json' > "$bad"
+ run python3 "$DETECT" --workspace "$WS" --profiles "$bad"
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -qx "filesystem"
+}
+
+@test "missing workspace dir falls back, exit 0" {
+ run python3 "$DETECT" --workspace "$TEST_TMP/nope" --profiles "$PROFILES"
+ [ "$status" -eq 0 ]
+ echo "$output" | grep -qx "filesystem"
+}
+
+@test "output is deterministic across runs" {
+ touch "$WS/go.mod"
+ a="$(python3 "$DETECT" --workspace "$WS" --profiles "$PROFILES")"
+ b="$(python3 "$DETECT" --workspace "$WS" --profiles "$PROFILES")"
+ [ "$a" = "$b" ]
+}
+
+@test "detect output filters a sample .mcp.json (auto end-to-end)" {
+ touch "$WS/go.mod"
+ mcp="$TEST_TMP/.mcp.json"
+ cat > "$mcp" <<'EOF'
+{ "mcpServers": {
+ "godevmcp": {"command":"x"}, "terraform": {"command":"x"},
+ "filesystem": {"command":"x"}, "git": {"command":"x"},
+ "memory": {"command":"x"}, "datadog": {"command":"x"}
+} }
+EOF
+ allowed="$(python3 "$DETECT" --workspace "$WS" --profiles "$PROFILES")"
+ out="$(jq --argjson allowed "$(echo "$allowed" | jq -R -s 'split("\n") | map(select(. != ""))')" \
+ '{ mcpServers: (.mcpServers | to_entries | map(select(.key as $k | $allowed | index($k))) | sort_by(.key) | from_entries) }' \
+ "$mcp")"
+ echo "$out" | jq -e '.mcpServers.godevmcp' >/dev/null # kept (go)
+ echo "$out" | jq -e '.mcpServers.filesystem' >/dev/null # kept (core)
+ echo "$out" | jq -e '.mcpServers.terraform // empty' >/dev/null && false || true # dropped
+ echo "$out" | jq -e '.mcpServers.datadog // empty' >/dev/null && false || true # dropped
+}
diff --git a/back2base-container/test/seed-agents.bats b/back2base-container/test/seed-agents.bats
new file mode 100644
index 0000000..e304abe
--- /dev/null
+++ b/back2base-container/test/seed-agents.bats
@@ -0,0 +1,33 @@
+#!/usr/bin/env bats
+
+# Tests for seed_agents_if_missing — seeds ~/.claude/agents from image
+# defaults when the target is absent/empty; idempotent otherwise.
+
+setup() {
+ TEST_TMP="$(mktemp -d "${BATS_TMPDIR}/seed-agents.XXXXXX")"
+ REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
+ export HOME="$TEST_TMP/home"; mkdir -p "$HOME/.claude"
+ export DEFAULTS="$TEST_TMP/opt/defaults/agents"; mkdir -p "$DEFAULTS/claudekit"
+ echo "agent body" > "$DEFAULTS/claudekit/example.md"
+ FN="$TEST_TMP/fn.sh"
+ awk '/^seed_agents_if_missing\(\) \{/,/^\}/' "$REPO_ROOT/entrypoint.sh" > "$FN"
+}
+
+teardown() { rm -rf "$TEST_TMP"; }
+
+@test "seeds agents when target is missing" {
+ run bash -c "source '$FN'; \
+ HOME='$HOME' B2B_DEFAULTS_AGENTS='$DEFAULTS' seed_agents_if_missing; \
+ cat '$HOME/.claude/agents/claudekit/example.md'"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"agent body"* ]]
+}
+
+@test "is idempotent / does not clobber a populated target" {
+ mkdir -p "$HOME/.claude/agents"; echo "user agent" > "$HOME/.claude/agents/mine.md"
+ run bash -c "source '$FN'; \
+ HOME='$HOME' B2B_DEFAULTS_AGENTS='$DEFAULTS' seed_agents_if_missing; \
+ cat '$HOME/.claude/agents/mine.md'"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"user agent"* ]]
+}
diff --git a/profile.go b/profile.go
index f72f12a..78493a5 100644
--- a/profile.go
+++ b/profile.go
@@ -58,19 +58,30 @@ func (c profilesConfig) resolvedServers(name string) ([]string, error) {
return servers, nil
}
-// profileNames returns sorted profile names for display order.
-// "full" is always first, "minimal" always last, rest alphabetical.
+// profileNames returns profile names in display order: "auto" first, "full"
+// second, "minimal" last, the rest alphabetical in between. Sentinel names are
+// only included when they actually exist in the loaded config, so a config
+// missing one of them never produces a menu row with an empty description or a
+// numeric choice that resolves to a non-existent profile.
func (c profilesConfig) profileNames() []string {
- var names []string
+ var middle []string
for k := range c.Profiles {
- if k != "full" && k != "minimal" {
- names = append(names, k)
+ if k != "auto" && k != "full" && k != "minimal" {
+ middle = append(middle, k)
}
}
- sort.Strings(names)
- result := []string{"full"}
- result = append(result, names...)
- result = append(result, "minimal")
+ sort.Strings(middle)
+
+ var result []string
+ for _, lead := range []string{"auto", "full"} {
+ if _, ok := c.Profiles[lead]; ok {
+ result = append(result, lead)
+ }
+ }
+ result = append(result, middle...)
+ if _, ok := c.Profiles["minimal"]; ok {
+ result = append(result, "minimal")
+ }
return result
}
@@ -82,12 +93,12 @@ func selectProfile(cfg profilesConfig) (string, error) {
// selectProfileWithDefault is selectProfile with a caller-supplied
// "remembered" profile. An empty or unknown defaultName falls back to
-// "full" (the original behavior). The default is the value used on EOF
+// "auto" (the new default). The default is the value used on EOF
// or empty-line input.
func selectProfileWithDefault(cfg profilesConfig, defaultName string) (string, error) {
names := cfg.profileNames()
- chosenDefault := "full"
+ chosenDefault := "auto"
if defaultName != "" {
if _, ok := cfg.Profiles[defaultName]; ok {
chosenDefault = defaultName
@@ -99,12 +110,16 @@ func selectProfileWithDefault(cfg profilesConfig, defaultName string) (string, e
fmt.Fprintln(os.Stderr)
for i, name := range names {
p := cfg.Profiles[name]
- servers, _ := cfg.resolvedServers(name)
marker := " "
if name == chosenDefault {
marker = "*"
}
- fmt.Fprintf(os.Stderr, " %s %d) %-12s %s (%d servers)\n", marker, i+1, name, p.Description, len(servers))
+ if name == "auto" {
+ fmt.Fprintf(os.Stderr, " %s %d) %-12s %s\n", marker, i+1, name, p.Description)
+ } else {
+ servers, _ := cfg.resolvedServers(name)
+ fmt.Fprintf(os.Stderr, " %s %d) %-12s %s (%d servers)\n", marker, i+1, name, p.Description, len(servers))
+ }
}
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "Profile [1-%s, name, or Enter for %s]: ", strconv.Itoa(len(names)), chosenDefault)
diff --git a/profile_test.go b/profile_test.go
index 9dcd5e8..871f52d 100644
--- a/profile_test.go
+++ b/profile_test.go
@@ -126,6 +126,7 @@ func TestProfileNames_Order(t *testing.T) {
Profiles: map[string]profileDef{
"minimal": {},
"go": {},
+ "auto": {},
"full": {},
"frontend": {},
"infra": {},
@@ -134,14 +135,17 @@ func TestProfileNames_Order(t *testing.T) {
names := cfg.profileNames()
- if names[0] != "full" {
- t.Errorf("first = %q, want full", names[0])
+ if names[0] != "auto" {
+ t.Errorf("first = %q, want auto", names[0])
+ }
+ if names[1] != "full" {
+ t.Errorf("second = %q, want full", names[1])
}
if names[len(names)-1] != "minimal" {
t.Errorf("last = %q, want minimal", names[len(names)-1])
}
- middle := names[1 : len(names)-1]
+ middle := names[2 : len(names)-1]
for i := 1; i < len(middle); i++ {
if middle[i] < middle[i-1] {
t.Errorf("middle not sorted: %v", middle)
@@ -150,6 +154,50 @@ func TestProfileNames_Order(t *testing.T) {
}
}
+func TestProfileNames_AutoFirst(t *testing.T) {
+ cfg := profilesConfig{
+ Profiles: map[string]profileDef{
+ "auto": {},
+ "full": {},
+ "go": {},
+ "minimal": {},
+ },
+ }
+
+ names := cfg.profileNames()
+ if len(names) == 0 || names[0] != "auto" {
+ t.Errorf("profileNames()[0] = %q, want auto", names[0])
+ }
+}
+
+func TestProfileNames_OmitsMissingSentinels(t *testing.T) {
+ // A config without the auto/full/minimal sentinels must not list them —
+ // otherwise the menu shows empty-description rows and numeric selection can
+ // resolve to a profile that isn't in the config.
+ cfg := profilesConfig{
+ Profiles: map[string]profileDef{
+ "go": {},
+ "python": {},
+ },
+ }
+
+ names := cfg.profileNames()
+ for _, n := range names {
+ if n == "auto" || n == "full" || n == "minimal" {
+ t.Errorf("profileNames() = %v, must not include sentinel %q absent from config", names, n)
+ }
+ }
+ want := []string{"go", "python"}
+ if len(names) != len(want) {
+ t.Fatalf("profileNames() = %v, want %v", names, want)
+ }
+ for i := range want {
+ if names[i] != want[i] {
+ t.Errorf("profileNames()[%d] = %q, want %q", i, names[i], want[i])
+ }
+ }
+}
+
func TestParseSemver(t *testing.T) {
tests := []struct {
input string
diff --git a/root.go b/root.go
index 45d6c2a..e9d0eda 100644
--- a/root.go
+++ b/root.go
@@ -2,8 +2,8 @@ package main
import (
"fmt"
- "os"
"github.com/spf13/cobra"
+ "os"
)
var (
@@ -26,7 +26,7 @@ configurable MCP server registry, an outbound network firewall, and
persistent state. Configs and profiles live in ~/.config/back2base/.
Update: oss-back2base update`,
- Args: cobra.ArbitraryArgs,
+ Args: cobra.ArbitraryArgs,
SilenceUsage: true,
SilenceErrors: true,
}
@@ -37,12 +37,11 @@ func init() {
rootCmd.Flags().StringVarP(&flagRepo, "repo", "r", "", "Override the default repo mount")
rootCmd.PersistentFlags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompts")
rootCmd.PersistentFlags().BoolVar(&flagNoUpdateCheck, "no-update-check", false, "Skip the once-daily new-release check")
- rootCmd.Flags().StringVar(&flagProfile, "profile", "", "MCP server profile (full, go, frontend, infra, research, minimal)")
+ rootCmd.Flags().StringVar(&flagProfile, "profile", "", "MCP server profile (auto, full, go, frontend, infra, research, minimal)")
rootCmd.Flags().BoolVar(&flagOverview, "overview", false, "Run a pre-launch repo overview before Claude starts")
rootCmd.Flags().BoolVar(&flagNoOverview, "no-overview", false, "Skip the pre-launch repo overview (overrides remembered preference)")
rootCmd.Flags().StringVar(&flagNamespace, "namespace", "", "Memory namespace (overrides auto-derivation from git remote or .back2base/namespace)")
-
// Prepend the workspace-aware banner to the default help output so
// the box contents always reflect the cwd, not a compile-time const.
defaultHelp := rootCmd.HelpFunc()
diff --git a/run.go b/run.go
index ca771f2..f1b6266 100644
--- a/run.go
+++ b/run.go
@@ -81,7 +81,7 @@ func runClaude(cmd *cobra.Command, args []string) error {
// Resolve profile: flag > env > interactive selector (skip for one-shot prompts).
// `--profile=last` (or BACK2BASE_PROFILE=last) resolves to the remembered
- // value for this namespace, falling back to "full" if nothing was saved.
+ // value for this namespace, falling back to "auto" if nothing was saved.
profile := flagProfile
if profile == "" {
profile = os.Getenv("BACK2BASE_PROFILE")
@@ -90,17 +90,17 @@ func runClaude(cmd *cobra.Command, args []string) error {
if remembered.Profile != "" {
profile = remembered.Profile
} else {
- profile = "full"
+ profile = "auto"
}
}
if profile == "" {
if prompt != "" {
- // One-shot mode: default to remembered profile, else full,
+ // One-shot mode: default to remembered profile, else auto,
// without showing the interactive prompt.
if remembered.Profile != "" {
profile = remembered.Profile
} else {
- profile = "full"
+ profile = "auto"
}
} else {
// Interactive mode: show selector, pre-selecting the
diff --git a/run_test.go b/run_test.go
index af07512..d67d10c 100644
--- a/run_test.go
+++ b/run_test.go
@@ -319,10 +319,11 @@ func TestSelectProfileWithDefault_EmptyInputUsesDefault(t *testing.T) {
}
}
-func TestSelectProfileWithDefault_UnknownDefaultFallsBackToFull(t *testing.T) {
+func TestSelectProfileWithDefault_UnknownDefaultFallsBackToAuto(t *testing.T) {
cfg := profilesConfig{
Core: []string{"a"},
Profiles: map[string]profileDef{
+ "auto": {Description: "auto-detect", Servers: []string{}},
"full": {Description: "all", Servers: []string{}},
"minimal": {Description: "min", Servers: []string{}},
},
@@ -339,7 +340,7 @@ func TestSelectProfileWithDefault_UnknownDefaultFallsBackToFull(t *testing.T) {
if err != nil {
t.Fatalf("select: %v", err)
}
- if got != "full" {
- t.Errorf("unknown default → %q, want full", got)
+ if got != "auto" {
+ t.Errorf("unknown default → %q, want auto", got)
}
}