一键导入
update-zskills
Install or update Z Skills supporting infrastructure (CLAUDE.md rules, hooks, scripts)
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Install or update Z Skills supporting infrastructure (CLAUDE.md rules, hooks, scripts)
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Safe commit workflow with optional scope hint. Inventories all changes, classifies related vs. unrelated files, traces dependencies, protects other agents' work, and optionally pushes, lands worktree commits, or opens a PR via /land-pr. Positional auto enables auto-merge (PR mode only).
Lightweight task dispatcher for ad-hoc work: documentation, examples, refactoring, content updates. Worktree/direct/pr landing modes via flag or execution.landing config. Recurring via every SCHEDULE; stop/next manage the schedule.
Draft a high-quality plan through iterative adversarial review: research, draft, review, devil's-advocate, refine — repeated until convergence. Output is a plan file ready for /run-plan execution.
Draft test specifications into an existing plan through iterative adversarial review. Appends a `### Tests` subsection per pending phase via a senior-QE reviewer + devil's-advocate + refiner loop. Completed phases are never modified (checksum-gated). Sister skill to /draft-plan, scoped to test specs.
Orchestrate a batch bug-fixing sprint: dispatch implementers in per-issue worktrees, verify, optionally auto-land via /land-pr. Recurring via every SCHEDULE; stop/next manage it. sync updates trackers + closes already-fixed issues; plan drafts plans for skipped ones.
Helper for PR landing — rebase, push, create-or-detect PR, poll CI, optional auto-merge. Dispatched via the Skill tool by /run-plan, /commit pr, /do pr, /fix-issues pr, /draft-plan, /refine-plan, /draft-tests (and orchestrator agents landing one-off PRs). Returns state via --result-file for caller-driven fix-cycle loops. Not for direct slash invocation — humans should use /commit pr instead.
| name | update-zskills |
| argument-hint | [install] [locked-main-pr|direct|cherry-pick] |
| description | Install or update Z Skills supporting infrastructure (CLAUDE.md rules, hooks, scripts) |
| metadata | {"version":"2026.06.15+7ef5d3"} |
Install or update the supporting infrastructure that Z Skills depend on: CLAUDE.md agent rules, safety hooks, helper scripts, and skill dependencies.
Invocation:
/update-zskills [install | --rerender | --migrate-paths]
[cherry-pick | locked-main-pr | direct]
Default mode (no argument): smart detection — if nothing is installed yet, do a full install; if already installed, pull latest, update changed skills, and fill new gaps. Always begins with an audit and reports what was found and what was done about it.
Explicit mode:
install — force a full first-time setup (same as what the default
mode does when nothing is installed, but skips the detection step)--rerender — regenerate .claude/rules/zskills/managed.md against
the current .claude/zskills-config.json. Simple full-file rewrite
of the zskills-owned rules file; root ./CLAUDE.md is never touched.
No audit, no preset, no hooks/scripts touched. See
### Step D — --rerender for the algorithm.--migrate-paths — one-shot deterministic relocation of legacy
artifacts into the path-config layout (docs/plans/ for plan files,
.zskills/audit/ for forensic + narrative reports, docs/issues/
for issue trackers, .zskills/dev-server.{pid,log} for runtime
state). Dispatches to
bash $ZSK/scripts/migrate-paths.sh "$MAIN_ROOT" (where $ZSK is
.claude/skills/update-zskills shipped, or skills/update-zskills
in zskills source tree). Writes a .pre-paths-migration manifest
(write-once), updates .gitignore, and writes output.plans_dir
output.issues_dir + output.reports_dir LAST (atomic
both-or-all-or-neither — 3-tuple). The script
triggers --rerender AS THE FIRST FILE-SYSTEM CHANGE so the
broadened recursive-delete hook regex protects the migration's own
filesystem actions. Idempotent — refuses to re-run if
.pre-paths-migration already exists. The agent-runnable
follow-up (path-config-upgrade prompt) handles start-dev.sh /
stop-dev.sh rewrites and any cross-references in plan content.Preset keywords (bare word, anywhere in the args):
Presets are config-only: they set execution.landing and
execution.main_protected in .claude/zskills-config.json. The
main-push gate in block-unsafe-generic.sh is no longer set by the preset
— the hook reads execution.main_protected from config at runtime
(fail-closed). Everything else in zskills-config.json is preserved.
| Preset | execution.landing | execution.main_protected | push gate (derived at runtime) |
|---|---|---|---|
cherry-pick (default) | cherry-pick | false | allow |
locked-main-pr | pr | true | block |
direct | direct | false | allow |
Behavior by invocation:
/update-zskills <preset> (a bare preset — a preset keyword with NO
install mode token) — config-only. Overwrite ONLY the two
preset-owned fields above (execution.landing,
execution.main_protected) in .claude/zskills-config.json; every
other field (branch_prefix, tests, CI, dev_server, UI patterns,
timezone, min_model) is preserved. Do NOT audit, pull, or update
skills — a bare preset is a pure landing-mode switch, not a refresh.
No greenfield prompt. After writing, print a config-only confirmation
(see "Config-only confirmation + version nudge" below) that makes clear
nothing was pulled, plus a best-effort version-availability nudge. See
the dedicated ### Bare-preset config-only short-circuit step for
the algorithm./update-zskills install <preset> (a preset composed with the explicit
install mode token) — install AND set config: run the full install
(audit + fill all gaps) AND apply the preset via Step F. Unchanged./update-zskills and no existing .claude/zskills-config.json —
ask the user the greenfield prompt (see Step 0.6), then apply the
chosen preset and write the config (as part of the install/update pass)./update-zskills and existing config, no preset arg — respect the
existing config; do NOT re-ask. This is the idempotent re-install /
update path (smart detection — audit + pull + update). Unchanged.This step runs before any mode. The portable assets (hooks, scripts,
CLAUDE_TEMPLATE.md, skills) can come from two sources: the zskills-portable/
vendored directory (inside projects like yours), or the Z Skills repo
root (which has the same structure). To find them:
Check if zskills-portable/ exists in the current working directory. If
yes, use it as $PORTABLE.
Check if zskills/ exists in the current directory and contains
CLAUDE_TEMPLATE.md. If yes, it's a repo clone — use zskills/ as
both $PORTABLE and $ZSKILLS_PATH.
Check if /tmp/zskills exists and contains CLAUDE_TEMPLATE.md. If
yes, use it.
Extended probe — common downstream clone locations. If none of
the above matched, check the following paths IN ORDER (first valid
wins). A path is valid iff the directory exists and contains all
four of CLAUDE_TEMPLATE.md, hooks/, scripts/, and skills/ —
the same validity test as the existing tiers. If the path is a git
clone, also store it as $ZSKILLS_PATH.
$PWD/../zskills (project's sibling)$PWD/../../zskills (grandparent-sibling)~/src/zskills~/code/zskills~/projects/zskills~/zskillsTrack each location you checked (matched and unmatched) for the stop-and-ask prompt below.
Stop-and-ask fallback. If no path above matched, do NOT silently
auto-clone. Instead, print the full list of locations that were
checked (tiers 1-3 plus the six extended-probe paths from tier 4),
then ask the user in plain conversation text (NOT
AskUserQuestion, per Key Rule 7):
Couldn't locate zskills source. Checked:
- ./zskills-portable/
- ./zskills/
- /tmp/zskills
- $PWD/../zskills
- $PWD/../../zskills
- ~/src/zskills
- ~/code/zskills
- ~/projects/zskills
- ~/zskills
Options: (a) paste a path to your clone (b) type
cloneto clone fresh to /tmp/zskills (c) typeabortto cancel
Wait for the user's reply. Then:
CLAUDE_TEMPLATE.md + hooks/ + scripts/ + skills/).
If valid, use it as $PORTABLE (and $ZSKILLS_PATH if it's a
git clone). If invalid, report what's missing and re-ask the same
options once; on a second invalid reply, treat as abort.clone: Fall through to the auto-clone behavior below.abort: Print "Aborted — no zskills source resolved." and
exit cleanly. Do not modify the project.abort.Auto-clone fallback (only when the user typed clone above).
Clone the repo:
git clone https://github.com/zeveck/zskills.git /tmp/zskills
If /tmp/zskills already exists, pull instead:
git -C /tmp/zskills pull
If the clone/pull fails (network, permissions), report the error clearly and stop — do not silently continue without portable assets. Tell the user:
Using Z Skills repo at /tmp/zskills for portable assets.
Portable asset detection: A valid portable source contains
CLAUDE_TEMPLATE.md, hooks/, scripts/, and skills/. The Z Skills
repo root has these at the top level (no zskills-portable/ subdirectory).
If the audit finds no gaps (all hooks, scripts, and CLAUDE.md rules already present — e.g., because the LLM already copied everything), the portable assets are not needed and Step 0 can return early.
Store the resolved path as $PORTABLE for use in install/update modes.
If the source is a git repo, also store it as $ZSKILLS_PATH for use
in update mode.
--migrate-paths short-circuit (Phase 5a)If the invocation arguments contain the bare flag --migrate-paths, this
takes precedence over every other mode (preset, install, --rerender). Run
the deterministic mover and exit; do not run the audit or any install/
update path.
Per-fence allow-hardcoded markers. The four fenced code blocks in this
section contain forbidden literals (plans/, reports/, SPRINT_REPORT.md,
etc.) that the conformance hook flags. The marker on the line preceding
each fence whitelists the block. Phase 5a ships 4 such markers; Phase 5b
adds 4 more in a separate section (total 8).
Dispatcher:
# $ZSK = .claude/skills/update-zskills (shipped) or skills/update-zskills
# (zskills source tree).
ZSK=".claude/skills/update-zskills"
[ -d "skills/update-zskills" ] && ZSK="skills/update-zskills"
MAIN_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
bash "$ZSK/scripts/migrate-paths.sh" "$MAIN_ROOT"
What the script moves (summary):
Forensic + narrative reports (Tier 2 — regenerable):
SPRINT_REPORT.md, FIX_REPORT.md, PLAN_REPORT.md,
VERIFICATION_REPORT.md, NEW_BLOCKS_REPORT.md
reports/**
plans/PLAN_INDEX.md
→ .zskills/audit/
Plans (Tier 1 — durable):
plans/*_PLAN.md, plans/CANARY*.md, plans/blocks/**
→ docs/plans/ (or $output.plans_dir if user-set)
Issue trackers:
plans/{ISSUES_PLAN,BUILD_ISSUES,DOC_ISSUES,QE_ISSUES}.md
→ docs/issues/ (or $output.issues_dir if user-set)
Work-trail reports (Tier 1.5 — durable narrative artifacts):
/run-plan plan-{slug}.md, /verify-changes verify-{name}.md,
/fix-issues SPRINT_REPORT.md
→ docs/reports/ (or $output.reports_dir if user-set;
legacy fallback `.zskills/audit`)
Runtime files:
legacy var/dev.{pid,log}
→ .zskills/dev-server.pid, .zskills/dev-server.log
Algorithm ordering (Phase 5a Locked Decisions):
The script executes 11 steps in a deterministic order. Hook-rerender is
HOISTED to step 2.5 (BEFORE any file moves) so the broadened recursive-
delete hook regex protects the migration's own filesystem actions. The
config-key write (step 10) is LAST so a mid-failure leaves the consumer
recovering via the helper's legacy-plans/ fallback.
1. Detection — refuse re-run if .pre-paths-migration already exists.
2. Resolve target dirs in memory only (no config write yet).
2.5 Trigger --rerender BEFORE any file moves (hook strengthens FIRST).
3. Move forensic + narrative reports → .zskills/audit/.
4. Move plans → $TARGET_PLANS (default docs/plans/).
4b. Move plans/PLAN_INDEX.md → .zskills/audit/.
5. Move issue trackers → $TARGET_ISSUES (default docs/issues/).
6. Move var/ runtime files → .zskills/dev-server.{pid,log}.
7. Update .gitignore (idempotent) + verify via git check-ignore -v.
8. (reserved — was --rerender step before round-2 plan hoisted to 2.5).
9. Write .pre-paths-migration manifest (write-once).
10. Write config keys (BOTH or NEITHER — atomic) LAST.
11. Print summary.
Stub-script handling (DEFER). tier1-shipped-hashes.txt does NOT
cover start-dev.sh / stop-dev.sh. The migration script does NOT
attempt auto-edit of these scripts; it prints a deferral notice naming
both files when the legacy var/dev.{pid,log} are moved. The agent-
runnable upgrade prompt (Phase 5b, references/path-config-upgrade.md)
handles them.
Recovery. If the mover aborts mid-way (a git mv fails, a git check-ignore -v returns negative, etc.) it exits non-zero and leaves
the partial state. Because the config write is LAST, the helper falls
back to legacy plans/ for any un-moved files — partial-but-functional
state, not broken. The user can re-run after fixing the underlying
cause; the idempotent guard (manifest existence) prevents double-moves.
Idempotent re-run. If .pre-paths-migration already exists, the
script prints "already migrated" and exits 0 without making any
changes. To force a fresh migration after a prior aborted run, the
user removes .pre-paths-migration AND restores files from the
manifest's from-column paths.
Example output:
moved: SPRINT_REPORT.md → .zskills/audit/SPRINT_REPORT.md
moved: plans/FOO_PLAN.md → docs/plans/FOO_PLAN.md
...
Wrote .pre-paths-migration with N entries.
Re-rendered hooks (broadened recursive-delete fence — applied EARLY).
Wrote output.plans_dir = "docs/plans", output.issues_dir = "docs/issues", and output.reports_dir = "docs/reports".
For start-dev.sh / stop-dev.sh customizations, see
.claude/skills/update-zskills/references/path-config-upgrade.md.
After dispatch, /update-zskills --migrate-paths exits with the script's
exit code. Do NOT proceed to Step 0.25 / 0.5 / audit.
migrate-paths.sh runs a structural-reference rewriter immediately after
gitignore update (step 7) and before the manifest write (step 9). The
rewriter scans every .md under <TARGET_PLANS> and rewrites legacy
plans/X.md and reports/Y.md tokens to the migrated paths, gated by
the YAML frontmatter status: field.
Frontmatter decision tree:
status: active → REWRITE (all 4 enclosure types)
status: proposal → REWRITE
(no frontmatter) → REWRITE
status: complete + filename CANARY*.md → REWRITE (slash-command lines
naturally limited by rule 4)
status: complete + non-canary filename → PRESERVE (frozen) + scan/warn
status: deferred / paused / other → PRESERVE + scan/warn
Four enclosure types triggering rewrite — token must appear inside ONE:
1. Markdown link: [...](plans/X.md) or [...](reports/Y.md)
2. Backtick span: `plans/X.md` or `reports/Y.md`
3. Shell line: inside ```bash/```sh/```shell/``` fence,
OR line starts with "$ ",
OR line ends with shell metachar | > < ;
4. Slash-command: /run-plan plans/X.md (also draft-plan, refine-plan,
draft-tests, work-on-plans, research-and-plan,
research-and-go)
Substitution targets:
plans/X.md → <TARGET_PLANS>/X.md (e.g., docs/plans/X.md)
reports/<slug>-Y.md → .zskills/audit/<slug>-Y.md
(slug ∈ {plan, verify, briefing, new-blocks})
Warning emission contract. For PRESERVED plans containing legacy
tokens, the rewriter emits a stderr WARN line per hit AND appends the
same line to .pre-paths-migration-warnings at the repo root:
WARN docs/plans/OLD_FEATURE.md:42: legacy token 'plans/OTHER.md' preserved (frozen plan; see path-config-upgrade.md)
--rewrite-only flag. For mid-version-skip recovery (when an older
5a-only migrate-paths.sh ran without cross-ref rewrite), the agent-
runnable upgrade prompt at references/path-config-upgrade.md invokes
migrate-paths.sh --rewrite-only "$MAIN_ROOT". This skips steps 1–7,
resolves <TARGET_PLANS> from the existing config, runs ONLY the cross-
ref rewrite, and appends a rewrite-only: <ts> <count> trailer to the
existing manifest. Config keys are not re-written. Idempotent.
Scan the invocation arguments for one of these bare keywords (order
doesn't matter; no preset= prefix; must be a whole word):
cherry-picklocked-main-prdirectRecord the match as $PRESET_ARG. If none is present, $PRESET_ARG is
empty. If more than one is present, stop with an error: "Specify exactly
one preset: cherry-pick, locked-main-pr, or direct."
Parser pseudocode (classify each token; presets and mode are orthogonal and can coexist):
if [ -n "${ZSH_VERSION:-}" ]; then setopt KSH_ARRAYS BASH_REMATCH SH_WORD_SPLIT 2>/dev/null || true; fi
PRESET_ARG=""
MODE="" # "install" or "" (default = smart detection)
for tok in $ARGUMENTS; do
case "$tok" in
cherry-pick|locked-main-pr|direct)
[ -n "$PRESET_ARG" ] && fail "multiple presets"
PRESET_ARG="$tok" ;;
install) MODE="install" ;;
*) ;; # unknown token — ignore, don't error
esac
done
install + a preset keyword are compatible and combine (force-install
with the chosen preset).
Preset → field mapping (used wherever a preset is applied in later steps):
$PRESET_ARG | execution.landing | execution.main_protected |
|---|---|---|
cherry-pick | "cherry-pick" | false |
locked-main-pr | "pr" | true |
direct | "direct" | false |
These two config fields are preset-owned. (The main-push gate in
block-unsafe-generic.sh derives from main_protected at runtime — it is
not a value the preset writes.) When $PRESET_ARG is
non-empty, every other field in .claude/zskills-config.json
(branch_prefix, testing.*, dev_server.*, ui.*, ci.*,
timezone, agents.min_model) is preserved unchanged.
Check if .claude/zskills-config.json exists in the target project root ($PROJECT_ROOT).
If it exists:
Read the file content.
Extract values using bash regex (pure bash, no external JSON tool).
IMPORTANT — parent-object scoping is mandatory for any field
that lives inside a parent block. An unscoped regex like
\"unit_cmd\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" matches the
FIRST occurrence of the field anywhere in the JSON, so a future
sibling block declaring its own same-named field will silently
shadow the intended one. The fix class addressed by issues #395
(zskills-resolve-config.sh) and #400 (apply-preset.sh) and
reproductively-broad issue #428 (this prose recipe) is the SAME
bug class: every field that lives inside "<parent>": { ... }
must scope its extraction under the parent. The canonical form
for a scoped string-value extraction is:
\"<parent>\"[[:space:]]*:[[:space:]]*\{[^}]*\"<field>\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"
Top-level fields (project_name, timezone) have no parent
block and may be extracted directly. The mapping below annotates
every parent-scoped field with # parent: <name> next to its
regex.
if [ -n "${ZSH_VERSION:-}" ]; then setopt KSH_ARRAYS BASH_REMATCH SH_WORD_SPLIT 2>/dev/null || true; fi
CONFIG_CONTENT=$(cat "$PROJECT_ROOT/.claude/zskills-config.json")
# CASCADE v2 (ENFORCEMENT_V2 Phase 4): execution.landing and
# execution.branch_prefix are plain-cascade workflow keys — source the
# canonical lane-portable resolver and read $ZSKILLS_CFG_LANDING /
# $ZSKILLS_CFG_BRANCH_PREFIX (project > user > empty) instead of the inline
# BASH_REMATCH reads (below). The remaining keys keep the documented
# scoped-extraction form (this fence demonstrates that canonical form).
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
# Top-level string (no parent — direct extraction is safe):
if [[ "$CONFIG_CONTENT" =~ \"project_name\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
PROJECT_NAME="${BASH_REMATCH[1]}"
fi
# parent: testing (sibling-shadow risk if unscoped — see #395)
if [[ "$CONFIG_CONTENT" =~ \"testing\"[[:space:]]*:[[:space:]]*\{[^}]*\"unit_cmd\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
UNIT_CMD="${BASH_REMATCH[1]}"
fi
# parent: testing
if [[ "$CONFIG_CONTENT" =~ \"testing\"[[:space:]]*:[[:space:]]*\{[^}]*\"full_cmd\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
FULL_CMD="${BASH_REMATCH[1]}"
fi
# parent: testing
if [[ "$CONFIG_CONTENT" =~ \"testing\"[[:space:]]*:[[:space:]]*\{[^}]*\"output_file\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
OUTPUT_FILE="${BASH_REMATCH[1]}"
fi
# parent: dev_server
if [[ "$CONFIG_CONTENT" =~ \"dev_server\"[[:space:]]*:[[:space:]]*\{[^}]*\"cmd\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
DEV_SERVER_CMD="${BASH_REMATCH[1]}"
fi
# parent: dev_server
if [[ "$CONFIG_CONTENT" =~ \"dev_server\"[[:space:]]*:[[:space:]]*\{[^}]*\"main_repo_path\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
MAIN_REPO_PATH="${BASH_REMATCH[1]}"
fi
# parent: ui (note: `file_patterns` also exists under `testing` as
# an array — scoping under `ui` disambiguates)
if [[ "$CONFIG_CONTENT" =~ \"ui\"[[:space:]]*:[[:space:]]*\{[^}]*\"file_patterns\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
UI_FILE_PATTERNS="${BASH_REMATCH[1]}"
fi
# parent: ui
if [[ "$CONFIG_CONTENT" =~ \"ui\"[[:space:]]*:[[:space:]]*\{[^}]*\"auth_bypass\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
AUTH_BYPASS="${BASH_REMATCH[1]}"
fi
# Top-level string (no parent):
if [[ "$CONFIG_CONTENT" =~ \"timezone\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
TIMEZONE="${BASH_REMATCH[1]}"
fi
# parent: execution (boolean — sibling-shadow risk if unscoped, see #400)
if [[ "$CONFIG_CONTENT" =~ \"execution\"[[:space:]]*:[[:space:]]*\{[^}]*\"main_protected\"[[:space:]]*:[[:space:]]*(true|false) ]]; then
MAIN_PROTECTED="${BASH_REMATCH[1]}"
fi
# parent: execution (landing mode) — CASCADE v2 (Phase 4): read the
# resolved cascade value ($ZSKILLS_CFG_LANDING, project > user > empty)
# instead of an inline BASH_REMATCH read.
LANDING_MODE="$ZSKILLS_CFG_LANDING"
# parent: execution (branch prefix) — CASCADE v2 (Phase 4): read the
# resolved cascade value ($ZSKILLS_CFG_BRANCH_PREFIX, project > user >
# empty) instead of an inline BASH_REMATCH read.
BRANCH_PREFIX="$ZSKILLS_CFG_BRANCH_PREFIX"
# parent: ci (boolean)
if [[ "$CONFIG_CONTENT" =~ \"ci\"[[:space:]]*:[[:space:]]*\{[^}]*\"auto_fix\"[[:space:]]*:[[:space:]]*(true|false) ]]; then
CI_AUTO_FIX="${BASH_REMATCH[1]}"
fi
# parent: ci (integer)
if [[ "$CONFIG_CONTENT" =~ \"ci\"[[:space:]]*:[[:space:]]*\{[^}]*\"max_fix_attempts\"[[:space:]]*:[[:space:]]*([0-9]+) ]]; then
CI_MAX_ATTEMPTS="${BASH_REMATCH[1]}"
fi
# parent: commit (optional — backfilled below if missing)
if [[ "$CONFIG_CONTENT" =~ \"commit\"[[:space:]]*:[[:space:]]*\{[^}]*\"co_author\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
CO_AUTHOR="${BASH_REMATCH[1]}"
fi
The [^}]* between the parent key and the field key is what
constrains the match to inside that block. Do NOT replace it with
.* (which would match across blocks) and do NOT drop the parent
prefix to "simplify" the regex — that re-introduces the bug class.
For each template placeholder, use the config value if non-empty.
3.5. Backfill commit.co_author if absent. If the existing config
does not contain a "commit" block with a "co_author" field (e.g.
configs written before this field was introduced), splice in the
default so downstream skills (/commit) can rely on the
field resolving. Default value:
"Claude Opus 4.7 (1M context) <noreply@anthropic.com>". Match the
same style used for other optional-field backfills — a targeted
Edit or small sed-based rewrite that preserves every other field unchanged.
If the commit key is absent, add the whole block; if the commit
block exists but lacks co_author, add only that field. Idempotent:
re-running on an already-backfilled config is a no-op.
> **Path-config keys: FRESH-SCAFFOLD writes them; auto-backfill does NOT.**
> A FRESH config scaffold (both lanes — this install's `Write` below and
> the plugin lane's A3 init-interview seed from zskills-defaults.json) DOES write
> `output.plans_dir = "docs/plans"`, `output.issues_dir = "docs/issues"`,
> and `output.reports_dir = "docs/reports"`, so a brand-new consumer's
> dashboard and plan-skills find plans in `docs/plans` out of the box.
> But these keys MUST NEVER be auto-BACKFILLED into an EXISTING config
> that lacks them during install or `--rerender`. Their absence in an
> existing config is meaningful — the helper falls back to legacy
> `plans/`, preserving consumer-current behavior. Relocating an EXISTING
> legacy layout is owned by `/update-zskills --migrate-paths`, which
> writes these keys (and writes BOTH or NEITHER).
> See plan `docs/plans/ZSKILLS_PATH_CONFIG.md` (or
> `plans/ZSKILLS_PATH_CONFIG.md` pre-migration).
Copy config/zskills-config.schema.json from $PORTABLE to
.claude/zskills-config.schema.json in the target project (so the
$schema reference in the config resolves correctly).
If $PRESET_ARG was set, defer preset application to
Step F — Apply Preset (invoked at the end of both install and
update paths). Step F runs .claude/skills/update-zskills/scripts/apply-preset.sh which is
config-only — it sets the two preset-owned config fields
(execution.landing, execution.main_protected) atomically,
including idempotency, JSON formatting variance, and a missing
execution key. It does NOT touch the hook; block-unsafe-generic.sh
reads main_protected from config at runtime. Don't attempt a manual
Edit here — the script is the single source of truth.
If it does not exist:
If $PRESET_ARG is empty, run the greenfield prompt (Step 0.6)
to pick a preset. Otherwise skip the prompt and use $PRESET_ARG.
Auto-detect values from the project (existing behavior).
Write the config file directly using the Write tool. Running
/update-zskills is the user's consent — do not gate this on a paste-this-
heredoc step. If the user's permission mode prompts for the write, that is
Claude Code's normal flow and the user will approve.
Content to write to .claude/zskills-config.json:
{
"$schema": "./zskills-config.schema.json",
"project_name": "<detected>",
"timezone": "America/New_York",
"execution": {
"landing": "<preset.landing>",
"main_protected": <preset.main_protected>,
"branch_prefix": "feat/"
},
"commit": {
"co_author": "Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
},
"testing": {
"unit_cmd": "<detected>",
"full_cmd": "<detected>",
"output_file": ".test-results.txt",
"file_patterns": ["<detected>"]
},
"dev_server": {
"cmd": "<detected>",
"default_port": 8080,
"main_repo_path": "<detected>"
},
"ui": {
"file_patterns": "",
"auth_bypass": ""
},
"ci": {
"auto_fix": true,
"max_fix_attempts": 2
},
"agents": {
"min_model": "auto"
},
"output": {
"plans_dir": "docs/plans",
"issues_dir": "docs/issues",
"reports_dir": "docs/reports"
}
}
Substitute the three preset-owned placeholders (<preset.landing>,
<preset.main_protected>) using the Step 0.25 mapping table. Fields
left empty by auto-detection stay as empty strings — the install
summary's test-setup blurb tells the user what to fill in later.
No hook edit needed. The config's execution.landing
and execution.main_protected placeholders above are substituted
in at write time, and block-unsafe-generic.sh reads
main_protected from config at runtime to decide whether to block
a push to main/master (fail-closed). Step F — Apply Preset is
config-only and does not touch the hook. Nothing to do here.
Merge algorithm pseudocode:
for each field F in schema:
if config[F] is non-empty string (or true/false for booleans):
use config[F]
else if auto_detect[F] is non-empty:
use auto_detect[F]
else:
mark as empty -> template section gets commented out
Template placeholder mapping: none — CLAUDE_TEMPLATE.md is fully
de-parameterized (INSTALL_REDESIGN Phase 4). The managed rules are
install-level: instead of carrying rendered per-project values, they
INSTRUCT the agent to resolve project-specific fields (dev_server.cmd,
ui.auth_bypass, dev_server.default_port, dev_server.main_repo_path,
test commands, timezone) from .claude/zskills-config.json at point of
use via the canonical prelude. The renderer still runs on every install
and --rerender (D24 — one renderer, both lanes) and still hard-errors
on any leftover {{...}} token, so a retired-token template fails loudly
instead of shipping broken rules.
Runtime-read fields (read by hooks and helper scripts at every invocation, NOT install-filled): testing.unit_cmd, testing.full_cmd, ui.file_patterns, dev_server.cmd, ui.auth_bypass, and the port.sh-read pair dev_server.default_port / dev_server.main_repo_path. Post-de-parameterization EVERY config field is runtime-read — nothing is install-substituted into managed.md (warn-config-drift still signals re-render-needed when CLAUDE_TEMPLATE.md itself is edited). See Phase 1 of plans/DRIFT_ARCH_FIX.md for the canonical bash-regex read pattern.
Empty value handling: When a config field is empty string "", the
corresponding template section is commented out with a TODO marker:
# Example: if UI_FILE_PATTERNS is empty, comment out the UI verification section
# in block-unsafe-project.sh:
#
# Before:
# UI_FILE_PATTERNS="src/components/.*\.tsx?$"
#
# After (empty):
# # TODO: Configure UI file patterns in .claude/zskills-config.json
# # UI_FILE_PATTERNS=""
Run this only when .claude/zskills-config.json does NOT exist AND
$PRESET_ARG is empty. Skip otherwise.
Do NOT use AskUserQuestion. Ask in plain conversation text, exactly as shown. Wait for the user's reply before proceeding.
Ask:
How should /run-plan land changes?
(1) cherry-pick — each phase squash-lands directly to main (simple, solo)
(2) locked-main-pr — plans become feature branches + PRs, CI, auto-merge
(locked main, shared repo)
(3) direct — work on main, no worktree isolation (minimal, risky)
Default: (1). Pick one, or accept the default.
Map the reply:
1, cherry-pick, or an empty/default-accepting reply → cherry-pick2, locked-main-pr, or pr → locked-main-pr3, direct → direct/update-zskills locked-main-pr later to switch." —
then proceed. Never re-ask the prompt; never invent a 4th option.Set $PRESET_ARG to the chosen preset and proceed. No follow-up
questions — the two-field config mapping (landing + main_protected) in
Step 0.25 is final. In particular, we do not ask "do you want the
main-push block on?" for locked-main-pr: main_protected=true is the
single source of truth — block-unsafe-project.sh blocks agent commits,
cherry-picks, and pushes on main, and block-unsafe-generic.sh derives
its push gate from the same main_protected value at runtime, so the
push block is not a separate user-facing choice.
The audit scans the project for all Z Skills dependencies and reports what is present and what is missing. The audit itself never modifies any files. Its output is always displayed so the user can see exactly what was found before any changes are made.
List all .claude/skills/*/SKILL.md files. For each skill:
requires: field (list of skill
names), check that each required skill is also installed. Collect all
missing dependencies.npm test, npm run test:all,
{{FULL_TEST_CMD}}) — check if test commands are configured.playwright-cli, gh) — check if the tool is
available via which.python3) — check via which. Python 3 is
required (per CLAUDE.md "Python is required"). Powers /briefing,
/plans rebuild, the dashboard, and other Python-only helpers.block-unsafe) — check if the hook file
exists in .claude/hooks/..claude/skills/update-zskills/scripts/port.sh, scripts/test-all.sh) — check if
the script file exists.Read .claude/rules/zskills/managed.md (the zskills-owned rules
file); if absent, fall back to reading root ./CLAUDE.md (pre-Phase-4
installs rendered rules there). For each of the 13 generic rules,
search for a distinctive key phrase that identifies the rule
(case-insensitive). Mark the rule as present if the key phrase is
found, missing otherwise.
| # | Rule Name | Key Phrase(s) to Search |
|---|---|---|
| 1 | Never weaken tests | "loosen tolerances" or "widen thresholds" |
| 2 | Capture test output | "capture" AND "output" AND "never pipe" |
| 3 | Max 2 fix attempts | "two attempts.*maximum" or "NEVER thrash" |
| 4 | Pre-existing failures | "pre-existing" AND "it.skip" |
| 5 | Never discard others' changes | "discard" AND "changes" AND "didn't make" |
| 6 | Protect untracked files | "protect untracked" or "git stash -u" |
| 7 | Feature-complete commits | "feature-complete" AND "trace" AND "imports" |
| 8 | Landed marker check | ".zskills/landed" AND "status: full" |
| 9 | Worktree verify before remove | "worktree" AND "batch-remove" |
| 10 | Never defer hard parts | "defer" AND "hard parts" AND "future phases" |
| 11 | Correctness over speed | "correctness over speed" or "correctness, not speed" |
| 12 | Enumerate before guessing | "enumerate before guessing" |
| 13 | Never skip hooks | "never.*--no-verify" or "skip.*pre-commit hooks" |
Search the zskills rules file (.claude/rules/zskills/managed.md,
falling back to root ./CLAUDE.md) for these documentation-presence
signals. Mark each present/missing based on case-insensitive
substring match:
| Check | Key phrase(s) to search in zskills rules file |
|---|---|
| Execution Modes section | ## Execution Modes (heading) |
| Landing mode keywords documented | cherry-pick AND pr AND direct |
| Direct mode description present | Work directly on main |
Report in the same pass/fail format as Step 2. Missing items are recommendations, not errors — this is a documentation-only gap with no enforcement consequence.
Look in .claude/hooks/ for these 2 files:
block-unsafe-generic.sh (or block-unsafe.sh — either name counts)block-unsafe-project.shTier-1 machinery moved out of root scripts/ and into the owning skill's
mirror at .claude/skills/<owner>/scripts/<name> (see
references/script-ownership.md for the authoritative tier → owner table).
Check each Tier-1 script at its owner's path — NOT at root scripts/:
.claude/skills/update-zskills/scripts/port.sh.claude/skills/briefing/scripts/briefing.py — also checked in Step 5; the /briefing skill requires it.claude/skills/update-zskills/scripts/clear-tracking.sh.claude/skills/commit/scripts/land-phase.sh — referenced by /run-plan, /fix-issues, /do for atomic post-landing cleanup.claude/skills/run-plan/scripts/post-run-invariants.sh — referenced by /run-plan as mandatory end-of-run gate (7 invariants).claude/skills/commit/scripts/write-landed.sh — referenced by /run-plan, /fix-issues, /commit for rc-checked atomic .zskills/landed marker writes.claude/skills/create-worktree/scripts/worktree-add-safe.sh — referenced by /run-plan, /fix-issues, /do for safe worktree creation (discriminates fresh vs poisoned stale branches).claude/skills/create-worktree/scripts/create-worktree.sh — referenced by /run-plan, /fix-issues, /do for unified worktree creation.claude/skills/create-worktree/scripts/sanitize-pipeline-id.sh — shared PIPELINE_ID sanitizer (used by /run-plan, /fix-issues, /do before persisting ID).claude/skills/update-zskills/scripts/apply-preset.sh — required by the preset UX (Step F); config-only — updates execution.landing/execution.main_protected in config (the hook reads main_protected at runtime; apply-preset does not touch it).claude/skills/run-plan/scripts/compute-cron-fire.sh — required by /run-plan (Phase 5c chunked finish-auto, verify-pending retry, re-entry) for computing one-shot cron expressions with correct minute/hour/day/month/year rollover.claude/skills/update-zskills/scripts/statusline.sh — session statusline helper (optional but should be installed if the user has it)Tier-2 release/consumer-tooling stays at root scripts/ (per
references/script-ownership.md) — check these there:
scripts/test-all.shscripts/stop-dev.sh — sanctioned SIGTERM-only dev-server stopper (reads .zskills/dev-server.pid). The approved way for agents to stop a dev server without reaching for kill -9 / fuser -k / lsof -ti | xargs killIf /briefing is installed, check for [ -f .claude/skills/briefing/scripts/briefing.py ]
(the artifact half catches partial skill-mirror installs). If not found, add a
note: "The /briefing skill requires .claude/skills/briefing/scripts/briefing.py
— see /briefing skill documentation."
Output the report in this exact format:
Z Skills Audit Report
=====================
Skills installed: N
[list of skill names]
Skill Dependencies: all satisfied | K missing
Missing:
- /run-plan requires /verify-changes — NOT INSTALLED
...
Agent Rules: M/13 present (K missing)
Missing:
- [rule name]: [key phrase not found]
...
Execution Mode Docs: M/3 present (K missing/recommended)
Missing (recommendation only):
- [check name]: [key phrase not found]
...
Hooks: M/2 installed (K missing)
Missing:
- [filename]
...
Scripts: M/3 installed (K missing)
Missing:
- [filename]
...
Tools: M/N available (K missing)
Missing:
- [tool name]: not found in PATH
...
Skills with additional requirements:
- /briefing: requires `.claude/skills/briefing/scripts/briefing.py` (not found)
...
Overall: X/Y dependencies satisfied.
After printing Overall: ..., append a one-line Versions summary
showing the installed zskills_version (consumer-side) vs the source
clone's latest tag (authoritative), plus how many skills have a different
metadata.version upstream. Compute it like this:
if [ -n "${ZSH_VERSION:-}" ]; then setopt KSH_ARRAYS BASH_REMATCH SH_WORD_SPLIT 2>/dev/null || true; fi
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
# Repo-level version: latest YYYY.MM.N tag in the source clone, falling
# back to the top-level `version` in .claude-plugin/plugin.json when there
# is no tag (#1124) — so a tagless-but-complete clone still resolves a
# version. Empty only when there is neither a tag nor a readable plugin.json.
current_zskills_ver=$(bash "$ZSKILLS_SKILLS_ROOT/update-zskills/scripts/resolve-repo-version.sh" "$ZSKILLS_PATH")
# Installed version: top-level `zskills_version` field in
# .claude/zskills-config.json. Read with inline BASH_REMATCH (NOT
# frontmatter-get.sh — that helper is YAML-only and exits 2 on JSON;
# silencing that with `2>/dev/null` would be the anti-CLAUDE.md
# "swallow failures" pattern. Same JSON-parsing idiom as
# zskills-resolve-config.sh — single source of truth.)
installed_zskills_ver=""
if [ -f "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" ]; then
cfg=$(cat "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json")
if [[ "$cfg" =~ \"zskills_version\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
installed_zskills_ver="${BASH_REMATCH[1]}"
fi
fi
# Per-skill delta: count rows whose `metadata.version` differs upstream.
delta_tsv=$(bash "$ZSKILLS_SKILLS_ROOT/update-zskills/scripts/skill-version-delta.sh" "$ZSKILLS_PATH")
n_changed=$(printf '%s\n' "$delta_tsv" | awk -F'\t' '$4 == "bumped" || $4 == "new"' | wc -l)
Render (print the lines verbatim, substituting the three values; if
current_zskills_ver is empty, print the literal (unversioned) — source clone has no tags; if installed_zskills_ver is empty, print (none)):
Repo version: <installed_zskills_ver> → <current_zskills_ver>
Versions: zskills <installed_zskills_ver>→<current_zskills_ver>; <n_changed> skills changed
The Repo version: line uses the same label as the install/update
final reports (Step G / Pull Latest step 6) for cross-report
consistency; the Versions: line is the audit-specific one-liner that
also includes the per-skill delta count. Both surface the same
underlying data.
If the source clone has neither a tag nor a readable
.claude-plugin/plugin.json version (genuinely unversioned):
current_zskills_ver is empty and both lines still print with the
(unversioned) placeholder. This surfaces the state instead of hiding it
(CLAUDE.md surface-bugs rule). Note that a tagless-but-complete clone is
NOT unversioned — resolve-repo-version.sh falls back to the plugin.json
version (#1124), so (unversioned) now appears only when the clone is
truly missing both signals. Same (none) handling applies to a
pre-Phase-5 install with no zskills_version field — print (none); the
install/Pull-Latest path will write the field on its next run via the
mirror-the-tag step (see Step F.5 / Pull Latest step 5.7).
If everything is satisfied, end with:
Overall: Y/Y dependencies satisfied. Nothing to install.
If there are gaps and the skill is running in default or install mode, proceed to fill them (see below). The audit report is always shown first so the user sees what was found before any modifications.
This branch runs after the arg parser (Step 0.25), config read (Step 0.5),
and the greenfield prompt (Step 0.6), and at/above the Default-Mode
Smart-Detection fork below. Explicit --migrate-paths already
short-circuited at Step 0.1; --rerender is a
user forcing a config action and is not intercepted by the
hard-refuse below. The destructive case this branch prevents is the silent
flip of a pure-plugin consumer on a bare /update-zskills call: with no
.claude/skills/ mirror, the audit would see every skill/hook as "missing"
and the gap-fill would copy them all in, flipping the consumer onto the
legacy lane.
Policy reversal (W6.1) — explicit install is now
HARD-REFUSED on the plugin lane. Earlier this branch let explicit install
through as an "opt-in" mirror action. That carve-out is removed: a client is
single-lane, so running /update-zskills install on a detect == plugin consumer is never the right move — it would re-create the legacy
mirror alongside the plugin and put the consumer into the dual state the rest
of the system actively pushes to consolidate. The refuse is UNCONDITIONAL
(the former in-flight-lane-switch marker carve-out died in INSTALL_REDESIGN
Phase 7 with the lane-switch machinery) and exits non-zero; it points the
user at the uninstall-one-lane-install-the-other model. Keyed on
detect_install_state == plugin, NOT
$CLAUDE_PLUGIN_ROOT — so the dev repo (which keeps its legacy mirror and
classifies as a mirror-bearing lane) is never blocked, and dogfooding both
lanes from this repo via /update-zskills install still works.
Signal = detect_install_state == plugin, NOT a bare
$CLAUDE_PLUGIN_ROOT test. Since INSTALL_REDESIGN Phase 7's detect rework,
lane == plugin means "the plugin is loaded (env context) AND no legacy
.claude/skills mirror evidence" — the exact flip-risk. A bare
$CLAUDE_PLUGIN_ROOT test is still rejected because it ignores the mirror
side: it is set in any claude --plugin-dir . session (including the dev
repo's legacy-lane dogfooding), so keying on it alone would make
/update-zskills install wrongly hit this branch and refuse to install.
The dev repo has its legacy mirror present, so it classifies as
update-zskills (plain session) or dual (plugin-loaded session) — both
NON-plugin — and this branch never fires there.
Resolve the lane (dual-locate + fail-soft). detect-install-state.sh
is NOT mirrored into .claude/hooks/_lib/ on the legacy lane, so locate it
under $CLAUDE_PLUGIN_ROOT or $PORTABLE; if it is unreachable, default
LANE=update-zskills (legacy behavior — safe, because the only unreachable
case is a pure-legacy session, which should proceed with legacy behavior
anyway; a pure-plugin consumer always has $CLAUDE_PLUGIN_ROOT set):
MAIN_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
DIS=""
if [ -f "${CLAUDE_PLUGIN_ROOT}/hooks/_lib/detect-install-state.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
DIS="${CLAUDE_PLUGIN_ROOT}/hooks/_lib/detect-install-state.sh"
elif [ -n "${PORTABLE:-}" ] && [ -f "${PORTABLE}/hooks/_lib/detect-install-state.sh" ]; then
DIS="${PORTABLE}/hooks/_lib/detect-install-state.sh"
fi
LANE="update-zskills" # fail-soft default = legacy behavior (safe)
if [ -n "$DIS" ]; then . "$DIS"; LANE="$(detect_install_state "$MAIN_ROOT")"; fi
If LANE == plugin: do NOT run Step 0 asset-locate, the audit's
gap-fill (Steps A–G below), or any .claude/-mirroring step.
W6.1 hard-refuse — explicit install on the plugin
lane. If an explicit install arg was parsed ($MODE == "install"),
REFUSE unconditionally and exit non-zero (the former W6.2
in-flight-lane-switch marker carve-out died in INSTALL_REDESIGN Phase 7
with the lane-switch machinery — no marker bypasses this refuse):
if [ "$MODE" = install ]; then
echo "ERROR: refusing 'install' on the plugin lane." >&2
echo "A client is single-lane. Running it here would re-create the legacy" >&2
echo ".claude/skills mirror alongside the plugin (the dual state the system" >&2
echo "actively pushes to consolidate). To switch lanes, uninstall the" >&2
echo "plugin (/plugin uninstall zs@zskills), restart, then run" >&2
echo "/update-zskills install -- see the install guide." >&2
exit 1
fi
If a preset arg was parsed ($PRESET_ARG non-empty): apply it
config-only via the existing Step F — Apply Preset call (it is
lane-portable — it edits only .claude/zskills-config.json, never the
mirror):
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
bash "$ZSKILLS_SKILLS_ROOT/update-zskills/scripts/apply-preset.sh" "$PRESET_ARG"
Report the result verbatim per Step F's exit-code table (0 applied / 1
no-change / 2 usage / 3 missing config / 4 malformed config), then print
the config-only confirmation + best-effort version nudge from
### Bare-preset config-only short-circuit below, and exit with the
script's exit code. Do not proceed to the audit or any fill step.
Else (bare call, no preset): the explicit init / update entry point
(INSTALL_REDESIGN Phase 6a, item A1). A bare /zs:update-zskills on the
plugin lane is the ONE-TIME explicit setup (init) — or, once initialised,
the update/refresh pass. The route is keyed on the init-done marker, whose
path (and the marker writer, and the frozen legacy-residue constants the
cleanup below consumes) is defined in exactly ONE place —
skills/update-zskills/scripts/init-state.sh (#1132 single-path-definition
rule). Source it first; never re-type the marker paths:
# Single path definition (#1132): paths + lock-LAST writer + frozen
# legacy-residue constants. Plugin root first; legacy-mirror fallback.
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/init-state.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/init-state.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/init-state.sh"
fi
# The version recorded in the init-done marker (and refreshed on update).
# resolve-repo-version.sh falls back to .claude-plugin/plugin.json (#1124).
ZS_INIT_VERSION="$(bash "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/resolve-repo-version.sh" "${CLAUDE_PLUGIN_ROOT}" 2>/dev/null || true)"
Then run step A1.5 — materialiser-residue cleanup (below) FIRST, and
route: zskills_init_done_present "$CLAUDE_PROJECT_DIR" true → the
update arm; false → the init sequence (A2–A7, in order). Both
arms are defined below.
Step A1.5 — materialiser-residue cleanup (BOTH arms, before anything
else). Upgraded (materialiser-era) consumers otherwise keep stale
artifacts forever: stale .claude/agents twins that SHADOW the plugin's
own agents on bare-name dispatch, and a stale project managed.md that
keeps the SessionStart rules hook's guard closed. The discriminator is
the D20 sentinel prefix constant from init-state.sh — user-owned and
legacy-lane-installed files never carry it and are NEVER touched.
(a) Sentinelled-artifact removal (unconditional, deterministic):
# Deletes each legacy materialiser artifact IFF it carries the D20
# sentinel (constants + predicate live ONLY in init-state.sh).
ZS_REMOVED_ARTIFACTS="$(zskills_legacy_remove_sentinelled "$CLAUDE_PROJECT_DIR")"
(b) Seeded-config cure: fire the offer ONLY when the legacy seed
notice exists AND the config still matches the frozen legacy seed shape
(value-bearing keys only — the comparison spec, which excludes the three
per-install dynamic keys, lives in init-state.sh's matcher):
ZS_SEED_OFFER=0
ZS_REMOVED_CONFIG_THIS_RUN=0
if zskills_legacy_seed_notice_present "$CLAUDE_PROJECT_DIR" \
&& zskills_legacy_seed_config_matches "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json"; then
ZS_SEED_OFFER=1
fi
If ZS_SEED_OFFER=1 AND the session is interactive, ask in plain
conversation text: "This config was auto-seeded by the old installer
and never customized; remove it and run on built-in defaults?" —
Accept → delete .claude/zskills-config.json, its
.claude/zskills-config.schema.json sibling, consume the notice
(zskills_legacy_consume_seed_notice "$CLAUDE_PROJECT_DIR"), and set
ZS_REMOVED_CONFIG_THIS_RUN=1. Decline, non-interactive, or
shape-mismatch (= the user customized it) → KEEP the config as
user-owned and still consume the notice. The notice is consumed exactly
once on every path, so the offer never repeats (W6.3 no-nag lesson).
The retired SessionStart materialiser was deleted in INSTALL_REDESIGN Phase 7, so nothing re-creates the removed residue: every cleanup here is permanent (the mid-window per-session re-pollution that existed while the materialiser was still alive is gone).
Include everything removed ($ZS_REMOVED_ARTIFACTS, the config/schema/
notice if cured) in the arm's final summary.
Init sequence (init-done absent) — steps A2–A7, in order. Lock-LAST: the init-done marker is written ONLY at A7, after every prior step succeeded; any earlier STOP leaves the consumer cleanly re-runnable.
A2 — gitignore-first. Append the umbrella .zskills/ entry to the
project .gitignore (idempotent — skip when an equivalent rule already
covers), then VERIFY effectiveness and detect a negative-override that
defeats it. This runs BEFORE any other init write; on STOP, no marker
has been written:
GI="$CLAUDE_PROJECT_DIR/.gitignore"
[ -f "$GI" ] || touch "$GI"
# Idempotent append: only when .zskills/ paths are not already ignored.
# Non-git duplicate guard (#1150 NIT): in a non-git dir check-ignore
# cannot run at all, so without the grep each re-run before the STOP
# below would stack a duplicate line. Inside a git repo the append
# stays UNCONDITIONAL on not-ignored — a present-but-overridden line
# is deliberately re-appended (last-match-wins self-heal).
if ! git -C "$CLAUDE_PROJECT_DIR" check-ignore -q .zskills/probe 2>/dev/null; then
if git -C "$CLAUDE_PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
|| ! grep -qxF ".zskills/" "$GI" 2>/dev/null; then
echo ".zskills/" >> "$GI"
fi
fi
# Verify effective ignore (the migrate-paths Step 8 pattern). A probe
# PATHNAME is enough — check-ignore does not require the file to exist.
zs_gi_match=$(git -C "$CLAUDE_PROJECT_DIR" check-ignore -v .zskills/probe 2>/dev/null) || {
echo "STOP: .zskills/ is not effectively ignored even after appending the umbrella entry." >&2
echo "Init did NOT complete (no init-done marker was written). Fix .gitignore (or run inside a git repo) and re-run /zs:update-zskills." >&2
exit 1
}
case "$zs_gi_match" in
*!*)
echo "STOP: a negative .gitignore rule overrides the .zskills/ ignore: $zs_gi_match" >&2
echo "Init did NOT complete (no init-done marker was written). Remove the override and re-run /zs:update-zskills." >&2
exit 1 ;;
esac
A2.5 — one-shot main-root marker migrate (INSTALL_REDESIGN Phase 8
root-turd consolidation). If a legacy root .zskills-tracked marker
exists at the MAIN repo root, move it to its consolidated home at
.zskills/tracked. MAIN ROOT ONLY — init NEVER walks or mutates
sibling worktrees (their markers live for the worktree's lifetime and
are covered by readers' dual-read fallback). Never-clobber: if the new
path already exists, leave both untouched (dual-read prefers the new
path; the stale root file is inert):
if [ -f "$CLAUDE_PROJECT_DIR/.zskills-tracked" ] \
&& [ ! -e "$CLAUDE_PROJECT_DIR/.zskills/tracked" ]; then
mkdir -p "$CLAUDE_PROJECT_DIR/.zskills"
mv "$CLAUDE_PROJECT_DIR/.zskills-tracked" "$CLAUDE_PROJECT_DIR/.zskills/tracked" \
|| echo "WARN: could not migrate .zskills-tracked to .zskills/tracked (non-fatal; hooks dual-read the old path)" >&2
fi
A3 — optional config interview (plugin-lane init arm ONLY — the legacy lane's Step 0.6 keeps its existing config-absent gate byte-untouched; this gate is init-done-keyed and lives only here). In a NON-INTERACTIVE context (cron-fired, headless, or an agent dispatch with no human to answer) SKIP the interview entirely — write nothing, defaults flow from the config cascade, the version lives in the init-done marker, and a later interactive bare run can still offer config via the update arm. If A1.5(b) just removed the seeded config THIS run, also skip — the consumer chose zero-config; re-offering immediately would contradict the choice (a later bare run re-offers via the update arm). Otherwise:
A config already exists (commonly: residue from the retired
pre-redesign installer's auto-seed that A1.5 conservatively kept, or
a user-created file): say "A config already exists at
.claude/zskills-config.json (it may have been auto-seeded) —
review it, especially testing.unit_cmd/full_cmd and
execution.landing." Keep it on any answer — init NEVER clobbers
and NEVER deletes a config.
No config exists (AND no project decline marker —
zskills_config_declined project "$CLAUDE_PROJECT_DIR" is false):
offer creation — "Want a project config? Without one, zskills runs on
built-in defaults (landing=direct, timezone=UTC); a config lets you
set test commands, landing mode, etc." On decline, write the
PROJECT decline marker so the update arm stops re-asking
(zskills_write_config_declined project "$CLAUDE_PROJECT_DIR") — no
config, no schema. On accept, seed from the
canonical defaults artifact (never a re-typed dict), atomically and
never-clobber, then copy the schema sibling and stamp
zskills_version (the Step F.5-region scripts) ONLY when the config
file exists:
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
[ -n "$PYTHON" ] || { echo "ERROR: zskills requires Python 3 — install it or set ZSKILLS_PYTHON" >&2; exit 1; }
ZS_CONFIG="$CLAUDE_PROJECT_DIR/.claude/zskills-config.json"
ZS_DEFAULTS="${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-defaults.json"
if [ ! -f "$ZS_CONFIG" ]; then # never-clobber
mkdir -p "$CLAUDE_PROJECT_DIR/.claude"
zs_cfg_tmp="$ZS_CONFIG.zskills-tmp.$$"
PROJECT_NAME="$(basename "$CLAUDE_PROJECT_DIR")" "$PYTHON" - "$ZS_DEFAULTS" "$zs_cfg_tmp" <<'PY'
import json, os, sys
with open(sys.argv[1]) as f:
defaults = json.load(f)
defaults.pop("_comment", None)
seed = {"$schema": "./zskills-config.schema.json",
"project_name": os.environ["PROJECT_NAME"]}
seed.update(defaults)
with open(sys.argv[2], "w") as f:
json.dump(seed, f, indent=2)
f.write("\n")
PY
if [ -s "$zs_cfg_tmp" ]; then
mv "$zs_cfg_tmp" "$ZS_CONFIG"
else
rm -f "$zs_cfg_tmp"
echo "WARN: failed to seed .claude/zskills-config.json — continuing config-less (defaults apply)" >&2
fi
fi
if [ -f "$ZS_CONFIG" ]; then
ZS_SCHEMA_SRC="${CLAUDE_PLUGIN_ROOT}/config/zskills-config.schema.json"
ZS_SCHEMA_DEST="$CLAUDE_PROJECT_DIR/.claude/zskills-config.schema.json"
if [ -f "$ZS_SCHEMA_SRC" ] && [ ! -f "$ZS_SCHEMA_DEST" ]; then
cp "$ZS_SCHEMA_SRC" "$ZS_SCHEMA_DEST"
|| echo "WARN: failed to copy zskills-config.schema.json — the config's $schema ref will dangle" >&2
fi
if [ -n "$ZS_INIT_VERSION" ]; then
bash "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/json-set-string-field.sh"
"$ZS_CONFIG" zskills_version "$ZS_INIT_VERSION"
|| echo "WARN: failed to stamp zskills_version into config" >&2
fi
fi
```
A3.5 — optional PERSONAL (user-tier) config offer (ENFORCEMENT_V2
Phase 6, #1160). A clearly-separated SECOND question, asked AFTER the
A3 project-config question, offering a user-global personal config at
~/.claude/zskills-config.json. Non-interactive contexts skip it (same
discipline as A3 — write nothing). The personal question fires ONLY
when ~/.claude/zskills-config.json is ABSENT and no personal
decline marker exists (zskills_config_declined personal "$HOME" is
false) — an existing user file means the consumer already chose, and a
decline must not re-ask in ANY project (the marker is user-global; see
A3.5 decline below). Phrase it as plainly separate from the project
question, e.g.:
"Separately — a PERSONAL config (
~/.claude/zskills-config.json) applies across ALL your projects. Under the v2 contract: unset keys track shipped defaults; workflow keys (execution.landing,execution.branch_prefix,execution.max_concurrent_worktrees) apply wherever a project does not override them; safety keys (execution.main_protected,agents.min_model) are raise-only floors that can raise a project's protection but never lower it. Want one?"
Accept → write an EMPTY scaffold (the _comment documents the v2
contract; NEVER seeded with values) plus a refresh-on-update copy of
the shipped schema. The scaffold is never-clobber (#1079); the
schema sibling is refresh-on-update (rewritten whenever it differs
from the shipped schema — it is a generated copy, and with
additionalProperties: false in the hooks block a stale sibling would
flag future VALID keys as editor errors):
# PERSONAL config accept (#1160). The scaffold is EMPTY BY DESIGN —
# NEVER seed values here. A seeded literal freezes today's default and
# silently stops tracking shipped-default changes (the FREEZE TRAP):
# an unset key tracks the evolving shipped default; a seeded key does
# not. The only config zskills ever pre-fills with values is the
# PROJECT seed (A3) — the personal scaffold stays empty.
#
# The mv destination is a VARIABLE ($ZS_USER_CONFIG), NOT the literal
# filename — this shape is LOAD-BEARING: the Phase 3 row-50 tamper
# check (config_hooks_tamper) is destination-anchored on the literal
# `.claude/zskills-config.json` string, so a literal-destination write
# here would warn/deny on zskills' OWN sanctioned writer. Keep it
# variable (DA4).
ZS_USER_DIR="$HOME/.claude"
ZS_USER_CONFIG="$ZS_USER_DIR/zskills-config.json"
ZS_USER_SCHEMA="$ZS_USER_DIR/zskills-config.schema.json"
mkdir -p "$ZS_USER_DIR"
if [ ! -f "$ZS_USER_CONFIG" ]; then # never-clobber (#1079)
zs_user_cfg_tmp="$ZS_USER_CONFIG.zskills-tmp.$$"
cat > "$zs_user_cfg_tmp" <<'PERSONALCFG'
{
"$schema": "./zskills-config.schema.json",
"_comment": "zskills personal config (v2 contract): unset keys track shipped defaults; workflow keys (execution.landing, execution.branch_prefix, execution.max_concurrent_worktrees, plus the cascadable string keys) apply wherever the project config does not override them; safety keys (execution.main_protected, agents.min_model) are raise-only floors — they can raise a project's protection, never lower it."
}
PERSONALCFG
if [ -s "$zs_user_cfg_tmp" ]; then
mv "$zs_user_cfg_tmp" "$ZS_USER_CONFIG"
else
rm -f "$zs_user_cfg_tmp"
echo "WARN: failed to write ~/.claude/zskills-config.json — continuing (defaults apply)" >&2
fi
fi
# Schema sibling — refresh-on-update (DA13): rewrite whenever it
# differs from the shipped schema (atomic; generated copy, not user
# content). Variable destination, same tamper-safety rationale.
ZS_USER_SCHEMA_SRC="${CLAUDE_PLUGIN_ROOT}/config/zskills-config.schema.json"
if [ -f "$ZS_USER_SCHEMA_SRC" ]
&& ! cmp -s "$ZS_USER_SCHEMA_SRC" "$ZS_USER_SCHEMA" 2>/dev/null; then
zs_user_schema_tmp="$ZS_USER_SCHEMA.zskills-tmp.$$"
if cp "$ZS_USER_SCHEMA_SRC" "$zs_user_schema_tmp"; then
mv "$zs_user_schema_tmp" "$ZS_USER_SCHEMA"
|| { rm -f "$zs_user_schema_tmp"; echo "WARN: failed to refresh ~/.claude/zskills-config.schema.json" >&2; }
else
rm -f "$zs_user_schema_tmp"
fi
fi
```
- **Decline** → record the user-scope decline marker so the question is
not re-asked in ANY project (update runs are deliberate checkup
moments, but a declined personal offer stays declined until the user
removes the marker):
```bash
zskills_write_config_declined personal "$HOME" \
|| echo "WARN: failed to write the personal-config decline marker" >&2
```
A4 — branch-dependent artifact writes: NO-OP on the landed branches.
Phase 1 selected agents = 1A (the plugin's root agents/ dispatches
directly) and timeout = T-A (Layer-0 ships via the plugin's
hooks/hooks.json), so init writes NO project-side agents and NO
project-side hooks. (The unselected 1B/T-C branch specs live in
docs/plans/INSTALL_REDESIGN_PLAN.md Phase 6a item A4, not in shipped
prose.)
A5 — rules delivery: NO-OP on the landed branch. Rules = R-b: the
plugin's SessionStart session-rules-context.sh hook delivers the
rendered rules as additionalContext every session — nothing to write
at init.
A6 — verify (HARD GATE — INSTALL_REDESIGN Phase 6b). Run the
bundled verifier's cheap structural tier — read-only, and a FAIL STOPs
init: no init-done marker is written (lock-LAST), the consumer fixes
the reported problem(s) and re-runs /zs:update-zskills (init re-runs
cleanly — every step up to here is idempotent). The verifier's
plugin-section checks are init-keyed (init-done/setup-confirmed,
gitignore umbrella, config-valid-if-present, R-b rules delivery,
version currency), so a healthy init never false-fails here. Note the
benign self-observation: when A6 runs INSIDE the init arm, init-done
does not exist yet by design (lock-LAST) — that is exactly the one FAIL
class still expected at this point, so the verifier is invoked with the
pre-lock allowance below. Never --deep. Resolve lane-awarely, exactly
as Step G.5 does:
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/verifiers/verify-install.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
VERIFY_INSTALL="${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/verifiers/verify-install.sh"
else
VERIFY_INSTALL="$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/verifiers/verify-install.sh"
fi
if [ -f "$VERIFY_INSTALL" ]; then
# Cheap tier only (no --deep). HARD GATE (Phase 6b): a FAIL beyond the
# expected pre-lock marker absences STOPs init before the lock write.
# ZSKILLS_VI_PRE_LOCK=1 tells the verifier the init markers are
# EXPECTED to be absent (A7 has not run yet) — their two checks report
# PASS-pending instead of FAIL, so the gate measures everything ELSE.
if ! ZSKILLS_VI_PRE_LOCK=1 bash "$VERIFY_INSTALL" --project-dir "$CLAUDE_PROJECT_DIR"; then
echo "STOP: verify-install reported a FAIL above — init did NOT complete (no init-done marker was written; lock-LAST)." >&2
echo "Fix the reported problem(s) and re-run /zs:update-zskills." >&2
exit 1
fi
else
echo "(post-install verifier not found at $VERIFY_INSTALL — skipping verification)"
fi
A7 — lock-LAST. Write the markers LAST, via the single writer
(setup-confirmed first, then init-done — both atomic; init-done carries
the version: + date: record read by the config-less version
fallback):
zskills_write_init_markers "$CLAUDE_PROJECT_DIR" "$ZS_INIT_VERSION" || {
echo "STOP: failed to write the init markers — init is incomplete; re-run /zs:update-zskills." >&2
exit 1
}
Then print the post-init footprint summary: exactly what was created
(the .zskills/ gitignore entry; the two .zskills/ markers; the
optional config + schema, only if accepted) and what A1.5 removed.
End the summary with one line of commit guidance: commit the
.gitignore change (and .claude/zskills-config.json + its schema
sibling, if created); .zskills/ stays untracked. Exit 0.
Update arm (init-done present) — A1.5 already ran; then:
Rules refresh: no-op on R-b (the SessionStart hook re-renders every session; there is no project-side rules copy to refresh).
Conditional config offer: when NO .claude/zskills-config.json
exists AND A1.5 did not just remove one THIS run
(ZS_REMOVED_CONFIG_THIS_RUN=0 — a consumer who accepted the
seeded-config removal has chosen zero-config; immediately re-offering
would contradict the choice; the offer stays available on a LATER bare
run) AND no project decline marker exists
(zskills_config_declined project "$CLAUDE_PROJECT_DIR" is false — a
consumer who declined the project config once is not re-asked), offer
creation via the same A3 interview (same accept fence, same
non-interactive skip, same decline-marker write).
Conditional PERSONAL config offer: re-run the A3.5 question on the
update arm too — it self-gates on ~/.claude/zskills-config.json
absence AND no personal decline marker
(zskills_config_declined personal "$HOME" is false), so an existing
user file or a prior decline silently suppresses it. Update runs are
deliberate checkup moments — the personal offer is re-presented to a
consumer who has neither a user file nor a decline on record.
Re-run verify-install — the same A6 fence (hard gate, cheap tier): a FAIL stops the run before the version-line refresh below; fix the reported problem(s) and re-run. (Markers already exist on this arm, so the fence's pre-lock allowance is inert here.)
Capture the RECORDED version BEFORE refreshing it (for the
config-news nudge below — the refresh clobbers it). The single-path
resolver is the init-done version: line, falling back to the config's
zskills_version field:
ZS_RECORDED_VER="$(sed -n 's/^version: //p' \
"$CLAUDE_PROJECT_DIR/$ZSKILLS_INIT_DONE_REL" 2>/dev/null | head -n 1)"
if [ -z "$ZS_RECORDED_VER" ] \
&& [ -f "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" ]; then
ZS_RECORDED_VER="$(sed -n 's/.*"zskills_version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
"$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" 2>/dev/null | head -n 1)"
fi
Refresh the version line in the init-done marker — re-run the same
single writer: zskills_write_init_markers "$CLAUDE_PROJECT_DIR" "$ZS_INIT_VERSION" (atomic rewrite; re-stamping setup-confirmed is the
same "ran /update-zskills" semantics the legacy lane records). If the
version resolve came back EMPTY, the writer keeps the marker's existing
non-empty version: value instead of clobbering it (#1150 guard —
documented in the writer's header in init-state.sh).
New-config-surface nudge (ENFORCEMENT_V2 Phase 6, #1160). The update
report is the SINGLE config-news channel (no SessionStart nags, no
per-session reminders). List every config key in
references/config-key-versions.tsv whose introduced-version is NEWER
than the consumer's recorded version — one line each (name, shipped
default, where to set). Version comparison is NUMERIC dot-segment
(R12) — YYYY.MM.N strings compare wrong lexically (2026.06.10 < 2026.06.2 as strings), so the compare runs in Python ($PYTHON from
the prelude already sourced in A3). No newer keys → no section (silence,
not noise). When the recorded version is empty/unknown, skip the nudge
(a config-less or unversioned install gets the full surface on its next
versioned refresh; do not flood it):
ZS_KEYVER_TSV="${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/references/config-key-versions.tsv"
if [ -n "$ZS_RECORDED_VER" ] && [ -f "$ZS_KEYVER_TSV" ] && [ -n "$PYTHON" ]; then
zs_news=""
while IFS=$'\t' read -r zs_key zs_introduced zs_default zs_where; do
case "$zs_key" in ''|'#'*) continue ;; esac
[ -n "$zs_introduced" ] || continue
if "$PYTHON" -c 'import sys; a,b=(tuple(int(x) for x in v.split("+")[0].split(".")) for v in sys.argv[1:3]); sys.exit(0 if a>b else 1)' \
"$zs_introduced" "$ZS_RECORDED_VER" 2>/dev/null; then
zs_news="$zs_news $zs_key — default ${zs_default:-(unset)} — set in $zs_where"$'\n'
fi
done < "$ZS_KEYVER_TSV"
if [ -n "$zs_news" ]; then
printf 'New config keys since %s:\n%s\n' "$ZS_RECORDED_VER" "$zs_news"
fi
fi
Print the update summary — include A1.5 removals, then the standing plugin-lane explanation — and exit 0:
You're on the plugin lane. Skills, hooks, and rules are plugin-managed —
update them with `/plugin marketplace update`, not `/update-zskills`. To
change landing mode here, run
`/zs:update-zskills <cherry-pick|locked-main-pr|direct>` or edit
`.claude/zskills-config.json` directly (config is the single source of
truth for mode).
(The former #1080 "plugin-pre-materialise" guard — LANE == fresh AND
$CLAUDE_PLUGIN_ROOT set, pointing the user at /clear so the SessionStart
materialiser could run — was deleted in INSTALL_REDESIGN Phase 7. It is
structurally unreachable now: detect_install_state's plugin-lane evidence
IS the $CLAUDE_PLUGIN_ROOT env context, so a plugin-loaded session can
never classify fresh — it classifies plugin (or dual when a mirror is
also present) and the LANE == plugin arm above runs the explicit init.)
Else (LANE is update-zskills, dual, or fresh, OR detection was
unreachable): proceed to the existing behavior. (A fresh arrival is by
construction plugin-less — $CLAUDE_PLUGIN_ROOT set would have classified
plugin — i.e. a genuine legacy first-time install, and proceeds
normally.) First check for a bare
preset: if $PRESET_ARG is non-empty AND $MODE is NOT install, jump to
## Bare-preset config-only short-circuit (config-only — no audit, no
pull, no fill) and exit there. Otherwise (no preset, or install <preset>)
proceed to Default Mode — Smart Detection / Fill-All-Gaps unchanged.
The dual case is intentionally NOT given its own arm — the mirror already
exists, so gap-fill is a non-destructive update, and verify-install's
lane.dual-unsupported FAIL already owns dual detection/warning (the
recovery is uninstall-one-lane-install-the-other);
/update-zskills must not add its own dual handling.
managed.md is not touched by this branch — because the plugin arm skips
the entire gap-fill (including Step B's render), the sentinel-clobber
landmine (a sentinel-less re-render shifting detect_install_state) cannot
occur on this path.
Runs on the legacy lane (i.e. when Step 0.7's Else arm was taken —
LANE is update-zskills, dual, or fresh, OR detection was
unreachable) when a bare preset was parsed: $PRESET_ARG is non-empty
AND $MODE is NOT install. (On the plugin lane the equivalent
config-only apply already happened in Step 0.7's preset arm, which then
prints the same confirmation + nudge documented here.)
A bare preset is a pure landing-mode switch — write the two
preset-owned config fields and STOP. Do NOT run the audit, do NOT
pull the source clone for skill updates, do NOT render/fill any gap.
The full smart-detect update is reserved for /update-zskills (no preset)
and /update-zskills install <preset>.
Step 1 — Apply the preset (config-only). Run the same
apply-preset.sh invocation as Step F (it edits ONLY
.claude/zskills-config.json — execution.landing +
execution.main_protected — preserving every other field; it never
touches the hook or the mirror):
if [ -f "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh" ]; then
export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
. "${CLAUDE_PLUGIN_ROOT}/skills/update-zskills/scripts/zskills-resolve-config.sh"
else
. "$CLAUDE_PROJECT_DIR/.claude/skills/update-zskills/scripts/zskills-resolve-config.sh"
fi
bash "$ZSKILLS_SKILLS_ROOT/update-zskills/scripts/apply-preset.sh" "$PRESET_ARG"
Capture the exit code per Step F's table (0 applied / 1 no-change / 2 usage / 3 missing config / 4 malformed config).
Step 2 — Config-only confirmation + version nudge. Print a confirmation that is EXPLICIT that nothing was pulled or updated, pointing the user at the bare no-arg command for a real update:
Applied preset <name> (config only — no skills pulled or updated).
(For exit 1 / no-change, say Preset <name> already applied — config unchanged (no skills pulled or updated). instead.)
Version nudge (best-effort, offline-safe — reuse the existing
machinery, do NOT invent new comparison logic). Reuse the SAME
resolve-repo-version + installed-zskills_version comparison the audit's
"Versions:" line uses (see ### Step 6 — Produce the gap report). The
ONLY addition for this path is a best-effort git fetch --tags on the
source clone FIRST, so the comparison sees newly-published tags that the
local clone has not yet fetched:
# Best-effort tag refresh on the source clone (offline-safe). The source
# clone is $ZSKILLS_PATH when Step 0 resolved a git clone; if it is unset,
# not a git repo, or the fetch fails (no network / no remote), SKIP the
# nudge entirely — NEVER fail or abort the config write because of it.
if [ -n "${ZSH_VERSION:-}" ]; then setopt KSH_ARRAYS BASH_REMATCH SH_WORD_SPLIT 2>/dev/null || true; fi
if [ -n "${ZSKILLS_PATH:-}" ] && [ -d "$ZSKILLS_PATH/.git" ]; then
git -C "$ZSKILLS_PATH" fetch --tags --quiet || true
fi
# Reuse the audit's resolve+read (single source of truth — same scripts):
current_zskills_ver=""
installed_zskills_ver=""
if [ -n "${ZSKILLS_PATH:-}" ]; then
current_zskills_ver=$(bash "$ZSKILLS_SKILLS_ROOT/update-zskills/scripts/resolve-repo-version.sh" "$ZSKILLS_PATH")
fi
if [ -f "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json" ]; then
cfg=$(cat "$CLAUDE_PROJECT_DIR/.claude/zskills-config.json")
if [[ "$cfg" =~ \"zskills_version\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
installed_zskills_ver="${BASH_REMATCH[1]}"
fi
fi
Print the nudge line **ONLY when the installed version is strictly behind