Skip to content
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Purpose

This repo is the **dev-env infrastructure repository** — it contains documentation, design plans, templates, and hook extensions for Andrew's Claude Code development environment. It is not an application codebase; there are no build, test, or lint commands.
This repo is the **dev-env infrastructure repository** — it contains documentation, design plans, templates, and hook extensions for Andrew's Claude Code development environment. It is not an application codebase and there is no build step, but it is not command-free either:

- **Tests**: `bash scripts/org-migration/tests/run-tests.sh` runs the hermetic stub-`gh` suite for the org-migration tooling. Run it after any change under `scripts/org-migration/`.
- **Lint**: `shellcheck -S info <script>` applies to every shell script in the repo, and must be clean with no `# shellcheck disable` directives.

## Repository Structure

- `docs/` — Design documents, workflow deep dives, and research notes
- `docs/plans/` — Implementation plans (e.g., infrastructure consolidation)
- `docs/WORKFLOW-DEEP-DIVE.md` — Comprehensive reference for all enforcement layers (hooks, wrappers, CI/CD)
- `docs/local-code-review-options.md` — Research on local review tooling (Semgrep, Sentry/Seer, adversarial reviewer enhancements)
- `docs/runbooks/` — Step-by-step manual procedures (UI actions the agent cannot perform)
- `docs/token-rotation.md` — Where each `CLAUDE_CODE_OAUTH_TOKEN` lives and when it expires; never contains a token
- `scripts/org-migration/` — Snapshot/transfer/verify tooling for the 2026-09 org migration; tests in `scripts/org-migration/tests/run-tests.sh`
- `.claude/` — Project-specific Claude Code configuration templates
- `.claude/config.sh.template` — Template for project configuration (Node version, required tools, deployment secrets, build/deploy hooks)
- `.claude/hooks/extensions/` — Project-specific git hook extensions (discovered and run by global hooks at `~/.config/git/hooks/`)
Expand Down
114 changes: 114 additions & 0 deletions docs/runbooks/org-migration-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Runbook: rename `smartwatermelon` → `twistedmelonman`, create org `smartwatermelon`

Design: `docs/superpowers/specs/2026-09-03-org-migration-design.md`, Step 2.
One sitting, one browser session. The window between A4 and B3 is the only
moment the name `smartwatermelon` is claimable by someone else. Do A and B
back to back.

## Before you start

- [ ] dotfiles PR "twistedmelonman owner table, login alias, F4 scope hint" is
merged and `~/Developer/dotfiles` is on `main` at or after it.
Check: `grep -c twistedmelonman ~/Developer/dotfiles/bash/gh-wrapper.sh`
prints a number ≥ 4.
- [ ] Baseline snapshot committed under `docs/data/org-migration/`.
- [ ] You are signed in to github.com as `smartwatermelon` in the browser.
- [ ] Nothing is pushing or running CI right now (check
`gh run list -R smartwatermelon/github-workflows --limit 3`).

## A. Rename the user

1. Open <https://github.com/settings/admin> ("Account" settings).
2. Under **Change username**, click **Change username**.
3. Read the warning dialog, click **I understand, let's change my username**.
4. Type `twistedmelonman`, click **Change my username**.
5. Confirm the page header shows `twistedmelonman`.

## B. Create the org (immediately)

1. Open <https://github.com/account/organizations/new?plan=free>.
2. Organization name: `smartwatermelon`. If the form says the name is taken,
**stop**: go back to A and rename to `smartwatermelon` again. The design's
failure table covers what happens next (a new spec).
3. Contact email: your gmail. "This organization belongs to": **My personal
account**. Complete the verification, click **Next**.
4. Skip "Add organization members" (**Skip this step**). Skip the survey.
5. Confirm <https://github.com/smartwatermelon> shows the org page with you
as owner.

## C. Org settings

1. <https://github.com/organizations/smartwatermelon/settings/actions>:
**Actions permissions** must be "Allow all actions and reusable workflows"
(the Free default). Under **Workflow permissions**, leave "Read
repository contents and packages permissions" (repos carry their own
setting across the transfer).
2. <https://github.com/organizations/smartwatermelon/settings/member_privileges>:
leave defaults. You are the only member.

