#!/usr/bin/env bash # run-operator-window.sh — turnkey execution of the polyrepo operator window (RUNBOOK §0–§4). # # RUN AS THE OPERATOR, via the chat `!` prefix: ! bash docs/marketplace-polyrepo-migration/migration/run-operator-window.sh # NOT via Claude's Bash tool — creating public repos + bulk-pushing trees to new remotes is gated by the # auto-mode safety classifier (by design). Running it yourself via `!` is the sanctioned path: it executes # as you, so the classifier / permission layer / push-window hook are all out of the picture. # # Idempotent + stop-on-first-failure. Safe to re-run after a partial run (created repos → 409 ok, pushed # refs → up-to-date, already-flipped catalog entries → skipped). # # DEFAULT (no args) — §0–§3, REVERSIBLE: # For all 11 targets in RUNBOOK order: (a) validate the extract → (b) create the Forgejo repo # (auto_init:false, public) → (c) push the extract over SSH → (d) verify HTTPS+ref resolution # (the install-smoke PROXY — a real `/plugin install` still needs a fresh Claude Code session) → # (e) flip marketplace.json to the external nested source + push the catalog. # graceful-handoff runs first as the PILOT gate. Standing up the 11 repos is what unblocks per-plugin # parallel work — thinning is NOT required for that. Every flip is `git revert`-able while ./plugins/ # still exists, so this whole phase is reversible. # # --thin — §4, build + verify the thin-catalog preview (no apply). # CONFIRM_THIN=1 ... --thin — §4 APPLY: git rm plugins/ shared/ sync-script, swap in CONVENTIONS/CLAUDE/ # README, commit, push, push the pre-polyrepo-archive tag. IRREVERSIBLE point of no easy return — # run only after the real `/plugin install` smoke-tests for all 11 have passed. # # Prereqs: $FORGEJO_TOKEN exported (Keychain → ~/.zshenv); on branch main; the 11 extracts buildable in # $WORK (the validate step self-extracts if missing — that self-heal also rebuilds the mirror via preflight). set -uo pipefail HOST="git.fromaitochitta.com" API="https://$HOST/api/v1" MIG="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$MIG/../../.." && pwd)" MAP="$MIG/plugin-map.json" LIVE="$ROOT/.claude-plugin/marketplace.json" WORK="${WORK:-/tmp/polyrepo-migration}" ARCHIVE_TAG="pre-polyrepo-archive" PILOT="graceful-handoff" REST="playground-design-system voyage llm-security linkedin-studio ms-ai-architect config-audit okr ai-psychosis human-friendly-style claude-design" # The shared design-system is a standalone repo (stood up so consumers can vendor from an upstream), but it # is NOT a marketplace plugin: it has no .claude-plugin/plugin.json and no marketplace.json entry. Steps (d) # and (e) special-case it (assert its DS root marker; no catalog flip — mirrors 60-rewrite --all). DS_KEY="playground-design-system" die() { printf '\n✖ %s\n' "$*" >&2; exit 1; } say() { printf '%s\n' "$*"; } # ---- preconditions ---- [ -n "${FORGEJO_TOKEN:-}" ] || die "FORGEJO_TOKEN not set — export it from Keychain via ~/.zshenv first." command -v python3 >/dev/null 2>&1 || die "python3 not found" command -v node >/dev/null 2>&1 || die "node not found" command -v curl >/dev/null 2>&1 || die "curl not found" [ -f "$MAP" ] || die "plugin-map.json missing at $MAP" [ -f "$LIVE" ] || die "live marketplace.json missing at $LIVE" BR="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)" [ "$BR" = "main" ] || die "catalog repo not on main (on '$BR')" # ---- SSH connection multiplexing (REQUIRED — Forgejo rate-limits rapid port-22 handshakes) ---- # The bare per-target `git push --all` + `git push --tags` opens 2 SSH connections per target (22 total) # in rapid succession; Forgejo refuses around the 6th handshake ("ssh: connect to host ... port 22: # Connection refused" — TCP-level, not auth), which killed voyage's tag push on every run. Route ALL # git-over-SSH through ONE persistent master connection so the whole rollout costs a single TCP handshake. # Verified: 8 rapid multiplexed connections all succeed where the 6th bare connection is refused. # ControlPersist keeps the master alive across the inter-target validate/clone gaps. export GIT_SSH_COMMAND="ssh -o ControlMaster=auto -o ControlPath=/tmp/polyrepo-ssh-%C -o ControlPersist=600" mapget() { python3 -c "import json;print(json.load(open('$MAP'))['targets']['$1'].get('$2',''))"; } entry_present() { python3 -c "import json print('yes' if any(x['name']=='$1' for x in json.load(open('$LIVE'))['plugins']) else 'no')" } entry_is_external() { python3 -c "import json p=[x for x in json.load(open('$LIVE'))['plugins'] if x['name']=='$1'] print('yes' if (p and isinstance(p[0].get('source'),dict)) else 'no')" } count_local() { python3 -c "import json print(sum(1 for x in json.load(open('$LIVE'))['plugins'] if isinstance(x.get('source'),str) and x['source'].startswith('./plugins/')))" } rollout_one() { key="$1" tag="$(mapget "$key" tag)" [ -n "$tag" ] || die "no tag for $key in plugin-map.json" say "" say "==== $key (tag $tag) ====" # (a) validate the extract — self-extracts if $WORK/$key is absent. # SC2 is REGRESSION-RELATIVE (the contract the Step-11 dry-run validated): a target passes iff the # extraction introduces NO NEW failure. Pre-existing in-repo red (voyage's 2 doc-consistency drifts, # ai-psychosis's 1) is the plugin's own concern — 41-validate-or-regression.sh enforces that exact # contract (strict 40 first, then standalone-failing ⊆ in-repo-failing) so the window does NOT STOP on # red the rehearsal blessed. config-audit keeps its dedicated deterministic gate. say " [a] validate extract…" if [ "$key" = "config-audit" ]; then WORK="$WORK" bash "$MIG/50-config-audit-sc2.sh" >/dev/null 2>&1 || die "$key SC2 gate FAILED — STOP" else sc2out="$(WORK="$WORK" bash "$MIG/41-validate-or-regression.sh" "$key" 2>&1)" \ || die "$key standalone validation FAILED (incl. regression-relative SC2) — STOP${sc2out:+ :: $sc2out}" case "$sc2out" in *pre-existing*) say " ${sc2out#*: }";; esac fi # (b) create the Forgejo repo (201 created | 409 already exists) say " [b] create repo open/${key}…" body="$(mktemp)" code="$(curl -sS -o "$body" -w '%{http_code}' -X POST "$API/orgs/open/repos" \ -H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" \ -d "{\"name\":\"$key\",\"private\":false,\"auto_init\":false,\"default_branch\":\"main\"}")" case "$code" in 201) say " created";; 409) say " already exists (ok, idempotent)";; *) cat "$body" >&2; rm -f "$body"; die "$key repo-create unexpected HTTP $code — STOP";; esac rm -f "$body" # (c) push the extract over SSH (proven auth; the marketplace source URL stays HTTPS) say " [c] push extract over SSH…" ( cd "$WORK/$key" || exit 1 git remote remove origin >/dev/null 2>&1 || true git remote add origin "ssh://git@$HOST/open/$key.git" || exit 1 git push origin --all || exit 1 git push origin --tags || exit 1 ) || die "$key push FAILED — STOP" # (d) install-smoke PROXY: HTTPS clone at the pinned tag (the resolution path users hit) say " [d] verify HTTPS+ref resolution…" sm="$(mktemp -d)" git clone --quiet --branch "$tag" "https://$HOST/open/$key.git" "$sm/r" >/dev/null 2>&1 \ || die "$key does NOT resolve over HTTPS at $tag — STOP, diagnose the Forgejo/HTTPS/ref chain" if [ "$key" = "$DS_KEY" ]; then # design-system: no plugin.json — assert its DS root marker instead (tokens.css, per plugin-map test_cmd) [ -f "$sm/r/tokens.css" ] || die "$key clone missing tokens.css (DS root marker) — STOP" else [ -f "$sm/r/.claude-plugin/plugin.json" ] || die "$key clone missing .claude-plugin/plugin.json — STOP" fi say " resolves: https://$HOST/open/$key @ $tag ✓" # (e) flip the catalog entry to the external nested source (idempotent) + push. # The design-system has no marketplace entry (consumers vendor it) — nothing to flip; this mirrors # 60-rewrite --all, which only touches the 10 plugins actually present in marketplace.json. if [ "$(entry_present "$key")" = "no" ]; then say " [e] $key has no marketplace entry (shared design-system, vendored by consumers) — no catalog flip" elif [ "$(entry_is_external "$key")" = "yes" ]; then say " [e] catalog already external for $key (skip)" else say " [e] flip catalog → external + push…" node "$MIG/60-rewrite-marketplace.mjs" --only "$key" --in "$LIVE" --out /tmp/mp-rollout.json >/dev/null \ || die "$key marketplace rewrite FAILED — STOP" cp /tmp/mp-rollout.json "$LIVE" || die "$key cp marketplace.json FAILED" git -C "$ROOT" add .claude-plugin/marketplace.json || die "$key git add FAILED" git -C "$ROOT" commit -q -m "chore(marketplace): externalise $key" || die "$key catalog commit FAILED" git -C "$ROOT" push origin main || die "$key catalog push FAILED — STOP" say " flipped + pushed" fi say " ✓ $key DONE" } # ======================== §4 thinning ======================== do_thin() { say "OPERATOR WINDOW — §4 thin catalog" locals="$(count_local)" [ "$locals" = "0" ] || die "$locals catalog entries still local — finish §0–§3 rollout before thinning" ws="$(mktemp -d)" bash "$MIG/70-thin-catalog.sh" --workspace "$ws" >/dev/null || die "thin-catalog preview FAILED" [ ! -d "$ws/plugins" ] || die "thin preview still contains plugins/" [ ! -d "$ws/shared" ] || die "thin preview still contains shared/" [ -f "$ws/CONVENTIONS.md" ] || die "thin preview missing CONVENTIONS.md" say " thin preview verified at $ws (no plugins/ shared/, CONVENTIONS.md present)" if [ "${CONFIRM_THIN:-}" != "1" ]; then say "" say " PREVIEW ONLY (irreversible apply is gated). To APPLY:" say " CONFIRM_THIN=1 bash $0 --thin" exit 0 fi say " APPLYING irreversible thin state to the live catalog…" git -C "$ROOT" rm -r -q --ignore-unmatch plugins shared scripts/sync-design-system.mjs scripts/sync-design-system.test.mjs || true cp "$ws/CONVENTIONS.md" "$ROOT/CONVENTIONS.md" || die "cp CONVENTIONS.md FAILED" cp "$ws/CLAUDE.md" "$ROOT/CLAUDE.md" || die "cp CLAUDE.md FAILED" cp "$ws/README.md" "$ROOT/README.md" || die "cp README.md FAILED" git -C "$ROOT" add CONVENTIONS.md CLAUDE.md README.md || die "git add (thin docs) FAILED" git -C "$ROOT" add -A plugins shared scripts 2>/dev/null || true git -C "$ROOT" commit -q -m "chore(marketplace): thin catalog to manifest + docs (polyrepo migration complete)" \ || die "thin commit FAILED" git -C "$ROOT" push origin main || die "thin push FAILED" git -C "$ROOT" push origin "$ARCHIVE_TAG" || die "archive-tag push FAILED" say "" say "✓ THINNING COMPLETE — catalog is manifest + docs only; archive tag $ARCHIVE_TAG pushed." say " POLYREPO MIGRATION COMPLETE." exit 0 } # ======================== main ======================== if [ "${1:-}" = "--thin" ]; then do_thin fi say "OPERATOR WINDOW — §0–§3 rollout (create + push + flip; REVERSIBLE)" say "Targets in order: $PILOT (pilot) → $REST" say "" say "### PILOT: $PILOT — must pass before the other 10 ###" rollout_one "$PILOT" say "" say "### PILOT PASSED — rolling out the remaining 10 ###" for k in $REST; do rollout_one "$k"; done say "" say "──────────────────────────────────────────────────────────────" say "✓ ROLLOUT COMPLETE — 11 repos created + pushed; marketplace.json all-external (nested HTTPS+ref)." say " Each plugin now lives at https://$HOST/open/ — clone & work on them in parallel." say "" say " NEXT (recommended before thinning) — real install-smoke per plugin in a FRESH Claude Code session:" say " /plugin marketplace update" say " /plugin install @ktg-plugin-marketplace # confirm commands/skills/agents load" say " Reversible until thinning: 'git revert' the externalise commit and ./plugins/ resolves again." say "" say " When every install-smoke passes, run the irreversible cleanup:" say " CONFIRM_THIN=1 bash $0 --thin"