docs(deploy): design spec + implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d
This commit is contained in:
parent
cd375dd74a
commit
b210e8d6a8
381
docs/plans/2026-06-27-deploy-skill.md
Normal file
381
docs/plans/2026-06-27-deploy-skill.md
Normal file
@ -0,0 +1,381 @@
|
||||
# Deploy Skill — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a `deploy` skill — a per-project shell runbook that re-instantiates from the delta since the last deploy, hands control to the user for out-of-band execution, resumes cold (even in a new session), and learns from deploy errors in place.
|
||||
|
||||
**Architecture:** A surgical-commit helper (`lib/deploy-commit.sh`, allowlist-scoped to `.claude/deploy/`) is the foundation. Five per-project artifacts under `.claude/deploy/` carry runbook, incident ledger, deploy oracle, in-flight bridge, and the instantiated checklist. The skill is a two-moment SKILL.md (before → user deploys out-of-band → after, on the user's report), resumable cold from the JSON bridge per the `audit-delta` state-file convention. Bootstrap scaffolds the runbook for a project that has none.
|
||||
|
||||
**Tech Stack:** Bash (helper + git), Markdown (SKILL.md + runbook + ledger), JSON (oracle + bridge). No new runtime deps — Claude reads JSON natively in skill steps; the helper never parses JSON.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Surgical commits only: `deploy-commit.sh` commits via explicit argv pathspec, never `git add -A`. (mirror BDR-034/036)
|
||||
- Allowlist scope = `.claude/deploy/` ONLY; any other path is a loud rc-4 refusal. Inverse of `doc-commit.sh`'s `.claude/**` exclusion (BDR-022). Verified: real `doc-commit.sh` returns rc 4 on `.claude/deploy/PROCEDURE.md`.
|
||||
- Delta = `git diff --name-only <base_sha> HEAD` — **explicit two endpoints, no dots** (two-dot ≡ this; three-dot undercounts — verified). Never `git rev-list` ancestry (phantom deltas on rebase — verified).
|
||||
- First-deploy detection = `[ -f .claude/deploy/STATE.json ]` (deterministic). NEVER `git describe` (hard-errors rc 128 on no tag — verified).
|
||||
- Resume convention = `audit-delta`: "the state file is the only memory between runs; never infer prior scope from context." Bridge read at STEP 0.
|
||||
- Helper inherits from `lib/memory-commit.sh`/`lib/doc-commit.sh`: rc 3 on unsafe git state (detached/merge/rebase/cherry-pick), short-hash on stdout only on a real commit, per-file changed-paths filter, diagnostics to stderr.
|
||||
- User executes the deploy out-of-band (prod ssh) — the skill NEVER runs deploy commands itself.
|
||||
- Registries/spec language English; the spec of record is `docs/specs/2026-06-27-deploy-skill-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Decisions resolved at plan time
|
||||
|
||||
**§10 (cross-session state) — TRANCHÉ: separate bridge artifact.**
|
||||
- Bridge = `.claude/deploy/PENDING.json` (JSON), **distinct from the ephemeral `NEXT.sh`**, **uncommitted** (transient local working state; gitignored). Schema:
|
||||
```json
|
||||
{ "base_sha": "<deployed STATE sha>", "target_sha": "<HEAD at instantiation>",
|
||||
"delta": ["supabase/migrations/0033_x.sql", "docker-compose.yml"],
|
||||
"step_reached": "awaiting-user", "started_at": "<ISO-8601>", "runbook_rev": "<PROCEDURE.md commit sha>" }
|
||||
```
|
||||
- Follows `audit-delta` ("state file is the only memory between runs"). Resolves the n°1↔n°3 coupling: NEXT.sh stays ephemeral per §3; the bridge persists and carries base+target+delta so moment 3 lays the correct marker and capitalizes the correct incident — **without re-parsing shell**, readable cold.
|
||||
- Form-novelty (mid-flow pause-resume) is new → `writing-skills` formalizes the convention in Task 3.
|
||||
- **LIMIT (acknowledged, not to be discovered):** `PENDING.json` is gitignored ⇒ cold-resume is **same-machine only** — it does not survive a clone or a move to another machine. Acceptable because a project's deploys run from one local; recorded as a constraint, not assumed away.
|
||||
|
||||
**§8 item 1 — tag push:** annotated tag `git tag -a deploy/<YYYY-MM-DD> <target_sha> -m "<summary>"` laid in MARK (success). **Project knob `# @config push_deploy_tags=true|false`** in the `PROCEDURE.md` header (default `false`): when true, MARK runs `git push origin deploy/<date>` — always **best-effort/non-fatal** (the push never blocks the deploy; tag is a bookmark, STATE.json is the oracle). Same-day re-deploy → suffix `-N`.
|
||||
|
||||
**§8 item 2 — INCIDENTS ID/name:** `.claude/deploy/INCIDENTS.md`, append-only, entries `DEP-NNN` (next = `grep '^## DEP-' | max+1`), fields mirror `blockers.md`: date, step, error (verbatim), root cause, fix, → resolving commit sha. Name confirmed `INCIDENTS.md` (not `ERRORS-LEARNED.md`).
|
||||
|
||||
**§8 item 3 — `@delta:` grammar:** directives on a runbook step's preceding comment line, patterns matched against the delta file list. `glob=` carries TWO required semantics (a single "checklist-only" reading was REJECTED — it breaks the game example, where step 3 runs `psql -f 0033` THEN `psql -f 0034` = one command PER file):
|
||||
- `# @delta:<name> glob=<pat>:each` — **repeat**: emit the step's command once per delta file matching `<pat>` (e.g. `psql -f <each>`).
|
||||
- `# @delta:<name> glob=<pat>:list` — **checklist**: emit the command once, with matching files as `# VERIFY:` items (e.g. `supabase migration up`).
|
||||
- `# @delta:<name> when=<pat>[,<pat>...]` — **conditional**: include the step only if the delta intersects any pattern (e.g. rebuild when compose/Dockerfile changed).
|
||||
- Patterns are git-pathspec/shell-glob; comma-separates alternatives. **Un-annotated step = fixed**, always emitted verbatim. The exact `:each`/`:list` keyword spelling is DEFERRED to `writing-skills` (Task 3); both semantics are mandatory.
|
||||
|
||||
**§8 item 4 — frontmatter / gates:**
|
||||
```yaml
|
||||
name: deploy
|
||||
description: |
|
||||
Use when deploying a project via its per-project runbook — instantiates the
|
||||
delta since last deploy, hands off for out-of-band execution, resumes cold,
|
||||
learns from errors.
|
||||
Triggers: "deploy", "déploie", "run the deploy", "ship to prod", "deploy runbook".
|
||||
allowed-tools: [Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion]
|
||||
```
|
||||
Gate vocabulary reused from `capitalize`/`client-handover`: `all / pick <IDs> / edit <ID> / skip-all`. Gates marked **[GATE]** in Task 3.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `lib/deploy-commit.sh` — surgical commit helper, allowlist `.claude/deploy/`. (Task 1)
|
||||
- Create `lib/tests/deploy-commit.test.sh` — real-git behavioral tests. (Task 1)
|
||||
- Create `skills/deploy/SKILL.md` — the two-moment skill. (Task 3)
|
||||
- Create `templates/deploy/PROCEDURE.md` — annotated starter runbook (scaffold source). (Task 2/4)
|
||||
- Create `templates/deploy/INCIDENTS.md` — empty ledger header. (Task 2)
|
||||
- Modify `.gitignore` — ignore `.claude/deploy/NEXT.sh` and `.claude/deploy/PENDING.json`. (Task 2)
|
||||
- Per-project, created at runtime (NOT in this repo): `.claude/deploy/{PROCEDURE.md, INCIDENTS.md, STATE.json, PENDING.json, NEXT.sh}`.
|
||||
|
||||
**Artifact lifecycle:**
|
||||
|
||||
| Artifact | Committed? | Lifecycle |
|
||||
|---|---|---|
|
||||
| `PROCEDURE.md` | yes (deploy-commit) | in-place edits (learning) |
|
||||
| `INCIDENTS.md` | yes (deploy-commit) | append-only `DEP-NNN` |
|
||||
| `STATE.json` | yes (deploy-commit) | overwritten on success = oracle |
|
||||
| `PENDING.json` | **no** (gitignored) | written at hand-back, deleted on success = cold-resume bridge |
|
||||
| `NEXT.sh` | **no** (gitignored) | regenerated per deploy, ephemeral checklist |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `lib/deploy-commit.sh` — surgical commit helper (FOUNDATION, TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/deploy-commit.sh`
|
||||
- Test: `lib/tests/deploy-commit.test.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `deploy-commit.sh pending <file>...` → exit 0 if any passed file in-scope has changes, else 1. `deploy-commit.sh commit "<msg>" <file>...` → commits ONLY passed in-scope files, prints short hash on stdout; rc 0 success, rc 1 clean/no-op, rc 3 unsafe git state, rc 4 out-of-scope path.
|
||||
- Consumes: nothing (foundation).
|
||||
|
||||
- [ ] **Step 1: Write the failing test harness**
|
||||
|
||||
```bash
|
||||
# lib/tests/deploy-commit.test.sh
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
H="$(cd "$(dirname "$0")/.." && pwd)/deploy-commit.sh"
|
||||
pass=0; fail=0
|
||||
mkrepo() { local d; d=$(mktemp -d); git -C "$d" init -q; git -C "$d" config user.email t@t;
|
||||
git -C "$d" config user.name t; mkdir -p "$d/.claude/deploy"; printf 'x\n' >"$d/seed";
|
||||
git -C "$d" add seed; git -C "$d" commit -q -m seed; printf '%s' "$d"; }
|
||||
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
|
||||
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
|
||||
|
||||
d=$(mkrepo); printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||
out=$( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ); rc=$?
|
||||
check T1-rc "$rc" 0
|
||||
check T1-committed-only "$(git -C "$d" show --name-only --format= HEAD)" ".claude/deploy/PROCEDURE.md"
|
||||
check T1-hash-nonempty "$([ -n "$out" ] && echo y || echo n)" y
|
||||
|
||||
d=$(mkrepo); printf 'b\n' >"$d/src.txt"
|
||||
( cd "$d" && bash "$H" commit "x" src.txt ) >/dev/null 2>&1; check T2-out-of-scope-rc "$?" 4
|
||||
|
||||
d=$(mkrepo)
|
||||
( cd "$d" && bash "$H" commit "x" ".claude/deploy/../memory/secret" ) >/dev/null 2>&1
|
||||
check T3-traversal-rc "$?" 4
|
||||
|
||||
d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"; printf 's\n' >"$d/src.txt"
|
||||
( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md src.txt ) >/dev/null 2>&1
|
||||
check T4-mixed-refuses-all "$?" 4
|
||||
check T4-nothing-committed "$(git -C "$d" rev-list --count HEAD)" 1
|
||||
|
||||
d=$(mkrepo); git -C "$d" checkout -q --detach
|
||||
printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||
( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1
|
||||
check T5-unsafe-rc "$?" 3
|
||||
|
||||
d=$(mkrepo)
|
||||
( cd "$d" && bash "$H" pending .claude/deploy/PROCEDURE.md ); check T6-pending-clean-rc "$?" 1
|
||||
|
||||
d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||
printf 'i\n' >"$d/.claude/deploy/INCIDENTS.md"; printf '{}\n' >"$d/.claude/deploy/STATE.json"
|
||||
( cd "$d" && bash "$H" commit "docs(deploy): learn" .claude/deploy/PROCEDURE.md \
|
||||
.claude/deploy/INCIDENTS.md .claude/deploy/STATE.json ) >/dev/null 2>&1
|
||||
check T7-atomic-rc "$?" 0
|
||||
check T7-three-files "$(git -C "$d" show --name-only --format= HEAD | grep -c deploy)" 3
|
||||
|
||||
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, verify it FAILS**
|
||||
|
||||
Run: `bash lib/tests/deploy-commit.test.sh`
|
||||
Expected: FAIL (helper absent) — every check fails or the harness errors on missing `lib/deploy-commit.sh`.
|
||||
|
||||
- [ ] **Step 3: Implement `lib/deploy-commit.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# deploy-commit.sh — surgical commit for the .claude/deploy/ runbook family.
|
||||
# Allowlist scope = .claude/deploy/ ONLY (inverse of doc-commit's .claude exclusion).
|
||||
set -u
|
||||
|
||||
_in_git_repo() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; }
|
||||
|
||||
_unsafe_state() { # 0 = unsafe
|
||||
local g; g=$(git rev-parse --git-dir 2>/dev/null) || return 0
|
||||
git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD
|
||||
[ -e "$g/MERGE_HEAD" ] || [ -d "$g/rebase-merge" ] || \
|
||||
[ -d "$g/rebase-apply" ] || [ -e "$g/CHERRY_PICK_HEAD" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
_out_of_scope() { # 0 = forbidden, 1 = in scope
|
||||
case "$1" in
|
||||
*..*) return 0 ;; # traversal — forbidden FIRST
|
||||
.claude/deploy/*) return 1 ;; # allowed
|
||||
*) return 0 ;; # everything else forbidden
|
||||
esac
|
||||
}
|
||||
|
||||
_scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; }
|
||||
|
||||
_changed_only() { # echo passed files that actually have changes
|
||||
local p; for p in "$@"; do
|
||||
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done
|
||||
}
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
_in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; }
|
||||
|
||||
case "$cmd" in
|
||||
pending)
|
||||
[ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; }
|
||||
[ -n "$(_changed_only "$@")" ] && exit 0 || exit 1 ;;
|
||||
commit)
|
||||
msg="${1:-}"; shift || true
|
||||
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
|
||||
viol=$(_scope_violations "$@")
|
||||
if [ -n "$viol" ]; then
|
||||
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||
printf ' - %s\n' $viol;
|
||||
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
|
||||
exit 4
|
||||
fi
|
||||
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
|
||||
mapfile -t changed < <(_changed_only "$@")
|
||||
[ "${#changed[@]}" -gt 0 ] || exit 1
|
||||
git commit -q -m "$msg" -- "${changed[@]}" || { echo "deploy-commit: git commit failed" >&2; exit 1; }
|
||||
git rev-parse --short HEAD ;;
|
||||
*) echo "usage: deploy-commit.sh pending <file>... | commit \"<msg>\" <file>..." >&2; exit 2 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it PASSES**
|
||||
|
||||
Run: `bash lib/tests/deploy-commit.test.sh`
|
||||
Expected: `PASS=11 FAIL=0` (exit 0).
|
||||
|
||||
- [ ] **Step 5: shellcheck**
|
||||
|
||||
Run: `shellcheck lib/deploy-commit.sh lib/tests/deploy-commit.test.sh`
|
||||
Expected: clean (matches repo Health Stack norm).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/deploy-commit.sh lib/tests/deploy-commit.test.sh
|
||||
git commit -m "feat(deploy): deploy-commit.sh — allowlist surgical commit for .claude/deploy/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Artifacts + bridge formats (§10 materialized)
|
||||
|
||||
**Files:**
|
||||
- Create: `templates/deploy/PROCEDURE.md`, `templates/deploy/INCIDENTS.md`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the on-disk shapes the skill reads/writes — `PROCEDURE.md` annotation grammar, `INCIDENTS.md` `DEP-NNN` template, `STATE.json` and `PENDING.json` schemas.
|
||||
- Consumes: nothing.
|
||||
|
||||
- [ ] **Step 1: Write `templates/deploy/PROCEDURE.md`** (annotated starter — fixed steps verbatim, dynamic steps annotated)
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# === deploy runbook (reference) — NOT run directly. Instantiated to NEXT.sh per delta. ===
|
||||
# Fixed steps run every deploy; `# @delta:` steps re-instantiate from the delta.
|
||||
# @config push_deploy_tags=false
|
||||
# NOTE grammar: glob=<pat>:each repeats the command per matching file (e.g. psql -f <each>);
|
||||
# glob=<pat>:list runs once + lists matching files as VERIFY items; when=<pat,...> is conditional.
|
||||
|
||||
# 1) backup BEFORE any forward-only migration
|
||||
ssh "$DEPLOY_HOST" 'pg_dump "$DB" > ~/backups/pre-deploy-$(date +%F-%H%M).sql' # VERIFY: dump size > 0
|
||||
|
||||
# @delta:migrations glob=supabase/migrations/*.sql:list
|
||||
# 2) apply NEW migrations (one command; skill lists the delta migrations to VERIFY)
|
||||
ssh "$DEPLOY_HOST" 'supabase migration up' # VERIFY: "Applied" for each
|
||||
|
||||
# @delta:rebuild when=docker-compose*.yml,Dockerfile,*.dockerfile
|
||||
# 3) rebuild + restart services (only if build inputs changed)
|
||||
ssh "$DEPLOY_HOST" 'docker compose up -d --build' # VERIFY: docker compose ps healthy
|
||||
|
||||
# @delta:deps when=package.json,*lock*,requirements.txt,pyproject.toml
|
||||
# 4) install deps (only if manifests changed)
|
||||
ssh "$DEPLOY_HOST" 'cd app && npm ci' # VERIFY: exit 0
|
||||
|
||||
# 5) reload cache + smoke test (fixed)
|
||||
ssh "$DEPLOY_HOST" 'systemctl reload app'
|
||||
curl -fsS https://$DEPLOY_HOST/health # VERIFY: HTTP 200
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `templates/deploy/INCIDENTS.md`** (ledger header)
|
||||
|
||||
```markdown
|
||||
# Deploy incidents (append-only) — DEP-NNN
|
||||
|
||||
<!-- One entry per incident. Next ID = grep '^## DEP-' | max+1. Mirrors blockers.md. -->
|
||||
<!-- ## DEP-NNN — <step> failed
|
||||
- date: YYYY-MM-DD
|
||||
- step: <runbook step + label>
|
||||
- error: `<verbatim error>`
|
||||
- cause: <root cause>
|
||||
- fix: <what changed in PROCEDURE.md>
|
||||
- resolved-by: <commit sha> -->
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Record the JSON schemas** (no parsing in shell — Claude reads them in skill steps)
|
||||
|
||||
`STATE.json` (committed oracle, overwritten on success):
|
||||
```json
|
||||
{ "deployed_sha": "<sha>", "deployed_at": "<ISO-8601>", "outcome": "ok",
|
||||
"tag": "deploy/<YYYY-MM-DD>" }
|
||||
```
|
||||
`PENDING.json` (gitignored bridge, deleted on success): schema as in "Decisions resolved at plan time / §10".
|
||||
|
||||
- [ ] **Step 4: Update `.gitignore`**
|
||||
|
||||
```gitignore
|
||||
# deploy: transient per-deploy state (the runbook/ledger/oracle ARE committed)
|
||||
.claude/deploy/NEXT.sh
|
||||
.claude/deploy/PENDING.json
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify templates are well-formed**
|
||||
|
||||
Run: `bash -n templates/deploy/PROCEDURE.md && grep -c '@delta:' templates/deploy/PROCEDURE.md`
|
||||
Expected: no syntax error; `3` annotations.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/deploy/PROCEDURE.md templates/deploy/INCIDENTS.md .gitignore
|
||||
git commit -m "feat(deploy): runbook/ledger templates + bridge schemas + gitignore transient state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `skills/deploy/SKILL.md` — the two-moment skill (REQUIRES writing-skills)
|
||||
|
||||
> **At this task, invoke `superpowers:writing-skills`** to shape SKILL.md to house conventions AND to formalize the **cross-session cold-resume** form (deploy's defining novelty; `audit-delta` is the state-file precedent, `client-handover` only an in-context pause). The step behaviors below are the contract; writing-skills governs structure/frontmatter/spine.
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/deploy/SKILL.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `lib/deploy-commit.sh` (Task 1); artifact shapes (Task 2).
|
||||
- Produces: the runtime behavior. STEP spine below.
|
||||
|
||||
**STEP spine (each = a SKILL.md section; [GATE] = mandatory stop):**
|
||||
|
||||
- [ ] **STEP 0 — PRE-FLIGHT + RESUME BRANCH.** Read `.claude/deploy/PENDING.json` FIRST (state file = only memory between runs).
|
||||
- `PENDING.json` present → **RESUME**: jump to STEP 3 with its `{base, target, delta, step_reached}` (do not recompute).
|
||||
- else `PROCEDURE.md` absent → **BOOTSTRAP** (Task 4).
|
||||
- else → FRESH: continue STEP 1.
|
||||
- [ ] **STEP 1 — DELTA.** `base = STATE.json.deployed_sha` (or, if `STATE.json` absent, first-deploy = full runbook). `git diff --name-only <base> HEAD` → delta file list. `target = git rev-parse HEAD`.
|
||||
- [ ] **STEP 2 — INSTANTIATE + [GATE].** Expand `PROCEDURE.md`: emit fixed steps verbatim; expand `@delta:glob=…:each` steps by repeating the command per matching delta file, and `@delta:glob=…:list` steps once with matching files as `# VERIFY:` items; include `@delta:when=` steps only if the delta intersects. Read `INCIDENTS.md` and prepend matching `# PRE-WARN: DEP-NNN …` notes. Write `NEXT.sh`. **[GATE]** present `NEXT.sh` → `all / edit / skip-all`. On approve: write `PENDING.json` (`step_reached: awaiting-user`), then **hand back** (AskUserQuestion: "Run NEXT.sh step by step. Report back: Deployed OK / Failed at step X / Not yet").
|
||||
- [ ] **STEP 3 — RESUME / REACT** (entry point on the user's report; may be a fresh session).
|
||||
- "Deployed OK" → STEP 5.
|
||||
- "Failed at step X: <err>" → STEP 4.
|
||||
- "Not yet" → re-state pending, stop.
|
||||
- [ ] **STEP 4 — LEARN + [GATE] + ATOMIC COMMIT.** Diagnose. Draft: (a) in-place `PROCEDURE.md` patch to step X; (b) `INCIDENTS.md` append `DEP-NNN` (error verbatim). **[GATE]** `all / pick / edit / skip-all` (significant edit). On approve: write both, then **one atomic** `bash lib/deploy-commit.sh commit "docs(deploy): patch <step> — recovered from <err>" .claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md`. Set `resolved-by` to the returned sha. Then bump `PENDING.json.runbook_rev` to the new `PROCEDURE.md` commit sha (keep `step_reached` at X). **Resume = REGENERATE `NEXT.sh` from `step_reached` against the PATCHED runbook** (steps X…end — X+1…end never ran), NOT replay a single step. The bumped `runbook_rev` is exactly the trigger: runbook changed ⇒ prior `NEXT.sh` is stale ⇒ regenerate. Re-present via STEP 2's hand-back.
|
||||
- [ ] **STEP 5 — MARK (success).** Write `STATE.json` (`deployed_sha = PENDING.target_sha`, outcome ok, tag). `git tag -a deploy/<date> <target> -m "<summary>"`; **if `@config push_deploy_tags=true`** then `git push origin deploy/<date>` (best-effort, non-fatal). `bash lib/deploy-commit.sh commit "chore(deploy): mark <date> @ <short>" .claude/deploy/STATE.json`. **Delete `PENDING.json`** (+ `NEXT.sh`). Report.
|
||||
|
||||
- [ ] **Verification scenarios** (dry-run walkthroughs, no prod):
|
||||
- First deploy (no `STATE.json`): full runbook fires; STATE laid; PENDING deleted.
|
||||
- Delta deploy: only changed-bucket steps instantiate; `git diff` form is `<base> HEAD`.
|
||||
- **Cold resume**: write a `PENDING.json` by hand, start `deploy` in a *fresh* context → STEP 0 detects it, resumes at STEP 3 from disk alone (no conversation memory).
|
||||
- Failure→learn: report "failed at step X" → patch + DEP append committed atomically (one sha, both files).
|
||||
- [ ] **Commit:** `git add skills/deploy/SKILL.md && git commit -m "feat(deploy): two-moment cross-session skill (resumes cold from PENDING.json)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Bootstrap (project without a runbook)
|
||||
|
||||
**Files:**
|
||||
- Modify: `skills/deploy/SKILL.md` (STEP 0 BOOTSTRAP branch)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `templates/deploy/*` (Task 2); STEP spine (Task 3).
|
||||
|
||||
- [ ] **Step 1 — BOOTSTRAP branch + [GATE].** When `PROCEDURE.md` absent, offer two paths (AskUserQuestion):
|
||||
- **Paste** — user provides an existing runbook → adopt verbatim, then propose `@delta:` annotations for migration/build/deps steps.
|
||||
- **Scaffold** — detect artifacts (`supabase/migrations/`, `docker-compose*.yml`/`Dockerfile`, `package.json`/lockfiles, `.env*`) + short interview (ssh host, backup cmd, health URL, rollback note) → fill `templates/deploy/PROCEDURE.md`.
|
||||
- **[GATE]** present drafted `PROCEDURE.md` → `all / edit / skip-all`. On approve: write `PROCEDURE.md` + empty `INCIDENTS.md`; `bash lib/deploy-commit.sh commit "feat(deploy): bootstrap runbook" .claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md`. First deploy then proceeds (no STATE.json ⇒ full runbook).
|
||||
- [ ] **Step 2 — Verify:** dry-run on a repo with `supabase/migrations/` + `docker-compose.yml` present → scaffold proposes migration + rebuild steps annotated; on a bare repo → interview-only path.
|
||||
- [ ] **Commit:** `git add skills/deploy/SKILL.md && git commit -m "feat(deploy): bootstrap — paste-or-scaffold initial runbook"`
|
||||
|
||||
---
|
||||
|
||||
## Gates identified
|
||||
|
||||
- **[GATE] STEP 2** — approve instantiated `NEXT.sh` before hand-back.
|
||||
- **[GATE] STEP 4** — approve runbook patch + `DEP-NNN` incident before the atomic learning commit.
|
||||
- **[GATE] STEP 0/Task 4** — approve scaffolded `PROCEDURE.md` before first write.
|
||||
- **Hand-back (STEP 2→3)** — AskUserQuestion is the resume point; the user executes out-of-band.
|
||||
- **Task gates** — each Task ends test-green + shellcheck-clean + committed before the next (deps: 1 → 2 → 3 → 4).
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** 4 artifacts + bridge (§3/§10) → Task 2; STATE-oracle + `<base> HEAD` delta (§4) → Task 1 constraints + STEP 1; runbook+INCIDENTS learning, atomic couple (§5) → STEP 4; `deploy-commit.sh` inverse allowlist (§6) → Task 1; bootstrap (§7) → Task 4; two-moment cold resume (§10) → STEP 0/2/3 + PENDING.json. All §8 items resolved above. ✓
|
||||
- **Placeholder scan:** none — helper code, test code, schemas, annotation grammar all concrete.
|
||||
- **Type consistency:** `STATE.json.deployed_sha` (STEP 1 base, STEP 5 write), `PENDING.json.{base_sha,target_sha,delta,step_reached}` (STEP 0 read, STEP 2 write, STEP 4 update), `deploy-commit.sh commit "<msg>" <file>...` (Tasks 1/3/4) — names align.
|
||||
- **Open at execution (not assumed):** the `writing-skills` consultation in Task 3 may rename/restructure SKILL.md sections to match the formalized cold-resume convention, and finalizes the `@delta:` `:each`/`:list` keyword spelling (both semantics mandatory); STEP behaviors and the §6 helper contract above are fixed regardless.
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Build order is strict by dependency: **Task 1 (helper, foundation) → Task 2 (formats) → Task 3 (skill, writing-skills) → Task 4 (bootstrap)**.
|
||||
161
docs/specs/2026-06-27-deploy-skill-design.md
Normal file
161
docs/specs/2026-06-27-deploy-skill-design.md
Normal file
@ -0,0 +1,161 @@
|
||||
# Deploy skill — design spec
|
||||
|
||||
- **Date:** 2026-06-27
|
||||
- **Status:** Design approved (5 knobs settled). **No skill code written yet.** Next step = implementation plan.
|
||||
- **Scope:** A new `deploy` skill = a per-project shell RUNBOOK that lives in `.claude/deploy/`, gets re-instantiated from the delta since the last deploy, and LEARNS from deploy errors in place.
|
||||
|
||||
## 1. Vision — deployment memory that learns
|
||||
|
||||
Three moments:
|
||||
|
||||
1. **BEFORE** — produce the *instantiated* runbook: reference runbook + delta since last deploy, parameterized steps rewritten with the real artifacts (e.g. the migration step lists the migrations actually added since last deploy, not the runbook's examples).
|
||||
2. **DURING** — the **user executes out-of-band** (prod ssh — Claude must not run it) and reports `deployed and tested` OR `failed at step X, here is the error` → fix together until success.
|
||||
3. **AFTER** — on confirmed success: (a) if errors were hit + fixed, update the reference runbook so the next deploy does not repeat them; (b) lay the marker "deployed up to here" for the next diff.
|
||||
|
||||
Structural ancestor in the corpus: `client-handover` (BEFORE baseline → DURING user-deploy gate via `AskUserQuestion` → AFTER validate + react). No existing skill owns a learning per-project runbook — clean gap, no `.claude/deploy/` precedent.
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| # | Knob | Decision |
|
||||
|---|------|----------|
|
||||
| 1 | Marker / oracle | **STATE file is the oracle** (deployed SHA), **annotated tag** added as a human bookmark only |
|
||||
| 2 | Learning storage | **In-place runbook edits + append-only `INCIDENTS.md`** (distinct jobs, atomic coupling) |
|
||||
| 3 | Parameterization | **`# @delta:` annotations** bind dynamic steps to path-patterns; un-annotated steps are fixed |
|
||||
| 4 | Bootstrap | **Offer both** — user pastes an existing runbook OR skill scaffolds via artifact detection + interview |
|
||||
| 5 | Execution model | **`NEXT.sh` is a step-by-step CHECKLIST** — runnable shell, but driven by hand with manual `# VERIFY:` gates; never `bash NEXT.sh` unattended |
|
||||
|
||||
**Why #5 is design-time, not impl:** the execution model is load-bearing for moments 2 and 3. Moment 2 is defined as "user reports *failed at step X*", and moment 3's LEARN loop must know *which* step failed to patch it. A single `bash NEXT.sh` blob collapses both into "exited non-zero somewhere" and can strand a prod deploy (migrations, restarts) in partial state with no step control. Checklist is *entailed* by the three-moment structure, not merely safer.
|
||||
|
||||
Treated as settled corollaries: user executes out-of-band; a **new** `lib/deploy-commit.sh` helper (existing helpers cannot commit the runbook — see §6, verified).
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
.claude/deploy/
|
||||
PROCEDURE.md reference runbook — fixed shell + `# @delta:` annotated steps (edited IN-PLACE)
|
||||
INCIDENTS.md DEP-NNN incident ledger: date, step, error verbatim, root cause,
|
||||
fix, -> resolving commit hash (APPEND-ONLY)
|
||||
STATE deployed SHA + timestamp + outcome — the diff oracle (overwritten each deploy)
|
||||
NEXT.sh instantiated runbook — EPHEMERAL, not committed ; run STEP-BY-STEP
|
||||
(checklist, manual # VERIFY: gates) — never `bash NEXT.sh` unattended
|
||||
|
||||
lib/deploy-commit.sh surgical commit, allowlist = .claude/deploy/ , rc3 unsafe-git guard, short-hash stdout
|
||||
|
||||
Skill STEP spine (PRE-FLIGHT -> PROPOSE+GATE -> WRITE+COMMIT, house style):
|
||||
0 PRE-FLIGHT runbook present? absent -> bootstrap (paste | scaffold+interview)
|
||||
1 DELTA STATE absent -> first deploy = full runbook ; else diff <STATE_SHA> HEAD
|
||||
2 INSTANTIATE expand @delta steps + read INCIDENTS pre-warns -> NEXT.sh -> GATE
|
||||
3 (user executes out-of-band; reports "done" | "failed at step X: <err>")
|
||||
4 LEARN on failure: patch PROCEDURE step + append DEP-NNN -> GATE -> deploy-commit (ATOMIC)
|
||||
5 MARK on success: write STATE@sha ; annotate + push tag ; optional doc
|
||||
```
|
||||
|
||||
## 4. Delta mechanism — verified (git 2.53.0)
|
||||
|
||||
All three facts re-run live before writing this spec; observed output recorded, not assumed.
|
||||
|
||||
**First-deploy detection = STATE-absent, deterministic. `describe` is off the detection path.**
|
||||
```
|
||||
[ -f .claude/deploy/STATE ] => exit 1 (absent = first deploy) <- THE detector
|
||||
git describe --tags --match 'deploy/*' => fatal: No names found ; exit 128 <- only the reason NOT to use describe
|
||||
[ -f .claude/deploy/STATE ] => exit 0 (present = delta path)
|
||||
```
|
||||
|
||||
**Delta = `git diff --name-only <STATE_SHA> HEAD`** (two explicit endpoints; no dots, so it cannot be misread as three-dot).
|
||||
```
|
||||
LINEAR git diff --name-only <sha> HEAD => 0033_new.sql, svc.yml (== two-dot == three-dot; merge-base == STATE)
|
||||
DIVERGED two-dot sideA sideB => fileA.txt, fileB.txt (both endpoints = true tree delta)
|
||||
DIVERGED three-dot sideA...sideB => fileB.txt (merge-base — UNDERCOUNTS)
|
||||
```
|
||||
Two-dot/explicit-endpoints is the literal tree difference between the deployed tree and HEAD = what deploy needs. It is also rebase-robust: an orphaned marker still yields the correct tree diff, whereas `git rev-list A..B` (ancestry) reports phantom deltas after history rewrite (LRN-054's trap; verified in an earlier run). **Never use `rev-list` ancestry for the artifact list.**
|
||||
|
||||
**delta -> steps:** `# @delta:<kind>` annotations bind a dynamic step to the path-pattern that feeds it; the diff buckets straight into steps:
|
||||
```
|
||||
# @delta:migrations glob=supabase/migrations/*.sql
|
||||
# @delta:rebuild when=docker-compose*.yml,Dockerfile
|
||||
# @delta:deps when=package.json,*lock*
|
||||
```
|
||||
|
||||
## 5. Learning model — runbook + INCIDENTS, non-redundant
|
||||
|
||||
| Artifact | Job | Lifecycle |
|
||||
|---|---|---|
|
||||
| `PROCEDURE.md` | The corrected procedure you run. A fix is baked into the step so the next run cannot repeat it. | in-place |
|
||||
| `INCIDENTS.md` | The incident ledger; **read at BEFORE-time to pre-warn** ("0033 hit a lock timeout last deploy; runbook already carries `--timeout`, watch for it"). | append-only |
|
||||
|
||||
The pre-warn read is the function `git log` serves badly — that is why the ledger is not duplication. This mirrors the memory system's own split (append-only `journal.md`/`blockers.md` alongside in-place TODO/code).
|
||||
|
||||
**Coupling invariant:** one incident → **one in-place `PROCEDURE.md` patch + one `INCIDENTS.md` append, committed atomically in a single `deploy-commit.sh` call.** Never one without the other (mirrors BDR-034/036 "couple the commit to the integration step"). Significant patch (changes a prod path) → surface + approve before writing.
|
||||
|
||||
## 6. `lib/deploy-commit.sh` — new helper, inverse `.claude/` rule (verified)
|
||||
|
||||
Neither existing helper can commit the runbook — confirmed live:
|
||||
```
|
||||
REAL doc-commit.sh .claude/deploy/PROCEDURE.md => rc 4 "REFUSED — out-of-scope ... BDR-022 ... NOTHING committed"
|
||||
REAL memory-commit.sh pending (deploy changed) => rc 1 (ignores it; allowlist = .claude/memory|tasks only)
|
||||
```
|
||||
`doc-commit.sh` is built to keep `.claude/**` *out* of public-doc commits; `.claude/deploy/` is under `.claude/`, so reuse is not just blocked, it is semantically wrong. `deploy-commit.sh` needs the **inverse** rule: a TARGET allowlist for `.claude/deploy/*`, modeled on `memory-commit.sh` (rc 3 unsafe-git guard, short-hash on stdout, `chore(deploy):`/`docs(deploy):` messages).
|
||||
|
||||
Allowlist guard — traversal reject ordered FIRST. Prototype matrix verified live:
|
||||
```sh
|
||||
_in_deploy_scope() {
|
||||
case "$1" in
|
||||
*..*) return 1 ;; # reject path traversal FIRST
|
||||
.claude/deploy/*) return 0 ;; # ALLOW the deploy family only
|
||||
*) return 1 ;; # reject everything else
|
||||
esac
|
||||
}
|
||||
```
|
||||
```
|
||||
ALLOW .claude/deploy/{PROCEDURE.md,INCIDENTS.md,STATE}
|
||||
REJECT .claude/memory/* .claude/tasks/* .claude/secret CLAUDE.md src/*
|
||||
REJECT .claude/deploy (bare dir, no slash)
|
||||
REJECT .claude/deploy-other/x (trailing-slash requirement closes prefix confusion)
|
||||
REJECT .claude/deploy/../memory/secret (traversal closed by *..* matched first)
|
||||
```
|
||||
|
||||
## 7. Bootstrap
|
||||
|
||||
`STEP 0 PRE-FLIGHT`: `PROCEDURE.md` present? Absent → bootstrap, two offered paths:
|
||||
1. **Paste** — user supplies an existing runbook (the game example); skill adopts + annotates it.
|
||||
2. **Scaffold** — skill detects deploy artifacts (migrations dir, compose/Dockerfile, package scripts, `.env`) + a short interview (ssh target, backup cmd, rollback note) → writes an annotated `PROCEDURE.md`.
|
||||
|
||||
First deploy has no marker → STATE-absent ⇒ full runbook fires; then lay STATE at the deployed SHA. The first deploy *is* the creation of the runbook + the first marker.
|
||||
|
||||
## 8. Open items (for the implementation plan)
|
||||
|
||||
> `NEXT.sh` execution model resolved → decision #5 (checklist), promoted to design-time.
|
||||
|
||||
- Tag push: tags don't push by default → AFTER step should `git push --tag deploy/<date>` or remind.
|
||||
- `INCIDENTS.md` ID/format detail (mirror `blockers.md` `DEP-NNN`); confirm name vs `ERRORS-LEARNED.md`.
|
||||
- `@delta:` annotation grammar (glob= vs when=) — finalize the small DSL.
|
||||
- Frontmatter `allowed-tools` set; STEP gate wording reuse from `capitalize`/`client-handover`.
|
||||
|
||||
## 9. Build sequencing & a structural flag
|
||||
|
||||
**Two distinct disciplines, in order — do not conflate:**
|
||||
1. `writing-plans` — global task ordering (helper → skill → bootstrap), dependencies, gates. The build plan.
|
||||
2. → execution →
|
||||
3. At the *skill* task ONLY: `writing-skills` — the discipline for the SKILL.md itself (structure, frontmatter, spine, config conventions). Used WHEN we reach the skill task, **not before** (it does not fire at plan time).
|
||||
|
||||
**Structural flag for `writing-skills` to resolve — do NOT assume the linear-spine convention suffices:**
|
||||
deploy's spine is unusual — **two parts split by out-of-band execution**: STEP 0–2 before → *user deploys by hand* → STEP 4–5 after, on the `done`/`failed` report. A skill that **hands back control mid-run and resumes**.
|
||||
|
||||
Preliminary recon (confirm at the skill task — NOT verified now):
|
||||
- The 6 completion flux (close, ship-feature, feat, bugfix, hotfix, commit-change) appear linear one-shot — synchronous gates at most, no out-of-band hand-back.
|
||||
- The relevant precedent is OUTSIDE those 6: `client-handover` already hands back — a synchronous "Deploy done?" `AskUserQuestion` pause (STEP 5) — but it holds state in *conversation context*, not on disk.
|
||||
- deploy's genuinely-new bit *may* be **disk-bridged resume** (`NEXT.sh` + `STATE` on disk as the bridge) — but **whether `NEXT.sh` alone suffices to resume cross-session is an OPEN design question, not a settled answer** (see §10). An earlier draft of this spec framed it as resolved; it is not. `writing-skills` must establish the convention (how to mark "I wait for your return here", detect + resume a pending deploy, hold state across the gap) — confirm there, do not assume the linear mould suffices.
|
||||
|
||||
## 10. Open design question (DESIGN-TIME, unresolved) — state across the two moments
|
||||
|
||||
deploy is a **two-moment skill**: moments 0–2 (BEFORE) → user deploys out-of-band → moment 3 (AFTER) on the `done`/`failed` report. **The report may arrive in a different session.** So the design must answer how state crosses the gap and what moment 3 must know to resume correctly.
|
||||
|
||||
> **`skill deux-temps, état entre temps = [à concevoir : NEXT.sh seul suffit-il pour reprendre cross-session ?]`**
|
||||
|
||||
Sub-questions (to settle when we resume — NOT now, NOT assumed):
|
||||
- **What must the bridge record?** Moment 3 must (a) lay the correct marker = `STATE ← target sha`, and (b) capitalize the correct incident (which step, which delta). HEAD may have moved since NEXT.sh was generated → "current HEAD" is unsafe. The bridge must persist at least **{base STATE sha, target sha, delta manifest}** — inside NEXT.sh (header block) or a sidecar (`.claude/deploy/PENDING`)? Undecided.
|
||||
- **Resume detection (re-entrancy):** STEP 0 PRE-FLIGHT must detect "a deploy is pending, awaiting your report" — likely *pending-bridge present + STATE not advanced to target* — and branch RESUME (ask done/failed) vs FRESH. Is moment 3 a new `deploy` call that re-detects from disk, or a `deploy --report`? Undecided.
|
||||
- **Ephemeral vs persistent tension — LINKED to sub-question 1 (not independent).** §3 calls NEXT.sh "EPHEMERAL, not committed", yet a cross-session bridge MUST survive on disk. So: **if the bridge must persist, NEXT.sh-as-bridge is impossible while NEXT.sh stays ephemeral.** Likely *binary* resolution at plan time — either (a) NEXT.sh becomes persistent (contradicts §3), or (b) the bridge is a **separate** "deploy-in-progress" artifact `{base/target/delta}` distinct from NEXT.sh. Settle with `writing-skills`. (Uncommitted local state is fine; note the single-machine assumption — an uncommitted bridge won't follow a clone.)
|
||||
- **Form-novelty — deploy's DEFINING characteristic: cross-session COLD resume.** `client-handover` is a *near* precedent, not exact: it hands back **in-context** (same conversation, state held in memory). deploy must resume with the **context lost** — so the **disk alone must carry everything to resume cold**. No existing skill resumes without context; that is what sets deploy apart, and it makes sub-question 1 **load-bearing** (disk must suffice for a cold restart). deploy likely introduces a NEW skill form → `writing-skills` establishes the convention. Confirm there.
|
||||
|
||||
**Next step:** `writing-plans` to turn this spec into an implementation plan (helper first, then skill); at the skill task, `writing-skills` to shape it to convention and **resolve the §10 two-moment state question** — which is design-time, deferred only because we are stopped here, not because it is impl detail.
|
||||
Loading…
Reference in New Issue
Block a user