## D. Re-login the keyring on THIS machine

The keyring token still works after the rename, but `hosts.yml` records the
login name and the wrapper compares it. Re-login rewrites it.

```bash
env -u GH_TOKEN gh auth login -h github.com --web --scopes admin:org,repo,workflow,delete_repo
env -u GH_TOKEN gh auth logout -h github.com -u smartwatermelon # stale entry, if listed
env -u GH_TOKEN gh auth status
```

Expected: the active github.com account is `twistedmelonman`, scopes include
`admin:org`. `delete_repo` is for Step 6's repo deletions; drop it from the
list if you would rather add it later with `gh auth refresh -s delete_repo`.

## E. Verify from a shell (paste the output back to the agent)

```bash
gh api user --jq .login # GH_TOKEN: twistedmelonman
env -u GH_TOKEN gh api user --jq .login # keyring: twistedmelonman
gh api orgs/smartwatermelon --jq '.login + " " + .type' # smartwatermelon Organization
gh api orgs/smartwatermelon/memberships/twistedmelonman --jq .role # admin
gh api repos/smartwatermelon/dotfiles --jq '.owner.login + " " + .owner.type' # twistedmelonman User (redirect)
cd ~/Developer/dotfiles && gh pr list --limit 1 # identity guard passes, no error
```

## F. Verify the transfer (`verify.sh`)

```bash
bash scripts/org-migration/verify.sh scripts/org-migration/move-list.txt \
docs/data/org-migration/baseline "$(mktemp -d)"
```

**Always give `verify.sh` a fresh, empty after-dir.** It refuses a directory
that already holds files, because a leftover JSON from an earlier run would be
compared as though this run had just written it — a repo whose snapshot failed
now could still be reported ok from stale state. A `mktemp -d` per run is the
simplest way to get one.

**`cleanroom` is the one repo that moves to a *different* owner name**
(`nightowlstudiollc`, not `smartwatermelon`), so its URL-bearing fields may
legitimately differ: `protection.url`, a ruleset's `source` and `_links`, and
`pages.html_url` all embed the owner. A `fields changed besides owner` report
naming only those fields, for `cleanroom` only, is expected — not drift to
"restore". Inspect it before deciding:

```bash
diff <(jq -S . docs/data/org-migration/baseline/cleanroom.json) \
<(jq -S . <after-dir>/cleanroom.json)
```

If every difference is an owner name inside a URL, the transfer is correct.
The same report for any other repo, or a difference in a non-URL field, is
real drift.

## G. The other two machines (TILSIT, MIMOLETTE)

Run section D on each, before the alias-removal PR (plan Task 12) merges.
Until then the alias keeps the old `hosts.yml` name working.

## Undo

Rename back at <https://github.com/settings/admin> → `smartwatermelon`. If
the org was created, delete it first at
<https://github.com/organizations/smartwatermelon/settings/profile> (bottom,
**Delete this organization**), because the name must be free.
55 changes: 55 additions & 0 deletions docs/token-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# CLAUDE_CODE_OAUTH_TOKEN rotation

**This file never contains a token, a token prefix, or `claude setup-token`
output.** Dates and locations only. If a token appears here, treat it as
leaked: revoke and rotate.

Org-level secrets (design:
`docs/superpowers/specs/2026-09-03-org-migration-design.md`, Step 5). Free-plan
org secrets do not reach private repos, so `scripts` keeps a repo-level
token until it goes public.

| Scope | Minted | Expires | Minted on |
| --- | --- | --- | --- |
| org `smartwatermelon` | | | |
| org `nightowlstudiollc` | | | |
| repo `smartwatermelon/scripts` | | | |

Each row has a Google Calendar event "Rotate CLAUDE_CODE_OAUTH_TOKEN (<scope>)"
two weeks before the expiry date, pointing here.

## Rotation runbook

Rotate **before** expiry. Do not revoke-then-mint: revocation can take days
to propagate (`dev-env#54` findings) and every Claude workflow fails in
between.

