smith-update · git:20260923.37b24fb · 2026-09-23 · sha256 81a165bc68c79465
smith-update git:20260923.37b24fbA
Immutable. This exact content never changes and is served at /api/v1/blob/81a165bc68c79465.
---
name: smith-update
description: Update Smith to the latest upstream version. Compares the installed commit SHA against https://github.com/ATTCKDigital/smith main, prompts to update if behind, and refreshes global install (~/.claude/skills, ~/.claude/hooks, ~/.smith) plus per-project artifacts (.specify/scripts, .claude/commands/smith.*, CLAUDE.md / constitution.md via migrate-templates, optional manifest sidecar bootstrap). Workflow-gate compliant.
---
# Smith — Update to Latest
End-to-end skill for keeping Smith up to date. Detects local-vs-upstream commit drift, prompts to update, and runs the existing installer plus per-project refresh steps when invoked inside a Smith-initialized project.
**Arguments:** $ARGUMENTS (no positional args; reserved for future flags like `--check` / `--no-project`)
## When to Use
- "is smith out of date?" / "update smith" / "let's update smith"
- After learning of a new Smith feature you want to adopt
- After an old project's `.smith/index/` was generated against an earlier manifest schema and needs regeneration
## Vault Logging
Throughout this action, log significant events to the vault session log. Read the session log path from `.smith/vault/.current-session`. If the file is missing or the vault is not initialized, skip all logging silently.
**Timestamps are UTC.** `HH:MM:SS` comes from `date -u +%H:%M:%S` — never a local clock, never a time from memory. The append already runs through a shell, so read the clock in that same command (`TS=$(date -u +%H:%M:%S)`) and spend no extra tool call. Substitute the resulting value: an unexpanded `$(...)` reaching the log is a known regression and will not parse. Every hook-written stamp in this log is `date -u`, so a local-clock entry lands hours away from its neighbours and skews the workflow window `hooks/workflow_summary_lib.py` derives from them.
Format:
```
### [HH:MM:SS] /smith-update <event>
**User Request:**
> <verbatim user message that triggered this action>
**Synthesized Input:** <brief summary>
**Outcome:** <what happened>
**Artifacts:** <files created/modified>
**Systems affected:** smith-update (smith-repo distribution)
```
Log at: invocation, after version detection (with SHAs + commits-behind), after global update, after per-project update, on completion.
## Natural Language Triggers
If the user says any of these (or similar), treat as invoking this command:
- "update smith"
- "let's update smith"
- "is smith out of date"
- "smith is behind"
- "pull the latest smith"
---
## Phase 0: Activate Workflow Tracking (Workflow-Gate Compliance)
`/smith-update` writes many files: install.sh's destructive `cp -R` of skills, the version file, settings.json merge, per-project script copies, etc. The PreToolUse workflow-gate hook (PR #20) would deny all of these without an active marker. So create the marker first.
**Skip if no `.smith/` directory exists in the current working directory** (the gate naturally exits silent in that case per PR #20, so no marker is needed). Otherwise create the marker via the shipped helper — the gate exempts `create-active-workflow.sh` by basename (per spec/31-workflow-gate-bootstrap) and deliberately blocks raw redirection into `active-workflows/` to prevent forged markers, so a `cat > marker.yaml` heredoc here would be DENIED when no marker exists yet:
```bash
TS=$(date -u +"%Y-%m-%dT%H-%M-%SZ")
PROJECT_DIR=$(pwd)
if [ -d "$PROJECT_DIR/.smith" ]; then
~/.smith/scripts/create-active-workflow.sh \
--branch "update-${TS}" \
--workflow smith-update \
--slug "update-${TS}" \
--worktree "$PROJECT_DIR"
# (Falls back to scripts/create-active-workflow.sh in repo-dev layouts.)
# remember marker path for cleanup at end
MARKER_PATH="$PROJECT_DIR/.smith/vault/active-workflows/update-${TS}.yaml"
fi
```
The marker is removed in Phase 6 cleanup (or on exit-with-no-changes paths). Use the shipped helper so it works under a `Bash(rm:*)` deny rule:
```bash
[ -n "${MARKER_PATH:-}" ] && [ -f "$MARKER_PATH" ] && \
"$PROJECT_DIR/.specify/scripts/bash/clear-active-workflow.sh" "update-${TS}" 2>/dev/null || true
```
---
## Phase 1: Detect Upstream and Installed Versions
```bash
UPSTREAM_REMOTE="https://github.com/ATTCKDigital/smith.git"
SMITH_HOME="$HOME/.smith"
VERSION_FILE="$SMITH_HOME/.installed-version"
# Upstream SHA (network call — graceful on failure per Q4)
UPSTREAM_OUT=$(git ls-remote "$UPSTREAM_REMOTE" refs/heads/main 2>&1)
if [ $? -ne 0 ]; then
echo "Unable to reach upstream — $UPSTREAM_OUT" >&2
# cleanup marker, exit cleanly
exit 0
fi
UPSTREAM_SHA=$(echo "$UPSTREAM_OUT" | awk '{print $1}')
# Installed SHA (per Q2-D: handle absent file by establishing baseline)
if [ -f "$VERSION_FILE" ]; then
INSTALLED_SHA=$(cat "$VERSION_FILE" | tr -d ' \n\r')
else
# First invocation since /smith-update shipped. Establish baseline silently.
mkdir -p "$SMITH_HOME"
echo "$UPSTREAM_SHA" > "$VERSION_FILE"
echo "Smith is up to date — version baseline established at ${UPSTREAM_SHA:0:7}."
# cleanup marker, exit
exit 0
fi
```
## Phase 2: Compute Commits-Behind
Per Q5-A: `gh` first, shallow-clone fallback.
```bash
COMMITS_BEHIND=""
if command -v gh >/dev/null 2>&1; then
COMMITS_BEHIND=$(gh api "repos/ATTCKDigital/smith/compare/${INSTALLED_SHA}...${UPSTREAM_SHA}" --jq '.ahead_by' 2>/dev/null)
fi
if [ -z "$COMMITS_BEHIND" ] || ! [[ "$COMMITS_BEHIND" =~ ^[0-9]+$ ]]; then
# Fallback: shallow-clone the upstream and use git rev-list --count.
TMPCLONE=$(mktemp -d -t smith-update-XXXXXX)
trap 'rm -rf "$TMPCLONE"' EXIT # extended at Phase 6 to include marker cleanup
if ! git clone --quiet "$UPSTREAM_REMOTE" "$TMPCLONE/smith" 2>/dev/null; then
echo "Unable to reach upstream — clone failed." >&2
exit 0
fi
COMMITS_BEHIND=$(git -C "$TMPCLONE/smith" rev-list --count "${INSTALLED_SHA}..${UPSTREAM_SHA}" 2>/dev/null || echo "?")
fi
# Up-to-date short-circuit
if [ "$COMMITS_BEHIND" = "0" ]; then
echo "Smith is up to date (commit ${UPSTREAM_SHA:0:7})."
exit 0
fi
# Prompt
echo "Smith is $COMMITS_BEHIND commits behind. Update? (y/n)"
read -r REPLY
case "$REPLY" in
[yY]|[yY][eE][sS]) ;;
*) echo "Update declined."; exit 0 ;;
esac
```
## Phase 3: Snapshot (Q7-A Rollback Safety)
Before any destructive work, snapshot the global install state. On non-zero exit from install.sh, restore.
```bash
BACKUP_ROOT="$SMITH_HOME/.backups"
BACKUP_DIR="$BACKUP_ROOT/update-${TS}"
mkdir -p "$BACKUP_DIR"
# Snapshot the three writable areas
cp -R "$HOME/.claude/skills" "$BACKUP_DIR/skills" 2>/dev/null || true
cp -R "$HOME/.claude/hooks" "$BACKUP_DIR/hooks" 2>/dev/null || true
cp "$HOME/.claude/settings.json" "$BACKUP_DIR/settings.json" 2>/dev/null || true
# GC: keep last 3 (Q7-A keep-last-N policy)
ls -t "$BACKUP_ROOT" 2>/dev/null | tail -n +4 | while read -r old; do
rm -rf "$BACKUP_ROOT/$old"
done
# Restore function — invoked if install.sh fails
restore_snapshot() {
echo "Update failed — restoring from $BACKUP_DIR" >&2
rm -rf "$HOME/.claude/skills" "$HOME/.claude/hooks"
cp -R "$BACKUP_DIR/skills" "$HOME/.claude/skills" 2>/dev/null || true
cp -R "$BACKUP_DIR/hooks" "$HOME/.claude/hooks" 2>/dev/null || true
cp "$BACKUP_DIR/settings.json" "$HOME/.claude/settings.json" 2>/dev/null || true
}
```
## Phase 3.5: Stop a Running Activity Daemon
`scripts/install.sh` refreshes `~/.smith/scripts/activity/` in place. A daemon started by `/smith-activity` has already imported the **old** `server.py`, `state.py` and friends into a long-lived Python process; rewriting the files underneath it leaves a process running code that no longer exists on disk, and any module it imports lazily afterwards comes from the new tree. That mix is worse than either version alone, and it survives until something happens to restart it.
So: stop it before Phase 4, and **do not restart it afterwards**. An update silently bringing a background listener back up is exactly the surprise OOS-4 rules out — the operator started that daemon deliberately and is the one who decides it comes back.
```bash
# Was a daemon running before we touched anything? Record it for the summary.
ACTIVITY_WAS_RUNNING=0
ACTIVITY_CLI="$SMITH_HOME/scripts/activity/smith-activity.sh"
ACTIVITY_PIDFILE="$SMITH_HOME/activity/activity.pid"
if [ -f "$ACTIVITY_PIDFILE" ]; then
ACTIVITY_PID=$(cat "$ACTIVITY_PIDFILE" 2>/dev/null)
if [ -n "$ACTIVITY_PID" ] && kill -0 "$ACTIVITY_PID" 2>/dev/null; then
ACTIVITY_WAS_RUNNING=1
echo "Stopping /smith-activity daemon (pid $ACTIVITY_PID) before refreshing its code..."
# Use the CLI so the pidfile, port file and token are cleaned up the
# same way a normal stop cleans them. Fall back to the signal only if
# the CLI is missing — a half-installed tree is exactly when this
# matters most.
if [ -x "$ACTIVITY_CLI" ]; then
"$ACTIVITY_CLI" stop >/dev/null 2>&1 || kill "$ACTIVITY_PID" 2>/dev/null || true
else
kill "$ACTIVITY_PID" 2>/dev/null || true
fi
fi
fi
```
With the daemon down, `hooks/activity-emitter.sh` is an unconditional no-op — no port file means it exits 0 before any network call — so nothing else in the update needs to know about it.
## Phase 4: Global Update
Clone smith-repo to a temp dir (or reuse the one from Phase 2's fallback), run installer, write version file.
```bash
if [ -z "${TMPCLONE:-}" ] || ! [ -d "$TMPCLONE/smith" ]; then
TMPCLONE=$(mktemp -d -t smith-update-XXXXXX)
trap 'rm -rf "$TMPCLONE"' EXIT
git clone --quiet "$UPSTREAM_REMOTE" "$TMPCLONE/smith"
fi
# Q1-D settings.json dedupe BEFORE running install.sh, so the installer's
# jq merge doesn't compound onto existing duplicates. Dedupe ONLY Smith-owned
# hook entries (those referencing ~/.claude/hooks/<known-name>.sh).
"$TMPCLONE/smith/scripts/dedupe-settings.sh" "$HOME/.claude/settings.json" 2>/dev/null || true
# Run the installer
if ! "$TMPCLONE/smith/scripts/install.sh" -y; then
restore_snapshot
echo "install.sh failed. Restored from snapshot at $BACKUP_DIR." >&2
exit 1
fi
# install.sh now writes $SMITH_HOME/.installed-version itself (this PR adds that),
# but write it again here defensively in case install.sh was pre-versioning.
echo "$UPSTREAM_SHA" > "$VERSION_FILE"
```
## Phase 5: Per-Project Update (Conditional)
Only runs if `$PROJECT_DIR/.smith/` exists. Refresh Smith-owned files; never touch user files; offer manifest bootstrap if `.smith/index/` is absent.
### 5.1 Refresh `.specify/scripts/bash/` and `.claude/commands/smith.*`
Smith-owned files. Safe to overwrite. **Detect orphans first (Q6-A + Q8-B unified pass).**
```bash
if [ -d "$PROJECT_DIR/.smith" ]; then
# Collect orphans before copying new files.
UPSTREAM_SCRIPTS="$TMPCLONE/smith/skills/smith/scripts"
LOCAL_SCRIPTS="$PROJECT_DIR/.specify/scripts/bash"
ORPHAN_SCRIPTS=()
if [ -d "$LOCAL_SCRIPTS" ]; then
for f in "$LOCAL_SCRIPTS"/*; do
name=$(basename "$f")
[ -f "$UPSTREAM_SCRIPTS/$name" ] || ORPHAN_SCRIPTS+=("$name")
done
fi
UPSTREAM_CMDS="$TMPCLONE/smith/skills/smith/commands"
LOCAL_CMDS="$PROJECT_DIR/.claude/commands"
ORPHAN_CMDS=()
if [ -d "$LOCAL_CMDS" ]; then
for f in "$LOCAL_CMDS"/smith.*.md; do
[ -e "$f" ] || continue
name=$(basename "$f")
[ -f "$UPSTREAM_CMDS/$name" ] || ORPHAN_CMDS+=("$name")
done
fi
UPSTREAM_HOOKS="$TMPCLONE/smith/hooks"
LOCAL_HOOKS="$HOME/.claude/hooks"
ORPHAN_HOOKS=()
if [ -d "$LOCAL_HOOKS" ]; then
for f in "$LOCAL_HOOKS"/*.sh; do
[ -e "$f" ] || continue
name=$(basename "$f")
[ -f "$UPSTREAM_HOOKS/$name" ] || ORPHAN_HOOKS+=("$name")
done
fi
# Q6/Q8 unified orphan prompt (default = n)
if [ ${#ORPHAN_SCRIPTS[@]} -gt 0 ] || [ ${#ORPHAN_CMDS[@]} -gt 0 ] || [ ${#ORPHAN_HOOKS[@]} -gt 0 ]; then
echo ""
echo "Orphaned Smith-owned files detected (no longer in upstream):"
[ ${#ORPHAN_HOOKS[@]} -gt 0 ] && printf " hooks: %s\n" "${ORPHAN_HOOKS[@]}"
[ ${#ORPHAN_SCRIPTS[@]} -gt 0 ] && printf " scripts: %s\n" "${ORPHAN_SCRIPTS[@]}"
[ ${#ORPHAN_CMDS[@]} -gt 0 ] && printf " commands: %s\n" "${ORPHAN_CMDS[@]}"
echo "Remove these? (y/N)"
read -r REPLY
case "$REPLY" in
[yY]|[yY][eE][sS])
for n in "${ORPHAN_HOOKS[@]}"; do rm -f "$LOCAL_HOOKS/$n"; done
for n in "${ORPHAN_SCRIPTS[@]}"; do rm -f "$LOCAL_SCRIPTS/$n"; done
for n in "${ORPHAN_CMDS[@]}"; do rm -f "$LOCAL_CMDS/$n"; done
;;
*) echo "Keeping orphaned files." ;;
esac
fi
# Refresh Smith-owned files (touch only smith.*.md in .claude/commands per Q8-B)
if [ -d "$UPSTREAM_SCRIPTS" ] && [ -d "$LOCAL_SCRIPTS" ]; then
cp "$UPSTREAM_SCRIPTS"/*.sh "$LOCAL_SCRIPTS/" 2>/dev/null || true
chmod +x "$LOCAL_SCRIPTS"/*.sh
fi
if [ -d "$UPSTREAM_CMDS" ] && [ -d "$LOCAL_CMDS" ]; then
cp "$UPSTREAM_CMDS"/smith.*.md "$LOCAL_CMDS/" 2>/dev/null || true
fi
fi
```
### 5.1b Seed `browser_verification.urls` in `.smith/security-config.json`
Same key, same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`. `.smith/security-config.json` is Smith-owned config (not vault *data*), so it is in scope for this phase's "refresh Smith-owned files" boundary — the "NEVER touch `.smith/vault/`" rule protects vault data specifically, not this file.
- If `.smith/security-config.json` **exists** and already declares `browser_verification.urls`, this is a no-op.
- If it **exists** but lacks `browser_verification.urls`, merge the key in as `{"staging": [], "production": []}`, preserving every other existing key (`warn_only_mode`, `allowed_commands`, `production_domains`, etc.) untouched.
- If it does **not exist at all**, leave it absent — do not invent a security-config file for a project that never opted into one (the guard already no-ops on a missing config file per FR-6/NFR-1).
```bash
if [ -f "$PROJECT_DIR/.smith/security-config.json" ]; then
python3 - "$PROJECT_DIR/.smith/security-config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict):
bv = config.get("browser_verification")
if not isinstance(bv, dict) or "urls" not in bv:
bv = bv if isinstance(bv, dict) else {}
bv.setdefault("urls", {"staging": [], "production": []})
config["browser_verification"] = bv
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.1c Seed `security_review` in `.smith/config.json`
Same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`, for the `security_review` config key (this feature's `data-model.md` §1 schema; see `docs/security-model.md`'s "Security Review Pass" section). `.smith/config.json` is Smith-owned config, not vault *data*, so it is in scope for this phase's "refresh Smith-owned files" boundary.
- If `.smith/config.json` **exists** and already declares a `security_review` key (in any shape), this is a no-op.
- If it **exists** but lacks `security_review` entirely, merge the key in with `data-model.md` §1's full default shape (the same shape `templates/config.default.json` ships), preserving every other existing key untouched.
- If it does **not exist at all**, leave it absent — `hooks/session-start-logger.sh`'s own whole-file seeding path is what creates it from scratch for a brand-new project (already carrying `security_review` per T012), not this step.
```bash
if [ -f "$PROJECT_DIR/.smith/config.json" ]; then
python3 - "$PROJECT_DIR/.smith/config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict) and "security_review" not in config:
config["security_review"] = {
"enforcement_tier": "flag",
"review_model": "opus",
"layers": {
"secret_scan": True,
"sast": True,
"llm_review": True
},
"excludes": [],
"allowlist_globs": ["package-lock.json", "yarn.lock"]
}
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.1d Seed `supply_chain` in `.smith/config.json`
Same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`, for the `supply_chain` config key (this feature's `data-model.md` §1 schema; see `docs/security-model.md`'s "Supply-Chain & License Review" section). `.smith/config.json` is Smith-owned config, not vault *data*, so it is in scope for this phase's "refresh Smith-owned files" boundary.
- If `.smith/config.json` **exists** and already declares a `supply_chain` key (in any shape), this is a no-op.
- If it **exists** but lacks `supply_chain` entirely, merge the key in with `data-model.md` §1's full default shape (the same shape `templates/config.default.json` ships), preserving every other existing key untouched.
- If it does **not exist at all**, leave it absent — `hooks/session-start-logger.sh`'s own whole-file seeding path is what creates it from scratch for a brand-new project (already carrying `supply_chain`), not this step.
```bash
if [ -f "$PROJECT_DIR/.smith/config.json" ]; then
python3 - "$PROJECT_DIR/.smith/config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict) and "supply_chain" not in config:
config["supply_chain"] = {
"cve_scan": True,
"license_inventory": True,
"license_policy": {
"allow": [],
"deny": []
},
"timeout_seconds": 60,
"excludes": []
}
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.1e Seed `quality` in `.smith/config.json`
Same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`, for the `quality` config key (spec FR-21, `plan.md`'s §Contracts schema). `.smith/config.json` is Smith-owned config, not vault *data*, so it is in scope for this phase's "refresh Smith-owned files" boundary.
- If `.smith/config.json` **exists** and already declares a `quality` key (in any shape), this is a no-op.
- If it **exists** but lacks `quality` entirely, merge the key in with `plan.md`'s §Contracts full default shape (the same shape `templates/config.default.json` ships), preserving every other existing key untouched.
- If it does **not exist at all**, leave it absent — `hooks/session-start-logger.sh`'s own whole-file seeding path is what creates it from scratch for a brand-new project (already carrying `quality`), not this step.
```bash
if [ -f "$PROJECT_DIR/.smith/config.json" ]; then
python3 - "$PROJECT_DIR/.smith/config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict) and "quality" not in config:
config["quality"] = {
"test": [],
"lint": [],
"typecheck": [],
"coverage": {
"command": None,
"minimum_percent": None,
"regex": None
},
"function_length": {
"soft": 50,
"decompose": 100
},
"timeout_seconds": 120,
"excludes": []
}
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.1f Seed `scheduled_audits` in `.smith/config.json`
Same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`, for the `scheduled_audits` config key (spec FR-19/FR-20 of the `58-scheduled-audits` feature, `plan.md`'s §Contracts schema). `.smith/config.json` is Smith-owned config, not vault *data*, so it is in scope for this phase's "refresh Smith-owned files" boundary.
- If `.smith/config.json` **exists** and already declares a `scheduled_audits` key (in any shape), this is a no-op.
- If it **exists** but lacks `scheduled_audits` entirely, merge the key in with `plan.md`'s §Contracts full default shape (the same shape `templates/config.default.json` ships), preserving every other existing key untouched. `enabled: false` MUST be the merged value — no project gets unattended scheduled-audit dispatch, report-writing, or marker-bootstrapping behavior without explicit opt-in.
- If it does **not exist at all**, leave it absent — `hooks/session-start-logger.sh`'s own whole-file seeding path is what creates it from scratch for a brand-new project (already carrying `scheduled_audits`), not this step.
```bash
if [ -f "$PROJECT_DIR/.smith/config.json" ]; then
python3 - "$PROJECT_DIR/.smith/config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict) and "scheduled_audits" not in config:
config["scheduled_audits"] = {
"enabled": False,
"cadence_days": 7,
"subsets": ["requirements", "codequality", "security", "dependencies", "workflow"],
"systems": "--all",
"skip_pdf": True
}
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.1g Seed `question_gate` in `.smith/config.json`
Same non-destructive merge idiom as `/smith` init's scaffold step (`skills/smith/SKILL.md`), applied here to existing projects refreshed by `/smith-update`, for the `question_gate` config key (spec FR-15/FR-16 of the `67-deterministic-questions` feature). `.smith/config.json` is Smith-owned config, not vault *data*, so it is in scope for this phase's "refresh Smith-owned files" boundary.
- If `.smith/config.json` **exists** and already declares a `question_gate` key (in any shape), this is a no-op.
- If it **exists** but lacks `question_gate` entirely, merge the key in with `mode: "deny"` (the shape `templates/config.default.json` ships), preserving every other existing key untouched. This is the intended propagation path (feature 67 FR-21): running `/smith-update` on an existing project is what activates `deny` there — before that, `question-gate-guard.sh` treats the absent key as `off` (fail-open), so installing the hook never silently changes an un-migrated project's behavior.
- If it does **not exist at all**, leave it absent — `hooks/session-start-logger.sh`'s own whole-file seeding path creates it from scratch for a brand-new project (already carrying `question_gate`), not this step.
```bash
if [ -f "$PROJECT_DIR/.smith/config.json" ]; then
python3 - "$PROJECT_DIR/.smith/config.json" << 'PYEOF'
import json, sys
path = sys.argv[1]
try:
with open(path) as f:
config = json.load(f)
except Exception:
config = None
if isinstance(config, dict) and "question_gate" not in config:
config["question_gate"] = {
"mode": "deny"
}
with open(path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
PYEOF
fi
```
### 5.2 Run `/smith-index --migrate-templates`
Non-destructively merge new template sections into `CLAUDE.md` and `constitution.md`. Invoke as a sub-action of this skill.
```bash
if [ -d "$PROJECT_DIR/.smith" ]; then
# Invoke /smith-index --migrate-templates as a Claude Code skill invocation
# (the parent assistant runs this; not shellable from here directly).
# See "Invoking sub-skills" note in the Key Rules section below.
echo "Running /smith-index --migrate-templates..."
fi
```
**Note**: bash can't directly invoke another Claude skill. The PARENT ASSISTANT (the one running this skill) must invoke `/smith-index --migrate-templates` as a Claude tool call after the bash block above echoes its placeholder. This is the only sub-skill invocation in `/smith-update`.
### 5.3 Q9-B — Schema Version Check + Regeneration Prompt
```bash
if [ -d "$PROJECT_DIR/.smith/index" ]; then
UPSTREAM_SCHEMA_VERSION=$(cat "$TMPCLONE/smith/scripts/parsers/meta_schema_version.txt" 2>/dev/null | tr -d ' \n\r')
LOCAL_SCHEMA_VERSION=$(cat "$PROJECT_DIR/.smith/index/.schema-version" 2>/dev/null | tr -d ' \n\r')
LOCAL_SCHEMA_VERSION="${LOCAL_SCHEMA_VERSION:-1}" # absent → assume legacy v1
if [ -n "$UPSTREAM_SCHEMA_VERSION" ] && [ "$UPSTREAM_SCHEMA_VERSION" != "$LOCAL_SCHEMA_VERSION" ]; then
echo ""
echo "Smith manifest schema mismatch:"
echo " Local: v${LOCAL_SCHEMA_VERSION}"
echo " Upstream: v${UPSTREAM_SCHEMA_VERSION}"
echo "Regenerate the manifest? (y/N) — runs /smith-index to refresh all .meta files."
read -r REPLY
case "$REPLY" in
[yY]|[yY][eE][sS])
# Parent assistant runs /smith-index here
echo "Running /smith-index..."
;;
*) echo "Keeping current manifest. You can regenerate later with /smith-index." ;;
esac
fi
fi
```
### 5.4 Manifest Sidecar Bootstrap Prompt (Q3-B)
If `.smith/index/` doesn't exist at all (project predates manifest system, PR #19), offer the bootstrap.
```bash
if [ -d "$PROJECT_DIR/.smith" ] && ! [ -d "$PROJECT_DIR/.smith/index" ]; then
echo ""
echo "Smith manifest sidecar is missing for this project. Bootstrap now?"
echo " (1) Structural only — fast, runs /smith-index"
echo " (2) Structural + LLM descriptions — slow, runs /smith-index --describe"
echo " (3) Defer — skip (you can run /smith-index manually later)"
echo "Default: 3 (defer)"
read -r REPLY
case "$REPLY" in
1) echo "Running /smith-index..." ;; # parent assistant invokes
2) echo "Running /smith-index --describe..." ;; # parent assistant invokes
*) echo "Deferred. Run /smith-index when ready." ;;
esac
fi
```
### 5.5 Refresh `.gitignore` / `.gitattributes` Policy (Feature 36)
Re-merge the canonical team-shareable vault & index policy into the project-root
`.gitignore` and `.gitattributes`, idempotently, using the same sentinel logic as
`/smith` init. This is **project-config**, not vault data — merging managed lines in
the project-root `.gitignore`/`.gitattributes` does NOT violate the
"NEVER touch `.smith/vault/`" rule (which protects vault *data*: bank, ledger,
sessions, queue, agents, todo). Confirmed permitted for this step.
Source templates (upstream clone first, installed copy fallback):
- `$TMPCLONE/smith/skills/smith-index/templates/.gitignore-smith-additions`
(or installed `~/.claude/skills/smith-index/templates/.gitignore-smith-additions`)
- `$TMPCLONE/smith/skills/smith-index/templates/.gitattributes-smith-additions`
**Staleness guard + auto-repair (Feature 38).** The merge logic below trusts
whatever template `$SMITH_TPL_DIR` resolves to. A pre-Feature-36 installed
template is the *inverse* policy (it IGNORES `index/files/` + `index/systems/`)
and has NO sentinel marker. Applying it would silently write the wrong policy.
So before merging: if the resolved source is the INSTALLED copy and it lacks the
`# >>> smith-gitignore-policy >>>` sentinel, treat it as stale. In `/smith-update`
an upstream clone (`$TMPCLONE`) is normally present, so we AUTO-REPAIR — overwrite
the stale installed template from the clone, then merge from the refreshed copy.
(`scripts/install.sh` already `cp -R`'d the whole skill dir during Phase 4, so a
clean update keeps the installed copy current; this guard catches the case where
§5.5 runs against a stale installed copy anyway.)
```bash
if [ -d "$PROJECT_DIR/.smith" ]; then
INSTALLED_TPL_DIR="$HOME/.claude/skills/smith-index/templates"
CLONE_TPL_DIR="$TMPCLONE/smith/skills/smith-index/templates"
SENTINEL='# >>> smith-gitignore-policy >>>'
# Prefer the upstream clone (authoritative this run); else the installed copy.
if [ -f "$CLONE_TPL_DIR/.gitignore-smith-additions" ]; then
SMITH_TPL_DIR="$CLONE_TPL_DIR"
else
SMITH_TPL_DIR="$INSTALLED_TPL_DIR"
fi
# Staleness guard + auto-repair: if we're about to use the INSTALLED template
# and it predates Feature 36 (no sentinel), repair it from the clone if we can.
if [ "$SMITH_TPL_DIR" = "$INSTALLED_TPL_DIR" ] \
&& ! grep -qF "$SENTINEL" "$INSTALLED_TPL_DIR/.gitignore-smith-additions" 2>/dev/null; then
if [ -f "$CLONE_TPL_DIR/.gitignore-smith-additions" ]; then
echo "NOTE: installed gitignore template is stale (pre-Feature-36) — repairing from upstream clone."
mkdir -p "$INSTALLED_TPL_DIR"
cp -f "$CLONE_TPL_DIR/.gitignore-smith-additions" "$INSTALLED_TPL_DIR/.gitignore-smith-additions"
cp -f "$CLONE_TPL_DIR/.gitattributes-smith-additions" "$INSTALLED_TPL_DIR/.gitattributes-smith-additions" 2>/dev/null || true
SMITH_TPL_DIR="$INSTALLED_TPL_DIR" # now refreshed; safe to use
else
echo "WARNING: installed gitignore template is stale (pre-Feature-36) and no upstream"
echo " clone is available to repair it. Skipping the .gitignore/.gitattributes"
echo " policy merge to avoid writing the wrong (inverted) policy. Re-run"
echo " /smith-update with network access to refresh the installed template."
SMITH_TPL_DIR="" # sentinel for "skip merge" — handled below
fi
fi
if [ -n "$SMITH_TPL_DIR" ]; then
# Q4-A: WARN (do not remove) on known-conflicting bare ignore lines that sit
# OUTSIDE the sentinels and shadow the now-committed shared paths.
GI="$PROJECT_DIR/.gitignore"
if [ -f "$GI" ]; then
# Strip the managed sentinel region first, then scan the remainder.
OUTSIDE=$(awk '
/# >>> smith-gitignore-policy >>>/ {inblk=1}
!inblk {print}
/# <<< smith-gitignore-policy <<</ {inblk=0}
' "$GI")
for bad in '.smith/' '.smith/index/' '.smith/index/files/' '.smith/index/systems/'; do
if printf '%s\n' "$OUTSIDE" | grep -qxF "$bad"; then
LINE=$(grep -nxF "$bad" "$GI" | head -1 | cut -d: -f1)
echo ""
echo "WARNING: $GI line $LINE contains a bare '$bad' ignore OUTSIDE the Smith"
echo " sentinels. This shadows the now-committed shared paths"
echo " (manifest, .meta describe layer, ledger, bank, agents, sessions)."
echo " Smith will NOT remove it automatically. Remove it manually so the"
echo " shared Smith artifacts can be committed."
fi
done
fi
# Idempotent sentinel replace-or-append (same contract as /smith init §4.7).
python3 - "$SMITH_TPL_DIR" "$PROJECT_DIR" <<'PYEOF'
import sys, pathlib
tpl_dir = pathlib.Path(sys.argv[1]); root = pathlib.Path(sys.argv[2])
def merge(target_path, tpl_path, open_marker, close_marker):
tpl = pathlib.Path(tpl_path)
if not tpl.exists():
print(f"WARNING: template not found: {tpl_path} — skipping"); return
block = tpl.read_text().rstrip("\n") + "\n"
target = pathlib.Path(target_path)
existing = target.read_text() if target.exists() else ""
if open_marker in existing and close_marker in existing:
pre = existing.split(open_marker, 1)[0]
post = existing.split(close_marker, 1)[1]
new = pre.rstrip("\n") + ("\n\n" if pre.strip() else "") + block + post.lstrip("\n")
action = "refreshed"
else:
sep = "" if (not existing or existing.endswith("\n\n")) else ("\n" if existing.endswith("\n") else "\n\n")
new = existing + sep + block
action = "appended"
target.write_text(new)
print(f"{action}: {open_marker.split()[1]} in {target}")
merge(root / ".gitignore", tpl_dir / ".gitignore-smith-additions",
"# >>> smith-gitignore-policy >>>", "# <<< smith-gitignore-policy <<<")
merge(root / ".gitattributes", tpl_dir / ".gitattributes-smith-additions",
"# >>> smith-gitattributes-policy >>>", "# <<< smith-gitattributes-policy <<<")
PYEOF
fi # end: SMITH_TPL_DIR non-empty (merge not skipped by staleness guard)
fi
```
Per Q4-A the merge only manages the region BETWEEN the sentinels and never deletes
user-authored lines; the warning above is the safe nudge for the operator to clean up
a stale blanket ignore by hand. Per Feature 38 the staleness guard ensures the merge
never applies a pre-Feature-36 (inverted, sentinel-less) installed template: in
`/smith-update` it auto-repairs from the upstream clone; with no clone reachable it
skips the merge with a warning rather than writing the wrong policy.
## Phase 6: Cleanup
```bash
# Clean up temp clone (also handled by EXIT trap)
[ -n "${TMPCLONE:-}" ] && rm -rf "$TMPCLONE"
# Clear active-workflow marker
if [ -n "${MARKER_PATH:-}" ] && [ -f "$MARKER_PATH" ]; then
"$PROJECT_DIR/.specify/scripts/bash/clear-active-workflow.sh" "update-${TS}" 2>/dev/null || rm -f "$MARKER_PATH"
fi
# Summary
echo ""
echo "Smith updated to ${UPSTREAM_SHA:0:7}."
echo " Skills: refreshed in ~/.claude/skills/"
echo " Hooks: refreshed in ~/.claude/hooks/"
echo " Scheduler: refreshed in ~/.smith/scheduler/"
[ -d "$PROJECT_DIR/.smith" ] && echo " Per-project: .specify/scripts and .claude/commands/smith.* refreshed"
[ -d "$PROJECT_DIR/.smith" ] && echo " Git policy: .gitignore/.gitattributes Smith policy block refreshed"
echo " Snapshot: $BACKUP_DIR (delete after confirming the update works)"
# The daemon we stopped in Phase 3.5 is deliberately NOT restarted here.
if [ "${ACTIVITY_WAS_RUNNING:-0}" = "1" ]; then
echo ""
echo " The /smith-activity daemon was stopped so its code could be refreshed."
echo " It was NOT restarted automatically. Run /smith-activity when you want it back."
fi
```
---
## Invoking Sub-Skills
`/smith-update` orchestrates two other skills as sub-actions:
1. **`/smith-index --migrate-templates`** — runs after the global update in any Smith-initialized project (Phase 5.2)
2. **`/smith-index` or `/smith-index --describe`** — runs only if Phase 5.4's bootstrap prompt is accepted
When the parent assistant runs `/smith-update`, it should treat the bash blocks above as the orchestration backbone and invoke the sub-skills via Claude Code's Skill tool at the marked points. The bash blocks emit `Running /smith-index...` placeholders to make the orchestration boundary visible in the session log.
## Key Rules
- **NEVER touch `.smith/vault/`** — user data is sacred (vault is the bank, ledger, sessions, queue, agents, todo — all user-owned). This protects vault *data*; merging the managed Smith policy region in the project-root `.gitignore`/`.gitattributes` (Phase 5.5) is project-config, not vault data, and is permitted.
- **NEVER touch project source code** — only `.specify/scripts/`, `.claude/commands/smith.*`, `CLAUDE.md` (via migrate-templates), `constitution.md` (via migrate-templates), `.smith/index/` (only on explicit prompt)
- **Active-workflow marker must be created BEFORE any file write** (workflow-gate compliance)
- **Snapshot before destructive work** — if `install.sh` fails partway, restore from snapshot
- **Default-defer on LLM-heavy paths** — Phase 5.4's bootstrap prompt defaults to (3); the schema-regen prompt in 5.3 defaults to (n)
- **Settings.json dedupe touches only Smith-owned hook entries** — never modify user-added entries (Q1-D)
- **Stop the activity daemon, never restart it** — Phase 3.5 stops a running `/smith-activity` daemon so `install.sh` is not rewriting modules out from under a live process. Restarting a background listener on the operator's behalf is the surprise OOS-4 exists to prevent; the summary tells them to run `/smith-activity` instead.
- **`python3` not `python`** (Smith convention, per Rule 6 in `~/.claude/CLAUDE.md`)