#!/usr/bin/env bash # install.sh — wire Steev profile distribution into Hermes. # Idempotent. Does NOT set secrets and does NOT enable cron. # # ./install.sh [--copy] [--dry-run] # # Default = SYMLINK mode: the repo is canonical, ~/.hermes/steev → this repo. # --copy = copy files into ~/.hermes/steev instead. set -euo pipefail REPO="$(cd "$(dirname "$0")" && pwd)" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" MODE=symlink; DRY=0 while [ $# -gt 0 ]; do case "$1" in --copy) MODE=copy ;; --dry-run) DRY=1 ;; *) echo "unknown arg: $1"; exit 2 ;; esac; shift; done CFG="$HERMES_HOME/profiles/steev/config.yaml" run() { if [ "$DRY" = 1 ]; then echo "DRY: $*"; else eval "$*"; fi; } echo "== preflight ==" for c in python3 sqlite3; do command -v "$c" >/dev/null || { echo "MISSING: $c"; exit 1; }; done echo "== link/copy repo → \$HERMES_HOME/steev ($MODE) ==" if [ "$MODE" = symlink ]; then if [ -e "$HERMES_HOME/steev" ] && [ ! -L "$HERMES_HOME/steev" ]; then echo "EXISTS (not a symlink): $HERMES_HOME/steev — remove it and re-run"; exit 1 fi run "ln -sfn '$REPO' '$HERMES_HOME/steev'" else run "mkdir -p '$HERMES_HOME/steev'" run "cp -r '$REPO'/skills '$REPO'/schema.sql '$HERMES_HOME/steev/'" fi echo "== register skills in steev profile config ==" SKILL_DIR="$REPO/skills" if [ "$DRY" = 0 ]; then python3 - "$CFG" "$SKILL_DIR" <<'PY' import sys, os, yaml cfg, sk = sys.argv[1], sys.argv[2] d = yaml.safe_load(open(cfg).read()) if os.path.exists(cfg) else {} d = d or {} d.setdefault('skills', {}).setdefault('external_dirs', []) if sk not in d['skills']['external_dirs']: d['skills']['external_dirs'].append(sk) open(cfg, 'w').write(yaml.dump(d, sort_keys=False, allow_unicode=True)) print(" +", sk) else: print(" already registered:", sk) PY else echo "DRY: register $SKILL_DIR in $CFG" fi echo "== steev.db ==" run "sqlite3 '$HERMES_HOME/steev/steev.db' < '$REPO/schema.sql'" echo "" echo "== hermes-native profile install (dispatch-readiness) ==" if [ "$DRY" = 1 ]; then echo "DRY: hermes profile install '$REPO' --yes --force" else hermes profile install "$REPO" --yes --force 2>&1 | tail -5 || \ echo " WARN: hermes profile install failed (legacy symlink still works)" fi echo "" # ---------------------------------------------------------------------------- # Wave 7 D6 — disclosure → runtime config wiring (F1-F4) # Materializes manifest.disclosure (schema v2) into the live Hermes runtime: # F1 resolve $HERMES_WORKSPACE in inherit_dirs → skills.external_dirs (global) # F2 compute denylist from disclosure.skills → skills.disabled (per-profile) # F3 propagate inherit_mcp_toolsets → agent.inherit_mcp_toolsets # F4 install subrepo pre-push disclosure-drift gate # Steev = personal scope: profile name is `steev` (no -planb suffix per FRAMEWORK §6.1). # All steps idempotent + graceful (WARN + skip on missing tooling). # ---------------------------------------------------------------------------- echo "== disclosure → runtime config (Wave 7 D6) ==" PROFILE_NAME="steev" HERMES_WORKSPACE="${HERMES_WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" GLOBAL_CFG="$HERMES_HOME/config.yaml" PROFILE_CFG="$HOME/.hermes/profiles/$PROFILE_NAME/config.yaml" # F1 — resolve $HERMES_WORKSPACE in disclosure.inherit_dirs → skills.external_dirs (global config) if [ "$DRY" = 1 ]; then echo "DRY: F1 expand inherit_dirs (HERMES_WORKSPACE=$HERMES_WORKSPACE) → $GLOBAL_CFG" elif command -v yq >/dev/null 2>&1; then INHERIT_DIRS=$(yq -r '.disclosure.inherit_dirs[]?' "$REPO/manifest.yaml" 2>/dev/null || true) if [ -n "$INHERIT_DIRS" ]; then while IFS= read -r raw; do [ -z "$raw" ] && continue resolved="${raw//\$HERMES_WORKSPACE/$HERMES_WORKSPACE}" if [ ! -d "$resolved" ]; then echo " WARN: F1 inherit_dir not found: $resolved (declared: $raw) — skipping" continue fi python3 - "$GLOBAL_CFG" "$resolved" <<'PY' import sys, os, yaml cfg, sk = sys.argv[1], sys.argv[2] d = yaml.safe_load(open(cfg).read()) if os.path.exists(cfg) else {} d = d or {} d.setdefault('skills', {}).setdefault('external_dirs', []) if sk not in d['skills']['external_dirs']: d['skills']['external_dirs'].append(sk) open(cfg, 'w').write(yaml.safe_dump(d, sort_keys=False, allow_unicode=True)) print(f" F1 + {sk}") else: print(f" F1 = {sk} (already present)") PY done <<< "$INHERIT_DIRS" else echo " F1: no disclosure.inherit_dirs declared — skip" fi else echo " WARN: F1 yq not on PATH — skipping inherit_dirs resolution" fi # F2 — compute denylist (all builtins NOT in disclosure.skills allowlist) → profile config if [ "$DRY" = 1 ]; then echo "DRY: F2 compute builtin denylist → $PROFILE_CFG (skills.disabled)" elif command -v hermes >/dev/null 2>&1 && command -v yq >/dev/null 2>&1; then # Try --json first; fall back to table parse w/ box-draw chars (Wave 5 parser). ALL_BUILTINS=$(hermes skills list --json 2>/dev/null | jq -r '.[] | select(.source=="builtin") | .name' 2>/dev/null || true) if [ -z "$ALL_BUILTINS" ]; then ALL_BUILTINS=$(hermes skills list 2>/dev/null | awk -F'│' 'NR>3 && /builtin/ {gsub(/^ +| +$/, "", $2); print $2}' || true) fi ALLOWLIST_BUILTIN=$(yq -r '.disclosure.skills[] | select(.source=="builtin") | .id' "$REPO/manifest.yaml" 2>/dev/null | sort -u) if [ -z "$ALL_BUILTINS" ]; then echo " WARN: F2 could not enumerate live builtins — skipping denylist" else SORTED_BUILTINS=$(echo "$ALL_BUILTINS" | sort -u) DENYLIST=$(comm -23 <(echo "$SORTED_BUILTINS") <(echo "${ALLOWLIST_BUILTIN:-}") | grep -v '^$' || true) mkdir -p "$(dirname "$PROFILE_CFG")" [ -f "$PROFILE_CFG" ] || : > "$PROFILE_CFG" python3 - "$PROFILE_CFG" </dev/null 2>&1; then INHERIT_MCP=$(yq -r '.disclosure.inherit_mcp_toolsets' "$REPO/manifest.yaml" 2>/dev/null || echo "null") if [ "$INHERIT_MCP" = "null" ] || [ -z "$INHERIT_MCP" ]; then echo " F3: disclosure.inherit_mcp_toolsets undeclared — skip" else mkdir -p "$(dirname "$PROFILE_CFG")" [ -f "$PROFILE_CFG" ] || : > "$PROFILE_CFG" python3 - "$PROFILE_CFG" "$INHERIT_MCP" <<'PY' import sys, yaml cfg, val = sys.argv[1], sys.argv[2] b = {"true": True, "false": False}.get(val.lower(), val) d = yaml.safe_load(open(cfg).read()) or {} d.setdefault('agent', {})['inherit_mcp_toolsets'] = b open(cfg, 'w').write(yaml.safe_dump(d, sort_keys=False, allow_unicode=True)) print(f" F3 wrote agent.inherit_mcp_toolsets: {b}") PY fi else echo " WARN: F3 yq not on PATH — skipping inherit_mcp_toolsets" fi # F4 — install subrepo pre-push hook (disclosure-drift gate) HOOK_DST="$REPO/.git/hooks/pre-push" if [ "$DRY" = 1 ]; then echo "DRY: F4 install pre-push hook → $HOOK_DST" elif [ ! -d "$REPO/.git" ]; then echo " WARN: F4 $REPO/.git missing — not a git checkout, skip" else cat > "$HOOK_DST" <<'HOOK_EOF' #!/usr/bin/env bash # pre-push.sh — Wave 7 D6 subrepo disclosure-drift gate # Schema v2 ref: ../sot/04-STANDARDS/DISCLOSURE-SCHEMA.md set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" PROFILE_DIR_NAME="$(basename "$REPO_ROOT")" # cmo | ceo | cto | steev | curator PROFILE_NAME="${PROFILE_DIR_NAME}-planb" # default: org-scoped C-suite naming # Personal-scope profiles drop the -planb suffix (FRAMEWORK §6.1). [ "$PROFILE_DIR_NAME" = "steev" ] && PROFILE_NAME="steev" VIOLATIONS=0 emit() { echo "[pre-push:$PROFILE_NAME] $*" >&2; } fail() { emit "BLOCK: $*"; VIOLATIONS=$((VIOLATIONS + 1)); } while read -r local_ref local_sha remote_ref remote_sha; do [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then base=$(git merge-base "$local_sha" jp 2>/dev/null || git rev-parse "$local_sha^" 2>/dev/null || echo "$local_sha") else base="$remote_sha" fi DELTA=$(git diff --name-only "$base..$local_sha" 2>/dev/null || true) [ -z "$DELTA" ] && continue # check 2: manifest governance block if echo "$DELTA" | grep -q '^manifest\.yaml$'; then emit "check 2: validating governance block…" python3 - "$REPO_ROOT/manifest.yaml" <<'PY' || fail "manifest.yaml: governance block invalid" import sys, yaml data = yaml.safe_load(open(sys.argv[1]).read()) or {} gov = data.get("governance") if not isinstance(gov, dict): print(f"missing governance: block", file=sys.stderr); sys.exit(1) req = ["org", "owner", "approval_authority", "vision_refs", "governing_protocols", "standards", "north_star"] missing = [k for k in req if k not in gov] if missing: print(f"governance missing: {', '.join(missing)}", file=sys.stderr); sys.exit(1) PY fi # check 3: identity doc frontmatter CHANGED_IDENTITY=$(echo "$DELTA" | grep -E '^(AGENT|CONTRACT|DISCLOSURE)\.md$' || true) if [ -n "$CHANGED_IDENTITY" ]; then emit "check 3: validating frontmatter…" for f in $CHANGED_IDENTITY; do python3 - "$REPO_ROOT/$f" <<'PY' || fail "$f: frontmatter invalid" import sys, re, yaml content = open(sys.argv[1]).read() m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if not m: print(f"missing YAML frontmatter", file=sys.stderr); sys.exit(1) fm = yaml.safe_load(m.group(1)) or {} req = {"name", "tier", "status", "owner", "source", "last_reviewed", "description"} missing = req - set(fm.keys()) if missing: print(f"frontmatter missing: {', '.join(missing)}", file=sys.stderr); sys.exit(1) PY done fi # check 6: disclosure drift (only if manifest changed) if echo "$DELTA" | grep -q '^manifest\.yaml$'; then emit "check 6: disclosure drift…" # 6.a skills drift if command -v hermes >/dev/null 2>&1; then declared=$(yq -r '.disclosure.skills[].id' "$REPO_ROOT/manifest.yaml" 2>/dev/null | sort -u) live=$(hermes -p "$PROFILE_NAME" skills list 2>/dev/null | awk 'NR>3 && /enabled|│ *enabled/ {for (i=1; i<=NF; i++) if ($i != "│" && $i != "enabled") {print $i; break}}' | sort -u || echo "") if [ -n "$live" ]; then drift=$(diff <(echo "$declared") <(echo "$live") 2>/dev/null || true) [ -n "$drift" ] && fail "skills drift: $drift" else emit " (skip 6.a — hermes CLI live-skill query returned empty; WARN)" fi else emit " (skip 6.a — hermes CLI not on PATH; WARN)" fi # 6.b/6.c/6.d/6.e simplified: just confirm manifest yq parses + sovereign_only invariant if [ "$(yq '.disclosure.sovereign_only' "$REPO_ROOT/manifest.yaml")" = "true" ]; then bad=$(yq -r '.disclosure.skills[] | select(.hosted_api != null) | .id' "$REPO_ROOT/manifest.yaml" 2>/dev/null) [ -n "$bad" ] && fail "sovereign_only=true but skills declare hosted_api: $bad" fi fi # bypass marker categorization for sha in $(git rev-list "$base..$local_sha"); do MSG=$(git log -1 --format=%B "$sha") bypass_line=$(echo "$MSG" | grep -E '^enforcement-bypass:' || true) if [ -n "$bypass_line" ]; then if ! echo "$bypass_line" | grep -qE '^enforcement-bypass: (emergency|upstream-blocker|schema-migration|hermes-bug|third-party-bug) — '; then fail "$sha: bypass marker uncategorized — required: 'enforcement-bypass: '" fi fi done done if [ "$VIOLATIONS" -gt 0 ]; then emit "✗ $VIOLATIONS violation(s) — push blocked" exit 1 fi emit "✓ subrepo pre-push gate passed" exit 0 HOOK_EOF chmod +x "$HOOK_DST" echo " F4 installed: $HOOK_DST" fi echo "" echo "== done ==" echo " verify skills: hermes -p steev skills list | grep steev-agent" echo " verify assignee registered: hermes kanban assignees | grep steev" echo " start gateway (when ready): hermes profile gateway start steev"