1. Mint, on your own machine:

```bash
claude setup-token
```

Copy the token from the terminal. Do not paste it anywhere but step 2.

2. Set it (the wrapper prints this exact line if you forget `env -u`):

```bash
env -u GH_TOKEN gh secret set CLAUDE_CODE_OAUTH_TOKEN --org smartwatermelon --visibility all
# or --org nightowlstudiollc
# or, for scripts: gh secret set CLAUDE_CODE_OAUTH_TOKEN -R smartwatermelon/scripts
```

Paste when prompted.

3. Re-run one Claude workflow on a repo in that scope and read the log: the
`claude-code-action` step must authenticate, not skip.

```bash
gh run list -R smartwatermelon/dev-env --workflow claude-blocking-review.yml --limit 1 --json databaseId --jq '.[0].databaseId' | xargs -I{} gh run rerun {} -R smartwatermelon/dev-env
```

4. Update the table row (minted date, expiry = minted + the lifetime
`setup-token` printed, machine).

5. Move the calendar event to two weeks before the new expiry.
31 changes: 31 additions & 0 deletions scripts/org-migration/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Shared helpers for the 2026-09 org migration scripts. Sourced, not run.
# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md

# Every lookup goes through repos/smartwatermelon/<repo>. Before the rename
# that is the real path; after the rename GitHub redirects it to
# twistedmelonman/<repo>; after the transfer it is the real path again (or a
# redirect to nightowlstudiollc/cleanroom). One path, every state.
OM_LOOKUP_OWNER="smartwatermelon"

