smith-update · diff
git:20260914.df33910 to git:20260914.bbb4d86
35 added, 0 removed. Audit A to A.
---
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.
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 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.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)"
```
---
## 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)
- **`python3` not `python`** (Smith convention, per Rule 6 in `~/.claude/CLAUDE.md`)