From b210e8d6a814345f14453d355f5e3ae2ed24edd0 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Sat, 27 Jun 2026 16:51:33 +0200 Subject: [PATCH] docs(deploy): design spec + implementation plan Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d --- docs/plans/2026-06-27-deploy-skill.md | 381 +++++++++++++++++++ docs/specs/2026-06-27-deploy-skill-design.md | 161 ++++++++ 2 files changed, 542 insertions(+) create mode 100644 docs/plans/2026-06-27-deploy-skill.md create mode 100644 docs/specs/2026-06-27-deploy-skill-design.md diff --git a/docs/plans/2026-06-27-deploy-skill.md b/docs/plans/2026-06-27-deploy-skill.md new file mode 100644 index 0000000..594a608 --- /dev/null +++ b/docs/plans/2026-06-27-deploy-skill.md @@ -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 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": "", "target_sha": "", + "delta": ["supabase/migrations/0033_x.sql", "docker-compose.yml"], + "step_reached": "awaiting-user", "started_at": "", "runbook_rev": "" } + ``` +- 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/ -m ""` 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/` — 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: glob=:each` — **repeat**: emit the step's command once per delta file matching `` (e.g. `psql -f `). +- `# @delta: glob=:list` — **checklist**: emit the command once, with matching files as `# VERIFY:` items (e.g. `supabase migration up`). +- `# @delta: when=[,...]` — **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 / edit / 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 ...` → exit 0 if any passed file in-scope has changes, else 1. `deploy-commit.sh commit "" ...` → 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 ..." >&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 ... | commit \"\" ..." >&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=:each repeats the command per matching file (e.g. psql -f ); +# glob=:list runs once + lists matching files as VERIFY items; when= 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 + + + +``` + +- [ ] **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": "", "deployed_at": "", "outcome": "ok", + "tag": "deploy/" } +``` +`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 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: " → 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 — recovered from " .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/ -m ""`; **if `@config push_deploy_tags=true`** then `git push origin deploy/` (best-effort, non-fatal). `bash lib/deploy-commit.sh commit "chore(deploy): mark @ " .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 ` 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 + ` 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 "" ...` (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)**. diff --git a/docs/specs/2026-06-27-deploy-skill-design.md b/docs/specs/2026-06-27-deploy-skill-design.md new file mode 100644 index 0000000..741cae2 --- /dev/null +++ b/docs/specs/2026-06-27-deploy-skill-design.md @@ -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 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: ") + 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 HEAD`** (two explicit endpoints; no dots, so it cannot be misread as three-dot). +``` +LINEAR git diff --name-only 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:` 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/` 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.