# Print "repo target" per non-comment line. Fail on a line that is not
# exactly two tab-separated fields.
om_read_move_list() {
local file="$1" line repo target extra n=0
while IFS= read -r line || [[ -n "${line}" ]]; do
n=$((n + 1))
[[ -z "${line}" || "${line}" == \#* ]] && continue
IFS=$'\t' read -r repo target extra <<<"${line}"
if [[ -z "${repo}" || -z "${target}" || -n "${extra}" || "${repo}" == *" "* ]]; then
echo "move-list: line ${n} is not 'repo<TAB>target': ${line}" >&2
return 1
fi
printf '%s %s\n' "${repo}" "${target}"
done <"${file}"
}

# Print "login type" for the repo's current owner. Non-zero on any failure.
om_lookup_owner() {
local repo="$1"
gh api "repos/${OM_LOOKUP_OWNER}/${repo}" --jq '.owner.login + " " + .owner.type'
}
37 changes: 37 additions & 0 deletions scripts/org-migration/move-list.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# repo<TAB>target-org — reviewed by hand in the PR that added it.
# Source of truth for transfer.sh; never derived at run time.
# Rows are alphabetical; order carries no meaning. github-workflows is
# transferred first and alone via `transfer.sh --only github-workflows`
# (design Step 3).
# cleanroom is the one repo that targets nightowlstudiollc.
.github smartwatermelon
archive-resolver smartwatermelon
claude-code-workflows-agents smartwatermelon
claude-config smartwatermelon
claude-config-backup smartwatermelon
claude-wrapper smartwatermelon
cleanroom nightowlstudiollc
crazy-larry smartwatermelon
dev-env smartwatermelon
dotfiles smartwatermelon
dumbify smartwatermelon
github-workflows smartwatermelon
gmail-newsletter-filter smartwatermelon
homebrew-tap smartwatermelon
huddle-transcribe smartwatermelon
lock-sync smartwatermelon
mac-dev-server-setup smartwatermelon
mac-server-setup smartwatermelon
personify smartwatermelon
pr-review smartwatermelon
projectinsomnia smartwatermelon
qwen-sidebar smartwatermelon
repo-template smartwatermelon
scripts smartwatermelon
slack-mcp smartwatermelon
smartwatermelon-marketplace smartwatermelon
spokane-snow smartwatermelon
superpowers smartwatermelon
superpowers-marketplace smartwatermelon
swift-progress-indicator smartwatermelon
x-thread-reader smartwatermelon
95 changes: 95 additions & 0 deletions scripts/org-migration/snapshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Snapshot the settings of every repo on the move list, one JSON per repo.
# Read-only. Usage: snapshot.sh <move-list> <outdir>
# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md
set -uo pipefail
unset CDPATH
HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/org-migration/lib.sh
source "${HERE}/lib.sh"

if [[ $# -ne 2 ]]; then
echo "usage: snapshot.sh <move-list> <outdir>" >&2
exit 2
fi
list="$1"
outdir="$2"
mkdir -p "${outdir}"

# Fetch a sub-resource. A 404 means "not configured" and prints `null`. Any
# other failure — a transient error, an expired token — is a failure, not an
# absence: mapping it to `null` would let verify.sh read it as expected state.
# Non-zero on such a failure, with gh's message on stderr.
_optional() {
local out err why rc=0
err="$(mktemp)" || return 1
# Cleanup on every exit path, including an early return.
trap 'rm -f "${err}"' RETURN
out="$(gh api "$1" 2>"${err}")" || rc=$?
if [[ "${rc}" -eq 0 ]]; then
printf '%s\n' "${out}"
elif grep -q '(HTTP 404)' "${err}"; then
echo null
rc=0
else
why="$(tr '\n' ' ' <"${err}" || true)"
printf 'snapshot: %s: %s\n' "$1" "${why}" >&2
fi
return "${rc}"
}

pairs="$(om_read_move_list "${list}")" || exit 1
failed=0
while read -r repo _target; do
base="repos/${OM_LOOKUP_OWNER}/${repo}"
if ! core="$(gh api "${base}")"; then
echo "snapshot: FAILED to read ${repo}" >&2
failed=$((failed + 1))
continue
fi
default_branch="$(jq -r '.default_branch' <<<"${core}")"
# topics and secrets go through _optional too: a 403 on secrets is a
# permission problem, not an empty secret list, and recording [] would make
# verify.sh read the failure as the expected state. Project the payload
# afterwards so a non-404 failure still fails the repo.
if ! topics_raw="$(_optional "${base}/topics")" ||
! secrets_raw="$(_optional "${base}/actions/secrets")" ||
! protection="$(_optional "${base}/branches/${default_branch}/protection")" ||
! rulesets="$(_optional "${base}/rulesets")" ||
! pages="$(_optional "${base}/pages")"; then
echo "snapshot: FAILED to read ${repo}" >&2
failed=$((failed + 1))
continue
fi
# The final assembly can still fail on a body that parsed as an argument but
# is not the shape we project. An unchecked jq here wrote an empty file and
# printed the success line anyway.
if ! jq -n \
--arg repo "${repo}" \
--argjson core "${core}" \
--argjson topics_raw "${topics_raw}" \
--argjson secrets_raw "${secrets_raw}" \
--argjson protection "${protection}" \
--argjson rulesets "${rulesets}" \
--argjson pages "${pages}" \
'{repo: $repo,
owner: {login: $core.owner.login, type: $core.owner.type},
default_branch: $core.default_branch,
visibility: $core.visibility,
archived: $core.archived,
topics: ($topics_raw.names // []),
pages: $pages,
secrets: ([($secrets_raw.secrets // [])[].name] | sort),
protection: $protection, rulesets: $rulesets}' >"${outdir}/${repo}.json"; then
rm -f "${outdir}/${repo}.json"
echo "snapshot: FAILED to assemble ${repo}" >&2
failed=$((failed + 1))
continue
fi
echo "snapshot: ${repo} -> ${outdir}/${repo}.json"
done <<<"${pairs}"

if [[ "${failed}" -gt 0 ]]; then
echo "snapshot: ${failed} repo(s) failed" >&2
exit 1
fi
11 changes: 11 additions & 0 deletions scripts/org-migration/tests/run-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Run every test-*.sh in this directory; exit non-zero if any fails.
set -uo pipefail
unset CDPATH
dir="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fail=0
for t in "${dir}"/test-*.sh; do
echo "== ${t##*/}"
if ! bash "${t}"; then fail=1; fi
done
exit "${fail}"
Loading
Loading