Compare commits
10 Commits
cd375dd74a
...
135b4872a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135b4872a8 | ||
|
|
79741e36e7 | ||
|
|
91850eb63a | ||
|
|
0ed074f4bb | ||
|
|
fdc248ded5 | ||
|
|
a10635aa36 | ||
|
|
3f11b61ab0 | ||
|
|
1c5c85e422 | ||
|
|
24e6b84add | ||
|
|
b210e8d6a8 |
@ -48,6 +48,7 @@ rules:
|
|||||||
| BDR-035 | 2026-06-26 | Analyze-before-plan invariant v1 — read-before bookend of coupled-capitalize | accepted |
|
| BDR-035 | 2026-06-26 | Analyze-before-plan invariant v1 — read-before bookend of coupled-capitalize | accepted |
|
||||||
| BDR-036 | 2026-06-27 | Doc-sync coupled invariant — commit docs doc-syncer patches (twin of BDR-034, BUILT not reordered) | accepted |
|
| BDR-036 | 2026-06-27 | Doc-sync coupled invariant — commit docs doc-syncer patches (twin of BDR-034, BUILT not reordered) | accepted |
|
||||||
| BDR-037 | 2026-06-27 | v2 capitalize Stop-hook rejected → wire /capitalize+/close to the include | accepted |
|
| BDR-037 | 2026-06-27 | v2 capitalize Stop-hook rejected → wire /capitalize+/close to the include | accepted |
|
||||||
|
| BDR-038 | 2026-06-27 | deploy skill: per-project learning runbook, two-moment cold-resume | accepted |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -595,3 +596,11 @@ rules:
|
|||||||
- (b) SessionEnd auto-commit (FAIT) — bypasses STEP 3 content gate, embarks half-written entries, can't report actionably.
|
- (b) SessionEnd auto-commit (FAIT) — bypasses STEP 3 content gate, embarks half-written entries, can't report actionably.
|
||||||
- (c) abandon with no redirect — leaves the real wiring gap open; fix for an unwired skill = wire it.
|
- (c) abandon with no redirect — leaves the real wiring gap open; fix for an unwired skill = wire it.
|
||||||
- **Reference**: read-phase analysis (no hook code ever written); wiring commit (capitalize STEP 5B) follows. Completes [[BDR-034]] rollout; applies [[BDR-033]] doctrine to REJECT (not all nudges — the determinism split is [[LRN-061]]). See [[LRN-061]], [[LRN-047]], [[LRN-049]], [[LRN-054]].
|
- **Reference**: read-phase analysis (no hook code ever written); wiring commit (capitalize STEP 5B) follows. Completes [[BDR-034]] rollout; applies [[BDR-033]] doctrine to REJECT (not all nudges — the determinism split is [[LRN-061]]). See [[LRN-061]], [[LRN-047]], [[LRN-049]], [[LRN-054]].
|
||||||
|
|
||||||
|
## BDR-038 — deploy skill: per-project learning runbook, two-moment cold-resume
|
||||||
|
- **date**: 2026-06-27
|
||||||
|
- **status**: accepted
|
||||||
|
- **decision**: New `/deploy` skill = per-project runbook in `.claude/deploy/`. 5 artifacts: `PROCEDURE.md` (runbook, in-place edits), `INCIDENTS.md` (`DEP-NNN` ledger, append-only), `STATE.json` (deploy oracle, committed), `PENDING.json` (cold-resume bridge, gitignored), `NEXT.sh` (instantiated checklist, gitignored, hand-run). Two-moment spine: BEFORE (delta-instantiate `NEXT.sh` + hand back) → user deploys OUT-OF-BAND → AFTER (react: MARK success or LEARN from failure). Cold cross-session resume via `PENDING.json` (disk = only memory across the gap). Learn = atomic patch+incident (one `deploy-commit.sh` call, both files). New helper `lib/deploy-commit.sh` (allowlist `.claude/deploy/`). Built via subagent-driven-development (4 tasks).
|
||||||
|
- **why**: deployment memory that LEARNS (runbook patched in place per failure) beats a frozen runbook; disk-bridge so a resume survives session loss.
|
||||||
|
- **alternatives**: tag-oracle (rejected — lightweight-tag date unreliable, rebase-fragile, [[LRN-063]]); separate append-only ERRORS log (rejected — git history of `PROCEDURE.md`+`INCIDENTS.md` suffices, no `resolved-by` field); `NEXT.sh`-as-bridge (rejected — ephemeral ≠ persistent → separate `PENDING.json`); reuse doc/memory-commit (rejected — neither can commit `.claude/deploy/`, [[LRN-064]]).
|
||||||
|
- **reference**: `skills/deploy/SKILL.md`, `lib/deploy-commit.sh`, `templates/deploy/`; branch `feat/deploy-skill` (b210e8d..79741e3, kept un-merged); spec `docs/specs/2026-06-27-deploy-skill-design.md`, plan `docs/plans/2026-06-27-deploy-skill.md`.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ rules:
|
|||||||
| EVAL-006 | 2026-06-25 | prune-memory v1.1 TDD — 6 guards (0a3e766), validated on real data | keep |
|
| EVAL-006 | 2026-06-25 | prune-memory v1.1 TDD — 6 guards (0a3e766), validated on real data | keep |
|
||||||
| EVAL-007 | 2026-06-26 | Coupled-capitalize machinery — TDD 13 + e2e, surgical scope proven | keep |
|
| EVAL-007 | 2026-06-26 | Coupled-capitalize machinery — TDD 13 + e2e, surgical scope proven | keep |
|
||||||
| EVAL-008 | 2026-06-27 | Doc-sync coupled machinery — 28/28 real-exec, swap-sweep caught prior debt | keep |
|
| EVAL-008 | 2026-06-27 | Doc-sync coupled machinery — 28/28 real-exec, swap-sweep caught prior debt | keep |
|
||||||
|
| EVAL-009 | 2026-06-27 | deploy skill subagent-driven build: multi-stage review + pressure-test net-positive | keep |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -106,3 +107,9 @@ rules:
|
|||||||
- **Output**: 6 surgical commits `ae1f218` · `4a54a65` · `fb1f359` · `636b491` · `e81f629` · `1b01b95`. Caught + fixed a PRIOR-chantier latent ref bug (README:153, stale since e8eff7e's swap). Scope expanded mid-chantier (sweep found the inline-flow gap → 3 flows wired).
|
- **Output**: 6 surgical commits `ae1f218` · `4a54a65` · `fb1f359` · `636b491` · `e81f629` · `1b01b95`. Caught + fixed a PRIOR-chantier latent ref bug (README:153, stale since e8eff7e's swap). Scope expanded mid-chantier (sweep found the inline-flow gap → 3 flows wired).
|
||||||
- **Anomalies**: (1) the deferred note ("reorder only") was WRONG → corrected in read-phase before any code ([[LRN-058]]). (2) init-project PARTIAL — [[BLK-010]]/[[BLK-011]] deferred = NEW work, surfaced not papered over. Both engraved in [[BDR-036]].
|
- **Anomalies**: (1) the deferred note ("reorder only") was WRONG → corrected in read-phase before any code ([[LRN-058]]). (2) init-project PARTIAL — [[BLK-010]]/[[BLK-011]] deferred = NEW work, surfaced not papered over. Both engraved in [[BDR-036]].
|
||||||
- **Action**: keep. BLK-010 (scaffold/unborn-HEAD) + BLK-011 (GSD ROADMAP post-FINISH) + MINOR-gate strengthening = separate chantiers.
|
- **Action**: keep. BLK-010 (scaffold/unborn-HEAD) + BLK-011 (GSD ROADMAP post-FINISH) + MINOR-gate strengthening = separate chantiers.
|
||||||
|
|
||||||
|
## EVAL-009 — deploy skill subagent-driven build: multi-stage review + pressure-test net-positive
|
||||||
|
- **output**: `/deploy` skill (helper + SKILL.md + templates + bootstrap), built via subagent-driven-development (4 tasks; fresh implementer + per-task spec+quality review each).
|
||||||
|
- **method**: per-task review (sonnet; opus on the keystone) + writing-skills pressure-test (fresh agent on a `PENDING.json`+moved-HEAD fixture) + final whole-branch review (opus).
|
||||||
|
- **anomalies**: (1) the PLAN's code carried 3 latent bugs — missing `git add` for new files, SC2086 unquoted `$viol`, comment-before-shebang SC1128 — all caught by the implementer's TDD+shellcheck gate → plan-code is a DRAFT, the test gate is load-bearing. (2) the final whole-branch review caught 2 Important seam-bugs INVISIBLE to per-task reviews: target-repo `.claude/`-ignored silent no-op ([[LRN-066]]) + `NEXT.sh`-absence non-regeneration → holistic review earns its keep. (3) pressure-test confirmed the cold-resume discipline holds under temptation (the agent excluded the moved-HEAD `0034`). (4) a reviewer subagent bugged out once (user killed it) → re-dispatched clean (transient, not a finding).
|
||||||
|
- **action**: keep. Multi-stage adversarial review + a behavioral pressure-test caught classes of bug single-pass review misses — worth the cost on a keystone skill.
|
||||||
|
|||||||
@ -212,3 +212,4 @@ rules:
|
|||||||
- Doc-sync coupled invariant (twin of BDR-034, BUILT not reordered): new `lib/doc-commit.sh` (inverse-scope surgical, fail-closed exit 4 on `.claude/`) + `lib/doc-commit.md` include; doc-syncer emits `PATCHED_FILES` (one path/line) → agent → distinct argv (space-safe). 2 orchestrators reordered DOC SYNC before FINISH (ship-feature 9→8, init-project 12→10c, GSD 13→12), 3 inline flows wired (feat/bugfix/hotfix). 6 commits `ae1f218` · `4a54a65` · `fb1f359` · `636b491` · `e81f629` · `1b01b95`. 28/28 real-exec, shellcheck clean. BDR-036, LRN-058/059/060, EVAL-008.
|
- Doc-sync coupled invariant (twin of BDR-034, BUILT not reordered): new `lib/doc-commit.sh` (inverse-scope surgical, fail-closed exit 4 on `.claude/`) + `lib/doc-commit.md` include; doc-syncer emits `PATCHED_FILES` (one path/line) → agent → distinct argv (space-safe). 2 orchestrators reordered DOC SYNC before FINISH (ship-feature 9→8, init-project 12→10c, GSD 13→12), 3 inline flows wired (feat/bugfix/hotfix). 6 commits `ae1f218` · `4a54a65` · `fb1f359` · `636b491` · `e81f629` · `1b01b95`. 28/28 real-exec, shellcheck clean. BDR-036, LRN-058/059/060, EVAL-008.
|
||||||
- Sweep caught PRIOR-chantier debt (README:153 stale since e8eff7e's swap) + expanded scope to 3 inline flows (asymmetry vs memory was decider). Swap flips meanings ≠ letter-insertion (LRN-059). Deferred note "reorder only" refuted in read-phase — doc-syncer commits nothing (LRN-058). BLK-010 (scaffold/unborn HEAD + worktree) + BLK-011 (GSD ROADMAP post-FINISH) deferred = new work.
|
- Sweep caught PRIOR-chantier debt (README:153 stale since e8eff7e's swap) + expanded scope to 3 inline flows (asymmetry vs memory was decider). Swap flips meanings ≠ letter-insertion (LRN-059). Deferred note "reorder only" refuted in read-phase — doc-syncer commits nothing (LRN-058). BLK-010 (scaffold/unborn HEAD + worktree) + BLK-011 (GSD ROADMAP post-FINISH) deferred = new work.
|
||||||
- v2 capitalize Stop-hook REJECTED on facts: `Stop`=per-turn (self-defeat, nags mid-flush, LRN-047), `SessionEnd`=debug-log-only (can't nag) + gate-bypass. Real gap = OUBLI de câblage: `/capitalize`+`/close` never call `capitalize-commit.md` (predate it 7-60d; wiring commits never touched them; commit done by hand 35×, orphans self-heal). Redirect = wire the include (STEP 5B); `/close` alias follows. BDR-037 + LRN-061 (capstone: runtime net for an unwired skill → check wiring first; deterministic gap = fix structurally, non-det aléa = net OK cf BDR-033). Next: câblage + dogfood (5B commits future capitalizations).
|
- v2 capitalize Stop-hook REJECTED on facts: `Stop`=per-turn (self-defeat, nags mid-flush, LRN-047), `SessionEnd`=debug-log-only (can't nag) + gate-bypass. Real gap = OUBLI de câblage: `/capitalize`+`/close` never call `capitalize-commit.md` (predate it 7-60d; wiring commits never touched them; commit done by hand 35×, orphans self-heal). Redirect = wire the include (STEP 5B); `/close` alias follows. BDR-037 + LRN-061 (capstone: runtime net for an unwired skill → check wiring first; deterministic gap = fix structurally, non-det aléa = net OK cf BDR-033). Next: câblage + dogfood (5B commits future capitalizations).
|
||||||
|
- /deploy skill built (subagent-driven, 4 tasks + opus keystone review + pressure-test + final whole-branch review). 5 artifacts (.claude/deploy/), two-moment cold-resume via PENDING.json, atomic learn coupling, new lib/deploy-commit.sh (allowlist .claude/deploy/). Branch feat/deploy-skill (b210e8d..79741e3, kept un-merged). BDR-038 + LRN-062..066 + EVAL-009 capitalized; TODO unchanged.
|
||||||
|
|||||||
@ -60,6 +60,11 @@ rules:
|
|||||||
| LRN-059 | 2026-06-27 | Step-number SWAP flips meanings (sweep refs) ≠ letter-suffix insertion (shifts nothing) | any pipeline renumber |
|
| LRN-059 | 2026-06-27 | Step-number SWAP flips meanings (sweep refs) ≠ letter-suffix insertion (shifts nothing) | any pipeline renumber |
|
||||||
| LRN-060 | 2026-06-27 | Fail-closed guard proven by what it REFUSES (loudly); pass dynamic lists as argv not separator-string | automated scoped-commit / destructive guards |
|
| LRN-060 | 2026-06-27 | Fail-closed guard proven by what it REFUSES (loudly); pass dynamic lists as argv not separator-string | automated scoped-commit / destructive guards |
|
||||||
| LRN-061 | 2026-06-27 | Runtime net for an unwired skill → check the wiring first (deterministic gap = fix structurally; non-det aléa = net OK, cf BDR-033) | "build a hook/watcher to catch when X isn't done" |
|
| LRN-061 | 2026-06-27 | Runtime net for an unwired skill → check the wiring first (deterministic gap = fix structurally; non-det aléa = net OK, cf BDR-033) | "build a hook/watcher to catch when X isn't done" |
|
||||||
|
| LRN-062 | 2026-06-27 | deploy first-run detection = file-existence, never `git describe` | any "first run vs incremental" tool — detect by explicit on-disk marker |
|
||||||
|
| LRN-063 | 2026-06-27 | delta-since-marker = `git diff --name-only X HEAD` (two endpoints), never rev-list/three-dot | any delta-since-checkpoint over git — explicit two endpoints for tree diff |
|
||||||
|
| LRN-064 | 2026-06-27 | surgical-commit helper family partitions `.claude/`; new subtree needs own allowlist sibling | adding a committable `.claude/X` subtree |
|
||||||
|
| LRN-065 | 2026-06-27 | cross-session cold-resume skill = disk-bridge read-first (audit-delta convention) | any "do work → user acts out-of-band → resume later" skill |
|
||||||
|
| LRN-066 | 2026-06-27 | surgical-commit must fail LOUD on git-ignored target paths (else silent no-op) | any helper relying on `git status --porcelain` to detect changes |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -742,3 +747,26 @@ rules:
|
|||||||
- **Context**: deferred "v2 capitalize hook" ([[BDR-037]]). Read-phase killed it before code: git proved skills predate the include (oubli), memory already committed by hand 35×, orphans self-heal via `commit_memory`. The hook would've been disabled within an hour (frequent ignored nag).
|
- **Context**: deferred "v2 capitalize hook" ([[BDR-037]]). Read-phase killed it before code: git proved skills predate the include (oubli), memory already committed by hand 35×, orphans self-heal via `commit_memory`. The hook would've been disabled within an hour (frequent ignored nag).
|
||||||
- **Future application**: any "build a hook/watcher/lint to catch when X isn't done" — first grep whether X is even WIRED at its source. Deterministic/structural gap (missing include/call) → fix structurally; reserve runtime nets for non-deterministic lapses, never to complete a rollout. Classify by determinism BEFORE building.
|
- **Future application**: any "build a hook/watcher/lint to catch when X isn't done" — first grep whether X is even WIRED at its source. Deterministic/structural gap (missing include/call) → fix structurally; reserve runtime nets for non-deterministic lapses, never to complete a rollout. Classify by determinism BEFORE building.
|
||||||
- **Reference**: [[BDR-037]], [[BDR-034]] (rollout this completes), [[BDR-033]] (the GOOD net — contrast). Conditions [[LRN-047]], [[LRN-049]], [[LRN-054]].
|
- **Reference**: [[BDR-037]], [[BDR-034]] (rollout this completes), [[BDR-033]] (the GOOD net — contrast). Conditions [[LRN-047]], [[LRN-049]], [[LRN-054]].
|
||||||
|
|
||||||
|
## LRN-062 — deploy first-run detection = file-existence, never `git describe`
|
||||||
|
- **pattern**: detect "first deploy / no prior marker" by `[ -f .claude/deploy/STATE.json ]` (deterministic). NEVER `git describe --tags --match 'deploy/*'` — it errors `fatal: No names found, cannot describe anything`, exit 128, when no matching tag exists (verified git 2.53). Oracle = committed `STATE.json` holding `deployed_sha` (external ledger; never infer from context — [[LRN-054]]).
|
||||||
|
- **context**: deploy skill design. The describe-128 result is only the REASON NOT to use describe — never the detection path.
|
||||||
|
- **future application**: any "first run vs incremental" tool — detect by an explicit on-disk marker's existence, not by a git query that errors on the empty case.
|
||||||
|
|
||||||
|
## LRN-063 — delta-since-marker = `git diff --name-only <base> HEAD` (two endpoints), never rev-list/three-dot
|
||||||
|
- **pattern**: "files changed since marker X" = `git diff --name-only <X_sha> HEAD` — two explicit endpoints = literal tree diff. NEVER `git rev-list X..HEAD` (ancestry → phantom deltas after rebase: an orphaned marker yields the whole history). NEVER three-dot `X...HEAD` (merge-base → UNDERCOUNTS on divergence). Verified git 2.53 (linear: all forms agree; diverged: two-dot = both sides, three-dot = one side only).
|
||||||
|
- **context**: deploy delta mechanism. Footgun: `git diff A..B` ≡ `git diff A B` (two endpoints), but `rev-list A..B` = ancestry — same `..` token, different meaning per command.
|
||||||
|
- **future application**: any delta-since-checkpoint over git — explicit two endpoints for the tree diff (artifact list); reserve `rev-list` for commit-counting only.
|
||||||
|
|
||||||
|
## LRN-064 — surgical-commit helper family partitions `.claude/`; a new subtree needs its own allowlist sibling
|
||||||
|
- **pattern**: the surgical-commit helpers each own a `.claude/` partition by OPPOSITE rules — `memory-commit.sh` ALLOWLISTS `.claude/memory`+`.claude/tasks`; `doc-commit.sh` EXCLUDES all `.claude/**` (loud rc 4, BDR-022 — [[LRN-060]], [[BDR-036]]). So committing a NEW `.claude/` subtree (e.g. `.claude/deploy/`) can reuse NEITHER: doc-commit refuses it, memory-commit ignores it. Verified live: real `doc-commit.sh` → rc 4 on `.claude/deploy/PROCEDURE.md`. Solution: mint a sibling (`deploy-commit.sh`) with a TARGET allowlist for the new subtree — guard order = traversal `*..*` reject FIRST, then `.claude/deploy/*` allow, else refuse. Inherit rc 3 unsafe-git, short-hash stdout, changed-paths filter.
|
||||||
|
- **future application**: adding a committable `.claude/X` subtree → new allowlist sibling, don't bend an existing helper; order the path guard traversal-first.
|
||||||
|
|
||||||
|
## LRN-065 — cross-session cold-resume skill = disk-bridge read-first (audit-delta convention)
|
||||||
|
- **pattern**: a skill that hands BACK control mid-flow (user acts out-of-band) and RESUMES — possibly in a NEW session, context gone — must carry ALL resume state on disk. A bridge file's PRESENCE = the wait-marker ("in flight, awaiting report"); STEP 0 reads it FIRST and resumes from its captured `{base, target, delta, step_reached}` WITHOUT recomputing (HEAD may have moved during the gap → "current HEAD" is wrong). Convention = audit-delta "the state file is the only memory between runs", extended from run-to-run to a MID-FLOW pause. `client-handover` only pauses in-context (synchronous), NOT cold — deploy is the first cold-resume form. A `runbook_rev` (FULL sha) does double duty: in-flow regenerate trigger + cold-resume staleness check; regenerate the instantiated artifact if ABSENT or stale. Pressure-test confirmed (fresh agent resumed from the bridge, excluded the moved-HEAD temptation).
|
||||||
|
- **future application**: any "do work → user acts out-of-band → resume later" skill — persist a disk bridge, read it first, never recompute on resume; mark the wait by the bridge's existence, not by conversation context.
|
||||||
|
|
||||||
|
## LRN-066 — surgical-commit must fail LOUD on git-ignored target paths (else silent no-op)
|
||||||
|
- **pattern**: `git status --porcelain -- <path>` HIDES git-ignored paths → a surgical-commit helper that filters changes via porcelain SILENTLY no-ops (rc 1) when the target project ignores the path (e.g. `.claude/` wholesale) → the artifact never persists, the skill silently forgets. Fix: guard with `git check-ignore -q <path>` BEFORE the changed-filter; any passed path ignored → LOUD refusal + dedicated rc (5), never a silent no-op. Fail-closed/loud over silent. (Same porcelain mechanism as the changed-filter — [[LRN-051]].)
|
||||||
|
- **context**: `deploy-commit.sh`; the FINAL whole-branch review caught it (per-task reviews could not — it is a skill↔target-repo seam). Applies to the whole memory/doc/deploy-commit family.
|
||||||
|
- **future application**: any helper relying on `git status --porcelain` to detect changes — add a `git check-ignore` guard; a path that must persist but is ignored has to fail loud, not no-op.
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -132,3 +132,7 @@ skills-external/frontend-design/
|
|||||||
# pollution dir is ignored.
|
# pollution dir is ignored.
|
||||||
/.agents/
|
/.agents/
|
||||||
/skills-lock.json
|
/skills-lock.json
|
||||||
|
|
||||||
|
# deploy: transient per-deploy state (the runbook/ledger/oracle ARE committed)
|
||||||
|
.claude/deploy/NEXT.sh
|
||||||
|
.claude/deploy/PENDING.json
|
||||||
|
|||||||
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. Resolution derivable from git: the commit that adds the entry IS the fix (atomic patch+incident); recover via `git log -S 'DEP-NNN' -- .claude/deploy/INCIDENTS.md`. 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=12 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. -->
|
||||||
|
<!-- Resolution = the commit that adds this entry (atomic patch+incident). Recover: git log -S 'DEP-NNN' -- .claude/deploy/INCIDENTS.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> -->
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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`. The commit that adds `DEP-NNN` IS its resolution (derive via git later). 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 (APPEND-ONLY; resolution = introducing commit, derive via git)
|
||||||
|
STATE.json 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.json ] => 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.json ] => 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.
|
||||||
73
lib/deploy-commit.sh
Normal file
73
lib/deploy-commit.sh
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#!/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 -uo pipefail
|
||||||
|
|
||||||
|
_in_git_repo() { git rev-parse --git-dir >/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; }
|
||||||
|
|
||||||
|
_ignored() { git check-ignore -q "$1"; } # rc 0 = ignored
|
||||||
|
|
||||||
|
_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; }
|
||||||
|
mapfile -t violations < <(_scope_violations "$@")
|
||||||
|
if [ "${#violations[@]}" -gt 0 ]; then
|
||||||
|
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||||
|
printf ' - %s\n' "${violations[@]}";
|
||||||
|
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
[ -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; }
|
||||||
|
mapfile -t violations < <(_scope_violations "$@")
|
||||||
|
if [ "${#violations[@]}" -gt 0 ]; then
|
||||||
|
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||||
|
printf ' - %s\n' "${violations[@]}";
|
||||||
|
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
mapfile -t ignored_paths < <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
|
||||||
|
if [ "${#ignored_paths[@]}" -gt 0 ]; then
|
||||||
|
{ echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:";
|
||||||
|
printf ' - %s\n' "${ignored_paths[@]}"; } >&2
|
||||||
|
exit 5
|
||||||
|
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 add -- "${changed[@]}"
|
||||||
|
if git diff --cached --quiet -- "${changed[@]}"; then
|
||||||
|
echo "deploy-commit: nothing staged — no-op" >&2; exit 1
|
||||||
|
fi
|
||||||
|
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
|
||||||
53
lib/tests/deploy-commit.test.sh
Normal file
53
lib/tests/deploy-commit.test.sh
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/tests/deploy-commit.test.sh
|
||||||
|
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
|
||||||
|
|
||||||
|
d=$(mkrepo); printf 'b\n' >"$d/src.txt"
|
||||||
|
( cd "$d" && bash "$H" pending src.txt ) >/dev/null 2>&1; check T8-pending-out-of-scope-rc "$?" 4
|
||||||
|
|
||||||
|
d=$(mkrepo); printf '.claude/\n' >"$d/.gitignore"
|
||||||
|
printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||||
|
( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1
|
||||||
|
check T9-ignored-rc "$?" 5
|
||||||
|
|
||||||
|
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
|
||||||
437
skills/deploy/SKILL.md
Normal file
437
skills/deploy/SKILL.md
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
---
|
||||||
|
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]
|
||||||
|
---
|
||||||
|
|
||||||
|
# /deploy — per-project runbook, instantiated from the delta, resumed cold
|
||||||
|
|
||||||
|
Run a project's deploy from its committed runbook (`.claude/deploy/PROCEDURE.md`):
|
||||||
|
instantiate only the steps the delta-since-last-deploy needs, hand the checklist
|
||||||
|
to the user for **out-of-band** execution, then **resume on their report — even
|
||||||
|
in a different session with no conversation memory** — and patch the runbook in
|
||||||
|
place when a step fails.
|
||||||
|
|
||||||
|
**Core principle — the disk is the only memory between the two moments.** This
|
||||||
|
skill runs in two moments split by a manual deploy you do not control. The report
|
||||||
|
that closes it may land in a fresh session. So everything moment 3 needs lives on
|
||||||
|
disk in `.claude/deploy/`, never in conversation context. Never reconstruct the
|
||||||
|
deploy from memory, commit messages, or `git describe`.
|
||||||
|
|
||||||
|
**Claude never runs the deploy.** Prod commands run by hand, out-of-band. This
|
||||||
|
skill only writes the checklist (`NEXT.sh`), reacts to the user's report, and
|
||||||
|
records the outcome.
|
||||||
|
|
||||||
|
## The two-moment contract — cold cross-session resume
|
||||||
|
|
||||||
|
This is the skill's defining form. No other skill resumes with the context gone.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Moment 1 (BEFORE)** | STEP 0–2: detect the delta, instantiate `NEXT.sh`, write the `PENDING.json` bridge, hand back. |
|
||||||
|
| **the gap** | The user deploys by hand. May take minutes or days. **May cross sessions.** |
|
||||||
|
| **Moment 2 (AFTER)** | STEP 3–5: on the user's report, react — mark success, or learn from a failure and re-hand-back. |
|
||||||
|
|
||||||
|
**How the wait is marked:** a `PENDING.json` on disk with `step_reached`
|
||||||
|
recorded **IS** the marker "a deploy is in flight, I am waiting for your report
|
||||||
|
here." Its presence is the whole signal — no flag in memory, no open question in
|
||||||
|
context. `STATE.json` is the deployed-up-to-here oracle; `PENDING.json` is the
|
||||||
|
in-flight bridge that outlives the session.
|
||||||
|
|
||||||
|
**How a cold resume detects + resumes (STEP 0):** every invocation reads
|
||||||
|
`PENDING.json` FIRST. Present ⇒ a deploy is mid-flight ⇒ jump straight to STEP 3
|
||||||
|
using the bridge's `{base_sha, target_sha, delta, step_reached}`. **Do not
|
||||||
|
recompute any of them** — HEAD may have moved during the gap, so "current HEAD"
|
||||||
|
is wrong; the bridge holds the truth captured at instantiation.
|
||||||
|
|
||||||
|
**Read the JSON natively.** Open `PENDING.json` / `STATE.json` with the Read
|
||||||
|
tool and parse the fields directly. NO `jq`, NO shell JSON parsing — there is no
|
||||||
|
jq dependency.
|
||||||
|
|
||||||
|
## When to use / When NOT to use
|
||||||
|
|
||||||
|
| Situation | Skill |
|
||||||
|
|-----------|-------|
|
||||||
|
| Run this project's deploy runbook, delta-instantiated, learning | **this skill** |
|
||||||
|
| Project has no `.claude/deploy/PROCEDURE.md` yet | this skill's **bootstrap** branch (see STEP 0) |
|
||||||
|
| Merge a branch + trigger CI deploy (gstack) | `/land-and-deploy` |
|
||||||
|
| Configure deployment settings | `/setup-deploy` |
|
||||||
|
| Document a release after shipping | `/document-release`, `/doc` |
|
||||||
|
|
||||||
|
## Artifacts — `.claude/deploy/` (five files)
|
||||||
|
|
||||||
|
| File | Committed? | Role |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `PROCEDURE.md` | yes | reference runbook — fixed shell + `# @delta:` steps; edited IN PLACE |
|
||||||
|
| `INCIDENTS.md` | yes | `DEP-NNN` ledger, append-only; read at instantiation for pre-warns |
|
||||||
|
| `STATE.json` | yes | deploy oracle — the SHA deployed up to here |
|
||||||
|
| `PENDING.json` | **no (gitignored)** | in-flight bridge; written at hand-back, deleted on success |
|
||||||
|
| `NEXT.sh` | **no (gitignored)** | instantiated checklist; run BY HAND, never `bash NEXT.sh` |
|
||||||
|
|
||||||
|
**Schemas (document of record — recover the shapes from here):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// STATE.json — overwritten each successful deploy (the diff oracle)
|
||||||
|
{ "deployed_sha": "<sha>", "deployed_at": "<ISO-8601>", "outcome": "ok", "tag": "deploy/<YYYY-MM-DD>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// PENDING.json — the cold-resume bridge; gitignored; deleted on success
|
||||||
|
{ "base_sha": "<deployed STATE sha>", "target_sha": "<HEAD at instantiation>",
|
||||||
|
"delta": ["<path>", ...], "step_reached": "awaiting-user", "started_at": "<ISO-8601>",
|
||||||
|
"runbook_rev": "<PROCEDURE.md commit sha>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`step_reached` = where the next `NEXT.sh` must start: `"awaiting-user"` = run from
|
||||||
|
the top. A numeric `X` is used **transiently within a learn** to regenerate from
|
||||||
|
step X; **persisted on disk it is always `"awaiting-user"`** — STEP 4 resets to
|
||||||
|
`awaiting-user` at re-hand-back, and the `runbook_rev` staleness guard is the real
|
||||||
|
cold-resume regenerate trigger.
|
||||||
|
`runbook_rev` = the commit sha of `PROCEDURE.md` at instantiation; a mismatch
|
||||||
|
versus the live runbook means `NEXT.sh` is stale and must be regenerated.
|
||||||
|
|
||||||
|
## `@delta:` grammar (PROCEDURE.md)
|
||||||
|
|
||||||
|
A directive sits on the comment line **above** the step it governs; patterns are
|
||||||
|
matched against the delta file list. Un-annotated step = **fixed**, always
|
||||||
|
emitted verbatim.
|
||||||
|
|
||||||
|
| Directive | Meaning | Instantiation |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `# @delta:<kind> glob=<pat>:each` | per-file command | repeat the command once **per** matching delta file (file substituted in) |
|
||||||
|
| `# @delta:<kind> glob=<pat>:list` | one command, many inputs | emit the command **once**; list matching files as `# VERIFY:` items |
|
||||||
|
| `# @delta:<kind> when=<pat,...>` | conditional | include the step **only if** the delta intersects a pattern |
|
||||||
|
|
||||||
|
`<kind>` is a human label. `<pat>` is a git-pathspec / shell glob; `when=`
|
||||||
|
comma-separates alternatives. Zero matches → omit that step. Both `:each` and
|
||||||
|
`:list` are first-class (e.g. apply each new migration with its own command vs.
|
||||||
|
one `migration up` that lists which migrations to verify).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 0 — PRE-FLIGHT + RESUME BRANCH
|
||||||
|
|
||||||
|
Read `.claude/deploy/PENDING.json` **first** (it is the only memory between runs).
|
||||||
|
|
||||||
|
- **`PENDING.json` present → RESUME.** A deploy is in flight. Parse its
|
||||||
|
`{base_sha, target_sha, delta, step_reached}` and jump to **STEP 3**. Announce:
|
||||||
|
"A deploy started `<started_at>` is awaiting your report (target `<target_sha>`)."
|
||||||
|
**Do not** recompute the delta, re-read HEAD, or re-instantiate from scratch —
|
||||||
|
the bridge is authoritative.
|
||||||
|
- *Staleness guard:* if `NEXT.sh` is absent **OR** `runbook_rev` ≠ the live
|
||||||
|
runbook commit (`git log -1 --format=%H -- .claude/deploy/PROCEDURE.md`),
|
||||||
|
the on-disk `NEXT.sh` is stale or missing (a patch landed, or a cold
|
||||||
|
resume without regeneration) — regenerate it from `step_reached`
|
||||||
|
(STEP 2's expansion) before reacting.
|
||||||
|
- **`PENDING.json` absent + `PROCEDURE.md` absent → BOOTSTRAP.** No runbook yet:
|
||||||
|
interview the project and scaffold an annotated `PROCEDURE.md` (or adopt one
|
||||||
|
the user pastes), then continue at STEP 1. *(See STEP 0-B below.)*
|
||||||
|
- **`PENDING.json` absent + `PROCEDURE.md` present → FRESH.** Continue to STEP 1.
|
||||||
|
|
||||||
|
First-deploy / fresh detection is **file existence only**. Never `git describe`
|
||||||
|
(it errors when no `deploy/*` tag exists and is not the detection path).
|
||||||
|
|
||||||
|
## STEP 0-B — BOOTSTRAP (no runbook yet)
|
||||||
|
|
||||||
|
Entered from STEP 0 when both `PENDING.json` and `PROCEDURE.md` are absent.
|
||||||
|
Author a runbook, seed the incident ledger, commit both, then proceed to STEP 1.
|
||||||
|
|
||||||
|
**AskUserQuestion — choose path:**
|
||||||
|
|
||||||
|
> "No runbook found in `.claude/deploy/PROCEDURE.md`. How do you want to create it?
|
||||||
|
>
|
||||||
|
> **A — Paste:** share an existing runbook (paste text, file path, or URL). I adopt
|
||||||
|
> it verbatim and propose `@delta:` annotations for migration, build, and dep steps.
|
||||||
|
>
|
||||||
|
> **B — Scaffold:** I detect deploy artifacts in this repo, ask a few questions, and
|
||||||
|
> fill the standard template."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Path A — Paste (adopt existing runbook)
|
||||||
|
|
||||||
|
1. Receive the runbook (paste, path → Read, or URL). Accept as-is.
|
||||||
|
2. Prepend the standard header:
|
||||||
|
```
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# === deploy runbook (reference) — NOT run directly. Instantiated to NEXT.sh per delta. ===
|
||||||
|
# Fixed steps run every deploy; annotated steps (@delta lines) re-instantiate from the delta.
|
||||||
|
# @config push_deploy_tags=false
|
||||||
|
```
|
||||||
|
3. Scan for migration, rebuild, and dependency steps; propose `@delta:` annotations inline:
|
||||||
|
- Migration steps (`psql -f`, `migrate up`, `supabase migration`) →
|
||||||
|
`# @delta:migrations glob=supabase/migrations/*.sql:list`
|
||||||
|
- Build/restart steps (`docker compose`, `make build`, image push) →
|
||||||
|
`# @delta:rebuild when=docker-compose*.yml,Dockerfile,Dockerfile.*`
|
||||||
|
- Dep-install steps (`npm ci`, `pip install -r`, `bundle install`) →
|
||||||
|
`# @delta:deps when=package.json,*lock*,requirements.txt,pyproject.toml`
|
||||||
|
4. Present the annotated draft; invite corrections before the gate.
|
||||||
|
|
||||||
|
→ **[GATE]** below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Path B — Scaffold (detect + interview)
|
||||||
|
|
||||||
|
**Detect artifacts** (Glob / Read only — never shell `find /`):
|
||||||
|
|
||||||
|
| Check | If found | Step emitted |
|
||||||
|
|-------|---------|--------------|
|
||||||
|
| `supabase/migrations/*.sql` | yes | migration step with `:list` annotation |
|
||||||
|
| `docker-compose*.yml` or `Dockerfile` | yes | rebuild step with `when=` annotation |
|
||||||
|
| `package.json` or `*lock*` | yes | deps step with `when=package.json,*lock*` |
|
||||||
|
| `requirements.txt` or `pyproject.toml` | yes | deps step with `when=requirements.txt,pyproject.toml` |
|
||||||
|
| `.env*` (not `.env.example`) | yes | add `# NOTE: inject env vars` to smoke-test step |
|
||||||
|
|
||||||
|
**Interview (AskUserQuestion — one prompt, all fields):**
|
||||||
|
|
||||||
|
| Field | Prompt | Default / placeholder |
|
||||||
|
|-------|--------|-----------------------|
|
||||||
|
| SSH host | "SSH host or deploy target?" | keep as `$DEPLOY_HOST` if blank |
|
||||||
|
| Backup command | "Backup command before migrations?" | `pg_dump "$DB" > ~/backups/pre-deploy-$(date +%F-%H%M).sql` |
|
||||||
|
| Health-check URL | "Health-check URL (expects HTTP 200)?" | `https://$DEPLOY_HOST/health` |
|
||||||
|
| Rollback note | "One-line rollback note (optional)?" | omit if blank |
|
||||||
|
| Push deploy tags | "`push_deploy_tags`? (true / false)" | `false` |
|
||||||
|
|
||||||
|
**Using** `templates/deploy/PROCEDURE.md` **as base, populate** fields from interview answers + detected artifacts:
|
||||||
|
- Substitute `$DEPLOY_HOST` with the supplied host (keep literal `$DEPLOY_HOST` if none given).
|
||||||
|
- Include only the annotated steps whose artifact was detected; keep all fixed steps.
|
||||||
|
- Set `# @config push_deploy_tags=<answer>` in the header.
|
||||||
|
- Append the rollback note as `# ROLLBACK: <note>` at the end if provided.
|
||||||
|
|
||||||
|
→ **[GATE]** below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [GATE] — approve PROCEDURE.md draft (`all / edit / skip-all`)
|
||||||
|
|
||||||
|
Present the full draft `PROCEDURE.md`.
|
||||||
|
|
||||||
|
- `all` → approve: write files and commit (see below).
|
||||||
|
- `edit` → revise the listed steps or annotations, re-present.
|
||||||
|
- `skip-all` → abort bootstrap: write nothing, stop. Re-invoke `/deploy` when ready.
|
||||||
|
|
||||||
|
**On approve — write + seed + commit:**
|
||||||
|
|
||||||
|
1. Write `.claude/deploy/PROCEDURE.md` (Write tool — the approved draft).
|
||||||
|
2. Seed `.claude/deploy/INCIDENTS.md` from `templates/deploy/INCIDENTS.md` (Write tool).
|
||||||
|
3. Ensure the target project's `.gitignore` contains `.claude/deploy/NEXT.sh` and
|
||||||
|
`.claude/deploy/PENDING.json` (append both if missing — these are the transient
|
||||||
|
artifacts that must not be committed).
|
||||||
|
4. Check that `.claude/deploy/` is NOT git-ignored: `git check-ignore -q .claude/deploy/PROCEDURE.md`
|
||||||
|
(rc 0 = ignored). If ignored — e.g. the project has `.claude/` in its `.gitignore` wholesale —
|
||||||
|
**ABORT bootstrap**: warn the user that the runbook/oracle/ledger cannot be committed,
|
||||||
|
and tell them to un-ignore `.claude/deploy/` (e.g. add `!.claude/deploy/` after the
|
||||||
|
`.claude/` rule). Do NOT commit anything further.
|
||||||
|
5. Commit via the allowlist helper:
|
||||||
|
```bash
|
||||||
|
bash lib/deploy-commit.sh commit \
|
||||||
|
"feat(deploy): bootstrap runbook" \
|
||||||
|
.claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md
|
||||||
|
```
|
||||||
|
Return codes: **0** committed · **1** no-op (investigate — both files should be new) ·
|
||||||
|
**3** unsafe git state (STOP, tell user) · **4** out-of-scope path ·
|
||||||
|
**5** a passed path is git-ignored (won't persist) — STOP, fix the target's `.gitignore` ·
|
||||||
|
**2** usage error OR not a git repo.
|
||||||
|
|
||||||
|
**On rc=0: continue to STEP 1.** `STATE.json` absent → first deploy →
|
||||||
|
STEP 1 sets `base_sha: null` and the full runbook fires (every fixed step and
|
||||||
|
every detected `@delta:` step instantiates). Correct and expected — no special
|
||||||
|
handling needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 — DELTA
|
||||||
|
|
||||||
|
Set the base, compute the changed-file list, capture the target.
|
||||||
|
|
||||||
|
- **`STATE.json` absent → FIRST DEPLOY.** No base (`PENDING.json.base_sha: null`).
|
||||||
|
The **full runbook** fires: delta = the entire tracked tree (`git ls-files`), so
|
||||||
|
every fixed step and every applicable `@delta:` step instantiates.
|
||||||
|
- **`STATE.json` present →** read `deployed_sha` as `base`, then:
|
||||||
|
```bash
|
||||||
|
git diff --name-only <base_sha> HEAD # two explicit endpoints, no dots
|
||||||
|
```
|
||||||
|
This is the literal tree difference deployed→HEAD. **Never** `git rev-list`
|
||||||
|
ancestry (phantom deltas after a rebase) and **never** three-dot `<base>...HEAD`
|
||||||
|
(merge-base undercounts).
|
||||||
|
- `target = git rev-parse HEAD` — the SHA this deploy carries to prod.
|
||||||
|
|
||||||
|
## STEP 2 — INSTANTIATE + [GATE] + HAND BACK
|
||||||
|
|
||||||
|
**Build `NEXT.sh` (the recipe — it IS this shape):**
|
||||||
|
|
||||||
|
1. Walk `PROCEDURE.md` in order. For each step:
|
||||||
|
- un-annotated (fixed) → emit verbatim;
|
||||||
|
- `@delta:…:each` → emit the command once per matching delta file, file
|
||||||
|
substituted; zero matches → omit;
|
||||||
|
- `@delta:…:list` → if any delta file matches, emit the command once and list
|
||||||
|
the matches as `# VERIFY:` items; zero matches → omit;
|
||||||
|
- `@delta:…when=` → emit verbatim only if the delta intersects a pattern.
|
||||||
|
2. Read `INCIDENTS.md`; for each `DEP-NNN` whose step matches an emitted step,
|
||||||
|
prepend `# PRE-WARN: DEP-NNN <one-line summary>` above it.
|
||||||
|
3. Keep every `# VERIFY:` gate. Header the file: *"Run by hand, step by step.
|
||||||
|
Never `bash NEXT.sh` unattended."*
|
||||||
|
4. Write `.claude/deploy/NEXT.sh`.
|
||||||
|
|
||||||
|
**[GATE] — present `NEXT.sh` → `all / edit / skip-all`.**
|
||||||
|
- `all` → proceed. `edit` → revise the listed steps, re-present.
|
||||||
|
- `skip-all` → abort: write no `PENDING.json`, discard the draft `NEXT.sh`, stop.
|
||||||
|
|
||||||
|
**On approve:** write `.claude/deploy/PENDING.json`:
|
||||||
|
```jsonc
|
||||||
|
{ "base_sha": "<STEP 1 base>", "target_sha": "<STEP 1 target>",
|
||||||
|
"delta": [<STEP 1 file list>], "step_reached": "awaiting-user",
|
||||||
|
"started_at": "<now, ISO-8601>",
|
||||||
|
"runbook_rev": "<git log -1 --format=%H -- .claude/deploy/PROCEDURE.md>" }
|
||||||
|
```
|
||||||
|
**Then HAND BACK** (AskUserQuestion): *"Run NEXT.sh step by step against prod.
|
||||||
|
Report back: **Deployed OK** / **Failed at step X: <err>** / **Not yet**."* Then
|
||||||
|
**stop** — control is the user's; `PENDING.json` on disk now marks the wait.
|
||||||
|
|
||||||
|
## STEP 3 — RESUME / REACT
|
||||||
|
|
||||||
|
Entry point on the user's report — reached inline after STEP 2, **or cold via
|
||||||
|
STEP 0** in a later session. Branch on the report:
|
||||||
|
|
||||||
|
- **"Deployed OK"** → STEP 5.
|
||||||
|
- **"Failed at step X: <err>"** → STEP 4.
|
||||||
|
- **"Not yet"** → restate what is pending (`step_reached`, target, the command to
|
||||||
|
run) and stop. `PENDING.json` stays; the wait continues.
|
||||||
|
|
||||||
|
## STEP 4 — LEARN + [GATE] + ATOMIC COMMIT
|
||||||
|
|
||||||
|
Diagnose the root cause of the step-X failure, then draft a **coupled pair**:
|
||||||
|
|
||||||
|
- **(a)** an in-place patch to step X in `PROCEDURE.md` so the next run cannot
|
||||||
|
repeat the failure;
|
||||||
|
- **(b)** an append to `INCIDENTS.md` — a new `DEP-NNN`
|
||||||
|
(`next = grep '^## DEP-' INCIDENTS.md | max+1`) with date, step, **error
|
||||||
|
verbatim**, root cause, and fix.
|
||||||
|
|
||||||
|
**[GATE] — `all / pick <IDs> / edit <ID> / skip-all`** (significant edit — it
|
||||||
|
changes a prod path).
|
||||||
|
- **Coupling invariant:** the patch and the incident are **one unit** — never
|
||||||
|
commit one without the other. `pick <IDs>` / `edit <ID>` apply only when
|
||||||
|
diagnosis yields **multiple** incidents (several failing steps); each selected
|
||||||
|
incident still commits its own patch+append together.
|
||||||
|
- `skip-all` → leave `PENDING.json` as-is, stop, nothing learned (the deploy stays
|
||||||
|
failed-and-pending).
|
||||||
|
|
||||||
|
**On approve — one ATOMIC commit of both files:**
|
||||||
|
```bash
|
||||||
|
bash lib/deploy-commit.sh commit \
|
||||||
|
"docs(deploy): patch <step> — recovered from <err>" \
|
||||||
|
.claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md
|
||||||
|
```
|
||||||
|
Return codes: **0** committed (short-hash on stdout) · **1** nothing staged — you
|
||||||
|
wrote neither file · **3** unsafe git state (detached/merge/rebase — STOP, tell
|
||||||
|
the user) · **4** out-of-scope path (you passed a non-`.claude/deploy/` path — fix
|
||||||
|
the call) · **5** a passed path is git-ignored (won't persist) — STOP, fix the
|
||||||
|
target's `.gitignore` · **2** usage error OR not a git repo. The helper commits
|
||||||
|
whatever subset actually changed;
|
||||||
|
patch+incident coupling is **Claude-discipline, not helper-enforced**.
|
||||||
|
|
||||||
|
**This commit IS the resolution** — the commit that introduces `DEP-NNN` is its
|
||||||
|
fix (patch + incident committed atomically). Recover later via
|
||||||
|
`git log -S '<DEP-NNN>' -- .claude/deploy/INCIDENTS.md`. No backfill needed.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Bump `PENDING.json.runbook_rev` to `git rev-parse HEAD` (full sha — not the helper's short-hash stdout); keep `step_reached` = `X`.
|
||||||
|
2. **Regenerate `NEXT.sh` from `step_reached` against the PATCHED runbook**
|
||||||
|
(steps X…end — X+1…end never ran). This is NOT replaying one step: the bumped
|
||||||
|
`runbook_rev` is exactly the staleness trigger — runbook changed ⇒ prior
|
||||||
|
`NEXT.sh` is stale ⇒ regenerate.
|
||||||
|
3. Re-present via **STEP 2's [GATE] + hand-back** (the regenerated `NEXT.sh`;
|
||||||
|
`PENDING.json` keeps `base/target/delta`, `step_reached` back to
|
||||||
|
`awaiting-user`).
|
||||||
|
|
||||||
|
## STEP 5 — MARK (success)
|
||||||
|
|
||||||
|
The deploy succeeded. Lay the oracle and close out.
|
||||||
|
|
||||||
|
1. Read `# @config push_deploy_tags=` from the `PROCEDURE.md` header (default
|
||||||
|
`false`). Pick `date = today` (`YYYY-MM-DD`); if `deploy/<date>` exists, suffix
|
||||||
|
`-N`.
|
||||||
|
2. Write `.claude/deploy/STATE.json` (overwrite):
|
||||||
|
```jsonc
|
||||||
|
{ "deployed_sha": "<PENDING.target_sha>", "deployed_at": "<now ISO-8601>",
|
||||||
|
"outcome": "ok", "tag": "deploy/<date>" }
|
||||||
|
```
|
||||||
|
**`deployed_sha` = `PENDING.target_sha`, NOT current HEAD** — HEAD may have
|
||||||
|
moved during the gap; the bridge's target is the deployed truth.
|
||||||
|
3. `git tag -a deploy/<date> <PENDING.target_sha> -m "<summary>"`.
|
||||||
|
4. If `push_deploy_tags=true` → `git push origin deploy/<date>` — **best-effort,
|
||||||
|
non-fatal**: a push failure logs a warning, never blocks the mark (the tag is a
|
||||||
|
bookmark; `STATE.json` is the oracle).
|
||||||
|
5. Commit the oracle:
|
||||||
|
```bash
|
||||||
|
bash lib/deploy-commit.sh commit "chore(deploy): mark <date> @ <short>" \
|
||||||
|
.claude/deploy/STATE.json
|
||||||
|
```
|
||||||
|
6. **Delete `.claude/deploy/PENDING.json` and `.claude/deploy/NEXT.sh`** — the
|
||||||
|
deploy is no longer in flight; the bridge is consumed.
|
||||||
|
7. Report: deployed SHA, tag (+ push result), state committed, any `DEP-NNN`
|
||||||
|
learned this deploy. Then offer to capitalize per CLAUDE.md (recurring failure
|
||||||
|
pattern → `learnings.md`; deploy verdict → `evals.md`), gated, never silent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- `PENDING.json` is the only memory across the gap. Read it first, every run.
|
||||||
|
- On RESUME, never recompute `{base, target, delta}` — the bridge is authoritative.
|
||||||
|
- `deployed_sha` is `PENDING.target_sha`, never live HEAD.
|
||||||
|
- Delta is `git diff --name-only <base> HEAD` (two endpoints). No `rev-list`, no
|
||||||
|
three-dot, no date ranges.
|
||||||
|
- First-deploy / fresh detection is file existence only — never `git describe`.
|
||||||
|
- Claude never executes the deploy. `NEXT.sh` is hand-run; `# VERIFY:` gates stay.
|
||||||
|
- Patch + incident commit **atomically**, one `deploy-commit.sh` call, both files.
|
||||||
|
- A learn bumps `runbook_rev` and **regenerates** `NEXT.sh` from `step_reached`;
|
||||||
|
it never replays a single step.
|
||||||
|
- Tag push is best-effort; `STATE.json` is the oracle.
|
||||||
|
- JSON is read natively (Read tool), never parsed with `jq`/shell.
|
||||||
|
- `STATE.json` written only on confirmed success (STEP 5). A failed/partial deploy
|
||||||
|
leaves the oracle untouched, `PENDING.json` alive — fail closed, resume later.
|
||||||
|
|
||||||
|
## Common mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| On resume, recomputing delta from current HEAD | HEAD moved during the gap. Use `PENDING.json.{base,target,delta}` verbatim. |
|
||||||
|
| `git describe` to detect first deploy | Errors with no tag. Detect by `STATE.json` / `PENDING.json` existence. |
|
||||||
|
| `git rev-list` or three-dot for the delta | Phantom/undercounted deltas. Two-dot `<base> HEAD` only. |
|
||||||
|
| `bash NEXT.sh` to "just run it" | Claude never deploys. Hand back; user runs by hand with `# VERIFY:` gates. |
|
||||||
|
| Committing the patch without the incident (or vice versa) | Coupling invariant. One atomic `deploy-commit.sh` call, both files. |
|
||||||
|
| Replaying only the failed step after a patch | Steps X…end never ran. Regenerate `NEXT.sh` from `step_reached`. |
|
||||||
|
| Writing `STATE.json` before the user confirms success | Oracle marks success only. Failed deploy leaves it untouched. |
|
||||||
|
| Setting `deployed_sha` to HEAD at MARK time | Use `PENDING.target_sha` — the SHA actually deployed. |
|
||||||
|
| Parsing the JSON bridges with `jq` | Read them natively. No jq dependency. |
|
||||||
|
| Deleting `PENDING.json` before STEP 5 | The bridge is the resume marker — delete it only on confirmed success. |
|
||||||
|
|
||||||
|
## Red flags — STOP
|
||||||
|
|
||||||
|
- About to recompute the delta or re-read HEAD while a `PENDING.json` exists.
|
||||||
|
- About to run `git describe`, `git rev-list`, or a three-dot diff for the delta.
|
||||||
|
- About to `bash NEXT.sh` or run any prod command yourself.
|
||||||
|
- About to commit `PROCEDURE.md` without `INCIDENTS.md` in the same call.
|
||||||
|
- About to write `STATE.json` before the user reported "Deployed OK".
|
||||||
|
- About to replay one failed step instead of regenerating from `step_reached`.
|
||||||
|
|
||||||
|
## Note on this skill (authoring)
|
||||||
|
|
||||||
|
Shaped via `superpowers:writing-skills`. The **cold cross-session resume** is the
|
||||||
|
novel form (design §10): the disk alone must carry the deploy across the
|
||||||
|
out-of-band gap, so `PENDING.json`'s presence marks the wait and STEP 0 resumes
|
||||||
|
from it without conversation memory — the `audit-delta` "state file is the only
|
||||||
|
memory between runs" convention, extended to a *mid-flow* pause. The forms here
|
||||||
|
match the failure modes the design identified: **discipline** failures
|
||||||
|
(recompute-on-resume, run-the-deploy, advance-the-oracle-early) get the
|
||||||
|
rationalization table + red flags; the **shape** of `NEXT.sh` and the schemas get
|
||||||
|
positive recipes; the patch↔incident **omission** is a structural atomic-commit
|
||||||
|
requirement. Pressure-scenario baseline testing per the writing-skills Iron Law
|
||||||
|
is a follow-up — the failure modes were taken from the design spec, not a fresh
|
||||||
|
RED run.
|
||||||
10
templates/deploy/INCIDENTS.md
Normal file
10
templates/deploy/INCIDENTS.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Deploy incidents (append-only) — DEP-NNN
|
||||||
|
|
||||||
|
<!-- One entry per incident. Next ID = grep '^## DEP-' | max+1. Mirrors blockers.md. -->
|
||||||
|
<!-- Resolution = the commit that adds this entry (atomic patch+incident). Recover: git log -S 'DEP-NNN' -- .claude/deploy/INCIDENTS.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> -->
|
||||||
25
templates/deploy/PROCEDURE.md
Normal file
25
templates/deploy/PROCEDURE.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
#!/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
|
||||||
Loading…
Reference in New Issue
Block a user