ktg-plugin-marketplace/docs/marketplace-polyrepo-migration/plan.md
Kjell Tore Guttormsen a44e37b71f docs(migration): polyrepo migration plan (11 steps, adversarially reviewed + revised)
Plan for splitting the monorepo into per-plugin Forgejo repos + a DS repo
+ a thin catalog. Built from the ratified brief via /trekplan: 6-agent
exploration verified the brief and surfaced 6 under-scoped findings
(F1-F6: plugin renames, ssh->https, 148MB blob bloat, config-audit
snapshot path-coupling, tag namespace, README link rot). plan-critic
REVISE on first draft (3 blockers + 8 majors) all addressed after
re-verifying contested facts; scope-guardian ALIGNED. Local Claude half
(Steps 1-11) is fully reversible NULL-push; operator window is a separate
runbook. Held unpushed per D8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:49:22 +02:00

69 KiB
Raw Blame History

Marketplace Polyrepo Migration — Implementation Plan

Plan quality: B+ (85/100) — APPROVE_WITH_NOTES (revised after adversarial review — Phase 9)

Generated by trekplan v5.1.1 on 2026-06-17 — plan_version: 1.7 Brief: docs/marketplace-polyrepo-migration/brief.md (RATIFIED, D1D8). Profile: premium (Opus).

⚠ Brief corrections requiring operator ratification (the plan overrides two literal values in the RATIFIED brief — surface these for explicit annotation, do not absorb silently):

  1. ssh://https:// (F2). Brief SC1 (§4) and the §6 entry shape literally specify ssh://git@…; D4 ratified only pin-to-tag, silent on scheme. Claude Code rejects SSH marketplace sources (issue #9740) → the plan uses https://git.fromaitochitta.com/open/<repo>.git everywhere. Correct engineering call, but it edits a ratified literal.
  2. config-audit SC2 carve-out (F4). Two back-compat test files (json-backcompat.test.mjs, raw-backcompat.test.mjs) assert byte-stability of a capture that embeds the original machine's absolute path + a sibling marketplace + deleted plugins; the plan excludes them by name from config-audit's SC2 gate (Step 7) — touches test fixtures, edges the §5 non-goal. Operator must accept the carve-out.

Context

The ktg-plugin-marketplace monorepo (10 plugins under plugins/, a shared design system under shared/, a catalog at the root) is split into one Forgejo repo per plugin + a versioned design-system repo + a thin catalog repo at the same URL. The plugins are already independent units (independent versions, self-contained plugin.json/README/CLAUDE.md, no live cross-plugin runtime imports — confirmed hard by exploration), so the split aligns structure with reality and removes concurrent-session git contention structurally rather than mitigating it. The marketplace must stay installable and live at every step (mixed in-repo + external sources).

This plan covers the Claude-run, local, reversible half: it builds the migration tooling, runs a full local dry-run extraction with standalone validation, and stages (but never runs) the outward steps — all with NULL push (honoring D8). The operator-run irreversible window (Forgejo repo creation, first push, live marketplace.json cutover) is captured as a runbook deliverable (Step 10) + the Execution Strategy, not as machine-verified steps, because those are not local file edits.

Every decision below traces to the brief: the goal (§3), success criteria SC1SC8 (§4), the scope fence (§5), decisions D1D8 (§7), risks R1R8 (§8), the five §9 open questions, and the execution model (§10). Exploration surfaced six findings the brief under-scoped (F1F6 below); the plan absorbs all six.

Exploration deltas the brief did not have (load-bearing)

  • F1 — Plugin renames split history. voyageultraplan-local (136 commits), llm-securityllm-security-copilot (3), linkedin-studiolinkedin-thought-leadership (50). A single-path filter (--subdirectory-filter plugins/voyage) silently drops the pre-rename history. Renamed plugins MUST be extracted with multiple --path arguments (both names) → SC6.
  • F2 — HTTPS, not ssh://. The brief's §6 ssh://git@… url source is unverified/likely rejected (Claude Code issue #9740 shows marketplace add rejecting SSH). HTTPS (https://git.fromaitochitta.com/open/<repo>.git) is the only form verified-accepted on both the url source schema and marketplace add. The discriminator key is source (brief correct), not type. Forgejo has no background auto-update token → public repos sidestep the gap → answers §9 visibility.
  • F3 — ms-ai-architect is a 148 MB screenshot blob-bomb (86% of .git; 26 blobs already deleted from HEAD but alive in history). filter-repo resurrects them → blob-strip decision.
  • F4 — config-audit snapshot tests embed the absolute path (verified directly): the default-output/ snapshots assert byte-equal output whose deep per-file paths (e.g. …/tests/fixtures/marketplace-medium/CLAUDE.md) are not normalized (normalizeScanOrchestrator scrubs only meta.target/timestamp/duration_ms), so they break at a new clone path; the v5.0.0/ back-compat snapshots (loaded by json-backcompat.test.mjs + raw-backcompat.test.mjs) additionally embed a sibling marketplace (ktg-privat) + deleted plugins from their original capture. SC2 blocker → real fix uses the existing UPDATE_SNAPSHOT=1 seam for the rebasable suite + by-name exclusion of the 2 frozen back-compat tests (Step 7).
  • F5 — Tag namespace is inconsistent (3 schemes; bare v5.0.0/v7.7.2 carry no plugin identity; v3.4.1→deleted ultraplan-local). filter-repo copies ALL tags into EACH extract → strip-all-then-re-tag.
  • F6 — README/metadata reference rot. Every plugin README.md:7 links ../../README.md (AI-disclosure) → 404 standalone; root-only .mailmap/.gitleaks.* don't travel with --path plugins/x; ai-psychosis/plugin.json already points at a standalone URL while others point at the monorepo (reconcile).

The five §9 open questions — RESOLVED with evidence

  1. Exact filter-repo invocation + shared/ history into DS. For non-renamed plugins: git filter-repo --path plugins/<name>/ --path-rename plugins/<name>/: (≡ --subdirectory-filter plugins/<name>). For renamed plugins: explicit multi---path with a --path-rename per old+new name (F1). DS: git filter-repo --subdirectory-filter sharedyes, preserve shared/ history into the DS repo (clean: 10/12 shared/-touching commits are pure-shared, 2 are single-plugin-entangled but conflict-free). All extractions run on a git clone --no-local fresh clone (avoids filter-repo's spurious freshness abort on local clones; do not paper over with --force).
  2. True DS consumers. Exactly 2llm-security, ms-ai-architect (only two with a playground/vendor/ dir). claude-design + human-friendly-style have no playground/ → don't need DS. The "6" in shared/PLAYGROUND-MAINTENANCE.md:5 is stale (one entry, ultraplan-local, is the ghost pre-rename name for voyage). DS-repo standup scope = 2.
  3. Repo visibility. Public (recommended) — sidesteps the Forgejo no-auto-update-token gap (F2), matches the existing open/ model, lets HTTPS clone work without credential setup. Operator confirms at the window.
  4. playground-examples/ home. DS repo. It is DS-coupled (every HTML references playground-design-system; PLAYGROUND-MAINTENANCE.md anchors ros-lier-kommune.html to ms-ai-architect renderers). Travels with the DS repo, not the thin catalog.
  5. Per-repo CHANGELOG. Carry existing — filter-repo preserves each plugin's CHANGELOG.md and its commit history (D2); tags are re-created cleanly per repo (F5).

Architecture Diagram

graph TD
    subgraph BEFORE["Monorepo (today) — open/ktg-plugin-marketplace"]
        MJ0[".claude-plugin/marketplace.json<br/>10x ./plugins/x"]
        PLUGS["plugins/ x10"]
        SH["shared/<br/>design-system + examples"]
        SYNC["scripts/sync-design-system.mjs"]
        SYNC -->|vendors| PLUGS
        SH -->|source| SYNC
    end

    subgraph AFTER["Polyrepo (target) — same URL"]
        CAT["open/ktg-plugin-marketplace<br/>THIN CATALOG<br/>marketplace.json (external+ref) · README · GOVERNANCE · CONVENTIONS · .mailmap · .gitleaks"]
        DS["open/playground-design-system<br/>design-system + examples + sync-design-system.mjs(--source/--check)"]
        R1["open/voyage (v5.1.1)"]
        R2["open/llm-security (v7.7.2)"]
        R3["open/linkedin-studio (v0.4.0)"]
        Rn["open/<plugin> ... x10 total"]
        CAT -.->|url + ref pin HTTPS| R1
        CAT -.->|url + ref pin HTTPS| R2
        CAT -.->|url + ref pin HTTPS| R3
        CAT -.->|url + ref pin HTTPS| Rn
        DS -->|vendor via re-homed sync| R2
        DS -->|vendor via re-homed sync| Rn
    end

    BEFORE ==>|filter-repo extraction<br/>NULL push until window| AFTER

Codebase Analysis

  • Tech stack: Node.js .mjs scripts (zero npm deps house style, node:test), bash 3.2 hooks/ validators, one TypeScript analytics subtree (linkedin-studio/scripts/analytics, needs npm install + tsx). Git on Forgejo (git.fromaitochitta.com, org open/). 1890 source files, 741 commits, .git 144 MB.
  • Key patterns: Each plugin self-contained (.claude-plugin/plugin.json + README + CLAUDE.md, optional hooks/ tests/ scripts/ playground/). Hooks resolve via ${CLAUDE_PLUGIN_ROOT} (relocation-invariant — confirmed). DS is vendored (playground/vendor/), never live-linked.
  • Relevant files (verified):
    • .claude-plugin/marketplace.json — 10 entries, all source: "./plugins/<name>", entry shape {name, source, description} (no version/ref today).
    • scripts/sync-design-system.mjs — SOURCE at :26-27 (MARKETPLACE_ROOT/shared/…), target at :146 (plugins/<name>/playground/vendor/…), SHA-256 MANIFEST.json + drift-guard at :118-127,151-156. No --source flag today (parseArgs :30-46).
    • .gitignoreplugins/*/ patterns at :9 (plugins/*/.claude/) and :12 (plugins/*/reports/*-beskrivelse.*); global patterns (STATE.md, *.local.md, …) stay.
    • .gitleaksignore — 2 plugin-prefixed fingerprints (:2 llm-security, :5 linkedin-studio); .gitleaks.toml allowlist :13 already plugin-relative.
    • plugins/linkedin-studio/scripts/analytics/src/utils/storage.ts:18-33,48 — marker resolver (standalone-safe primary) + ../../../../ fallback (latent depth assumption, dead branch).
    • plugins/voyage/tests/helpers/hook-helper.mjs:4 — verbatim copy (C7), stays a copy.
  • Reusable code: the existing SHA-256 MANIFEST.json + detectDrift in sync-design-system.mjs already implements SC5's hash mechanism — extend, don't rebuild. The validate-plugin.sh structure-validator in claude-design/tests/ + ms-ai-architect/tests/ is the pattern to port to the two test-less plugins (okr, human-friendly-style).
  • Recent git activity: HEAD f2d41c8 (ratified brief, 3 commits ahead of origin, unpushed). Only main, no feature branches. Cross-plugin bleed commits confirmed (f460814 touches 10 plugins; 69610d4 2) — ~1.8% of commits span 3+ plugins, mostly marketplace-wide doc sweeps.

Research Sources

Topic Source Key findings Confidence
git filter-repo subdir extract git-filter-repo man page --subdirectory-filter X--path X/ --path-rename X/:; removes origin; rewrites tags; aborts on non-fresh clone High
Local-clone freshness filter-repo #38 git clone --no-local from a local source (not --force) High
marketplace.json external/mixed/ref plugin-marketplaces, plugins-reference discriminator is source not type; {source:"url",url,ref}; mixed in-repo+external in one file supported; /plugin marketplace update refreshes a bumped ref High
SSH rejection claude-code #9740 (open) marketplace add rejects SSH URLs → use HTTPS High (use HTTPS)
Forgejo auto-update token plugin-marketplaces token table no FORGEJO_TOKEN for bg auto-update of private repos → prefer public Medium
Forgejo repo create Forgejo api-usage POST /api/v1/orgs/{org}/repos, header Authorization: token …, body {name,private,auto_init:false,default_branch} High

Implementation Plan

Execution invariants (apply to every step):

  • D8 — NULL PUSH. Every git commit here is local only; no git push until the authorized operator window. Checkpoints are local commits held on main ahead of origin.
  • All extraction runs from a git clone --no-local fresh clone in a throwaway workspace ($WORK, default /tmp/polyrepo-migration), never the working checkout (R4). The working repo is read-only to every extraction.
  • Migration tooling lives under docs/marketplace-polyrepo-migration/migration/ so it sits beside the brief/plan and is removed/archived at thinning, never polluting the live catalog.
  • The single source of truth for per-plugin paths/versions/flags is migration/plugin-map.json (Step 1); every later script reads it.

Step 1: Preflight, archive tag, and the plugin map

  • Files: docs/marketplace-polyrepo-migration/migration/00-preflight.sh, docs/marketplace-polyrepo-migration/migration/plugin-map.json, docs/marketplace-polyrepo-migration/migration/00-preflight.test.mjs
  • Changes: (new files) 00-preflight.sh asserts tooling and repo hygiene: git filter-repo --version returns a real version, python3 --version ≥ 3.6, HEAD is f2d41c8 or a descendant on main, origin/main is behind (unpushed state expected). Hygiene check is relaxed and idempotent (M12 fix): it aborts only if git status --porcelain plugins shared .claude-plugin/marketplace.json scripts is non-empty (uncommitted change to a migration-sensitive surface) — committed migration/ files and unrelated untracked docs are tolerated, so re-running preflight mid-migration does not abort. It creates the local annotated tag pre-polyrepo-archive at HEAD (D2; pushed later in the window) idempotently. It establishes the dedicated extraction source (R4 / M6 fix): git clone --no-local "$REPO_ROOT" "$WORK/_mirror" once, at the pinned HEAD — $MIRROR is the single source every per-target extraction clones from, so concurrent commits to the operator's working checkout can never affect any extraction (the working checkout is never read by filter-repo). It also enumerates blob-strip candidates (M4 fix): lists every blob >1 MB in ms-ai-architect history with its path, and emits blob_strip_safe: true into plugin-map.json iff all >1 MB blobs are under playground/screenshots/ (else false → the surgical --path-glob strategy is selected instead of size-strip, decided deterministically, no runtime prompt). plugin-map.json encodes, per extraction target: the rename-aware --path list, the --path-rename map, the release tag (from plugin.json version), the new HTTPS repo URL, has_vendor_ds (true only for llm-security + ms-ai-architect), blob_strip + blob_strip_safe (ms-ai-architect), test_cmd, and standalone_caveat. Includes the DS target (shared/open/playground-design-system) and an explicit drop: ["plugins/ultra-cc-architect/"] note (removed plugin, already its own repo — its history is dropped, not extracted).
  • Reuses: version strings from each plugins/<name>/.claude-plugin/plugin.json (task-finder table); churn order from git-historian (voyage→llm-security→linkedin-studio→ms-ai-architect).
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/00-preflight.test.mjs (new)
    • Verifies: plugin-map.json parses; has exactly 11 targets (10 plugins + DS); the 3 renamed plugins carry ≥2 --path entries each; every plugin's repo_url is https:// (not ssh://); ms-ai-architect has blob_strip: true; only llm-security + ms-ai-architect have has_vendor_ds: true.
    • Pattern: plugins/voyage/tests/validators/*.test.mjs (node:test style)
  • Verify: bash docs/marketplace-polyrepo-migration/migration/00-preflight.sh --dry-run → expected: prints PREFLIGHT OK and the 11-target table, creates no tag in --dry-run; then node --test docs/marketplace-polyrepo-migration/migration/00-preflight.test.mjs → expected: pass.
  • On failure: escalate — if tooling is missing (git filter-repo absent), stop and instruct brew install git-filter-repo; do not proceed.
  • Checkpoint: git commit -m "chore(migration): preflight script + rename-aware plugin map"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/00-preflight.sh
        - docs/marketplace-polyrepo-migration/migration/plugin-map.json
        - docs/marketplace-polyrepo-migration/migration/00-preflight.test.mjs
      min_file_count: 3
      commit_message_pattern: "^chore\\(migration\\): preflight script \\+ rename-aware plugin map$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/00-preflight.sh
      forbidden_paths:
        - .claude-plugin/marketplace.json
        - scripts/sync-design-system.mjs
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/plugin-map.json
          pattern: "ultraplan-local"
        - path: docs/marketplace-polyrepo-migration/migration/plugin-map.json
          pattern: "https://git.fromaitochitta.com/open/"
    

Step 2: Re-home and extend sync-design-system.mjs (--source + --check)

  • Files: scripts/sync-design-system.mjs, scripts/sync-design-system.test.mjs
  • Changes: Add a --source <dir> flag so the script vendors from an arbitrary DS checkout (the re-homed DS repo) instead of the hardcoded MARKETPLACE_ROOT/shared/playground-design-system (:26-27); keep the in-repo path as the default fallback so the script still works pre-migration. Add a --check mode that re-hashes a target plugin's vendored tree against its committed MANIFEST.json and exits non-zero on drift, with no source required — making SC5 a single runnable command in a clean clone (D3). Resolve the plugin/target dir from a --target <dir> arg when MARKETPLACE_ROOT/plugins/<name> no longer exists. This is migration rewiring, not a plugin-functionality change (non-goal §5 untouched). The modified script + test travel into the DS repo at standup (Step on the DS target) and are removed from the catalog at thinning (Step 9).
  • Reuses: existing detectDrift + SHA-256 MANIFEST.json writer (:118-127) — --check reuses the same hashing.
  • Test first:
    • File: scripts/sync-design-system.test.mjs (new)
    • Verifies: --source <tmpDS> vendors byte-identically to the default in-repo source (SC5 parity); --check passes on an unmodified vendored tree and fails (exit 2) after a tampered byte; omitting --source still resolves the in-repo default.
    • Pattern: plugins/voyage/tests/scripts/*.test.mjs
  • Verify: node --test scripts/sync-design-system.test.mjs → expected: pass (all assertions); then node scripts/sync-design-system.mjs --check ms-ai-architect → expected: MANIFEST OK against the current vendored tree.
  • On failure: revert — git checkout -- scripts/sync-design-system.mjs scripts/sync-design-system.test.mjs; the legacy in-repo sync still works, so this is non-blocking for the rest until retried.
  • Checkpoint: git commit -m "feat(migration): sync-design-system --source + --check for DS re-home"
  • Manifest:
    manifest:
      expected_paths:
        - scripts/sync-design-system.mjs
        - scripts/sync-design-system.test.mjs
      min_file_count: 2
      commit_message_pattern: "^feat\\(migration\\): sync-design-system --source \\+ --check for DS re-home$"
      bash_syntax_check: []
      forbidden_paths:
        - .claude-plugin/marketplace.json
      must_contain:
        - path: scripts/sync-design-system.mjs
          pattern: "--source"
        - path: scripts/sync-design-system.mjs
          pattern: "--check"
    

Step 3: Rename-aware extraction driver

  • Files: docs/marketplace-polyrepo-migration/migration/10-extract.sh, docs/marketplace-polyrepo-migration/migration/10-extract.test.mjs
  • Changes: (new files) Given a target key from plugin-map.json, it: git clone --no-local "$WORK/_mirror" "$WORK/<key>" (clones from the dedicated mirror built in Step 1, never the working checkout — R4/M6); runs a single git filter-repo invocation that composes all history rewrites in one pass (M5 fix — avoids the second-pass --force abort): the rename-aware --path/--path-rename set from the map (single path for non-renamed; both names for voyage/llm-security/linkedin-studio — F1) plus, for ms-ai-architect, the blob strip selected deterministically from blob_strip_safe (Step 1): --strip-blobs-bigger-than 1M when all >1 MB blobs are screenshots, else --path-glob '!plugins/ms-ai-architect/playground/screenshots/*' — no runtime prompt (M4/F3). filter-repo composes --path, --path-rename, and --strip-blobs-bigger-than in one invocation (verified against the man page). After the filter completes (so the tag points at the final rewritten HEAD — M5 ordering fix), it strips every tag filter-repo carried over and re-creates exactly one annotated tag v<version> at HEAD (F5). Idempotent: re-running wipes $WORK/<key> first. No push.
  • Reuses: plugin-map.json (Step 1); git clone --no-local per research-scout.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/10-extract.test.mjs (new)
    • Verifies (runs the driver against the live monorepo into a temp $WORK): the voyage extract's git log --all contains commits whose original paths were under both plugins/voyage/ and plugins/ultraplan-local/ (≥150 commits, proving F1 handled); the extract git tag lists exactly v5.1.1 and nothing else; the extract has no plugins/ prefix at root (contents at repo root); origin remote is absent post-filter.
    • Pattern: integration test style in plugins/voyage/tests/integration/*.test.mjs
  • Verify (self-contained — M6): bash docs/marketplace-polyrepo-migration/migration/00-preflight.sh && bash docs/marketplace-polyrepo-migration/migration/10-extract.sh voyage (preflight builds $WORK/_mirror if absent; extract is self-healing) → expected: EXTRACT OK voyage → $WORK/voyage (257 commits, tag v5.1.1); then git -C "$WORK/voyage" log --oneline | wc -l → expected: ≥ 250; git -C "$WORK/voyage" tag → expected: exactly v5.1.1.
  • On failure: retry — if filter-repo aborts on freshness, confirm the source is $WORK/_mirror (a fresh --no-local clone); if a rename path is missing, the test catches the dropped-history count. Revert leaves no working-tree change (extract is in $WORK).
  • Checkpoint: git commit -m "chore(migration): rename-aware filter-repo extraction driver"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/10-extract.sh
        - docs/marketplace-polyrepo-migration/migration/10-extract.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): rename-aware filter-repo extraction driver$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/10-extract.sh
      forbidden_paths:
        - .claude-plugin/marketplace.json
        - plugins
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/10-extract.sh
          pattern: "no-local"
        - path: docs/marketplace-polyrepo-migration/migration/10-extract.sh
          pattern: "ultraplan-local"
    

Step 4: Per-repo config re-rooting (.gitignore, .gitleaks, .mailmap)

  • Files: docs/marketplace-polyrepo-migration/migration/20-rehome-config.sh, docs/marketplace-polyrepo-migration/migration/templates/gitignore.plugin.tmpl, docs/marketplace-polyrepo-migration/migration/20-rehome-config.test.mjs
  • Changes: (new files) Operates on an extracted repo in $WORK/<key>: writes a re-rooted .gitignore from the template (the plugins/*/ prefixes stripped → .claude/, reports/*-beskrivelse.*; the global STATE.md/*.local.md/OS patterns kept — F6/C3); generates a per-plugin .gitleaks.toml from the root baseline and a .gitleaksignore with the matching fingerprint re-rooted by dropping ONLY the plugins/<name>/ path prefix and keeping the full :rule-id:line suffix (M7 fix — the gitleaks fingerprint format is path:rule-id:line, not path:line; dropping the rule-id yields a fingerprint that no longer suppresses) — only for llm-security (examples/malicious-skill-demo/evil-project-health/lib/telemetry.mjs:generic-api-key:18) and linkedin-studio (hooks/prompts/content-quality-gate.md:linkedin-client-id:14); copies the root .mailmap into every extract (F6 — it does not travel with --path). For the DS target it also copies the modified sync-design-system.mjs + test (Step 2) and shared/PLAYGROUND-MAINTENANCE.md + playground-examples/ into the DS repo root (§9 Q4).
  • Reuses: root .gitignore (:9,:12), .gitleaks.toml (:13), .gitleaksignore (:2,:5), .mailmap.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/20-rehome-config.test.mjs (new)
    • Verifies (against a freshly extracted llm-security in $WORK): the new .gitignore contains .claude/ but no plugins/*/ prefix; the re-rooted .gitleaksignore fingerprint is exactly examples/malicious-skill-demo/evil-project-health/lib/telemetry.mjs:generic-api-key:18 (no plugins/llm-security/ prefix, rule-id generic-api-key retained); .mailmap exists; a plugin with no fingerprint (graceful-handoff) gets a .gitleaks.toml but an empty/absent .gitleaksignore.
    • Pattern: plugins/config-audit/tests/lib/*.test.mjs
  • Verify: bash …/20-rehome-config.sh llm-security then git -C /tmp/polyrepo-migration/llm-security check-ignore .claude/x → expected: .claude/x (ignored); grep -c 'plugins/' /tmp/polyrepo-migration/llm-security/.gitleaksignore → expected: 0.
  • On failure: retry — config generation is idempotent; revert leaves working tree clean.
  • Checkpoint: git commit -m "chore(migration): per-repo gitignore/gitleaks/mailmap re-rooting"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/20-rehome-config.sh
        - docs/marketplace-polyrepo-migration/migration/templates/gitignore.plugin.tmpl
        - docs/marketplace-polyrepo-migration/migration/20-rehome-config.test.mjs
      min_file_count: 3
      commit_message_pattern: "^chore\\(migration\\): per-repo gitignore/gitleaks/mailmap re-rooting$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/20-rehome-config.sh
      forbidden_paths:
        - .gitignore
        - .gitleaks.toml
        - .gitleaksignore
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/20-rehome-config.sh
          pattern: "mailmap"
    
  • Files: docs/marketplace-polyrepo-migration/migration/30-fix-references.mjs, docs/marketplace-polyrepo-migration/migration/30-fix-references.test.mjs
  • Changes: (new files) Operates on an extracted repo in $WORK/<key>: replaces the [Full disclosure →](../../README.md#ai-generated-code-disclosure) footnote at every plugin README.md:7 with the inlined disclosure text (M13 — inline chosen over a cross-repo link: deterministic, self-contained, no dangling anchor); fixes graceful-handoff/README.md:355 and any other ../../README.md / ../../.claude-plugin/marketplace.json reference (e.g. linkedin-studio remediation docs); sets plugin.json repository and package.json homepage/repository to the new standalone open/<plugin> HTTPS URL. Scope note (M14): ai-psychosis/plugin.json already points at open/ai-psychosis (verified) — for it this is a verify-only no-op; the real reconcile work is the 9 other plugins still pointing at the monorepo URL/subpath (e.g. linkedin-studio, llm-security package.json homepage). Reads URLs from plugin-map.json. Reports every file it rewrote.
  • Reuses: plugin-map.json repo URLs.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/30-fix-references.test.mjs (new)
    • Verifies (against an extracted graceful-handoff): no ../../README.md substring remains in any *.md; plugin.json.repository ends with /open/graceful-handoff; an idempotent second run makes zero changes.
    • Pattern: plugins/linkedin-studio/render/__tests__/*.test.mjs
  • Verify: node …/30-fix-references.mjs graceful-handoff then grep -rn "\.\./\.\./README.md" /tmp/polyrepo-migration/graceful-handoff → expected: no matches (exit 1 from grep).
  • On failure: retry — rewriter is idempotent and reports diffs; revert leaves working tree clean.
  • Checkpoint: git commit -m "chore(migration): standalone reference-rot rewriter"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/30-fix-references.mjs
        - docs/marketplace-polyrepo-migration/migration/30-fix-references.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): standalone reference-rot rewriter$"
      bash_syntax_check: []
      forbidden_paths:
        - plugins
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/30-fix-references.mjs
          pattern: "ai-generated-code-disclosure"
    

Step 6: Standalone validation harness (SC2 + SC7)

  • Files: docs/marketplace-polyrepo-migration/migration/40-validate-standalone.sh, docs/marketplace-polyrepo-migration/migration/40-validate-standalone.test.mjs
  • Changes: (new files) Per plugin-map.json test_cmd: clones the extracted repo to a clean /tmp/claude-* dir (no marketplace parent) and runs its self-validate command in the runner the plugin itself declaresnode --test 'tests/**/*.test.mjs' (quoted globstar, Node-≥21 native; config-audit/llm-security/voyage) or node --test <dir>/*.test.mjs glob for plugins with a flat layout — never node --test <dir> (Node 25 gotcha). Per-plugin caveats baked in: ai-psychosis runs from repo root (relative-fixture cwd); linkedin-studio SC2 is explicitly two-tier (M11): the .mjs core (node --test hooks/scripts/__tests__/*.test.mjs render/__tests__/*.test.mjs agents/__tests__/*.test.mjs) is the hard gate (must pass with zero deps); the TS analytics suite (cd scripts/analytics && npm install && npm test) is reported as a separate ADVISORY line — network-required, NOT a clean-room hard gate (not silently waived, explicitly labelled non-blocking for SC2). claude-design + ms-ai-architect run bash tests/validate-plugin.sh; okr + human-friendly-style run the ported validate-plugin.generic.sh structure check (Step 7 provides it) since they have no unit suite (H2). voyage/.forgejo/ survival (M17): for voyage, assert the extract still contains .forgejo/ISSUE_TEMPLATE/ (it travels under --path plugins/voyage/) and that no file in it references a monorepo-relative path (it holds only issue templates, no workflows — confirmed). SC7 assertions for every repo: git ls-files | grep -E 'STATE\.md|\.local\.md$' is empty except the intentional tracked okr/templates/okr.local.md.template; no ../../README.md remains; .gitignore ignores .claude/. Emits a per-repo PASS/FAIL table.
  • Reuses: the per-plugin command table (test-strategist); validate-plugin.sh pattern.
  • Test first:
    • File: (the harness is itself the test surface) — assert via a smoke run that the harness exits non-zero when fed a deliberately broken extract (a planted tracked STATE.md) and zero on a clean one. Encoded as docs/marketplace-polyrepo-migration/migration/40-validate-standalone.test.mjs.
    • Verifies: harness FAILs a repo with a tracked *.local.md; PASSes a clean repo; uses glob test form (asserts the command string contains *.test.mjs, not a bare dir).
    • Pattern: plugins/voyage/tests/synthetic/*.test.mjs
  • Verify: bash …/40-validate-standalone.sh graceful-handoff → expected: graceful-handoff: PASS (6 tests, standalone-safe); node --test …/40-validate-standalone.test.mjs → expected: pass.
  • On failure: escalate per repo — a FAIL is a genuine SC2/SC7 blocker for that plugin (e.g. config-audit until Step 7); record it, continue other repos, do not mask.
  • Checkpoint: git commit -m "chore(migration): standalone SC2/SC7 validation harness"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/40-validate-standalone.sh
        - docs/marketplace-polyrepo-migration/migration/40-validate-standalone.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): standalone SC2/SC7 validation harness$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/40-validate-standalone.sh
      forbidden_paths:
        - plugins
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/40-validate-standalone.sh
          pattern: "\\*.test.mjs"
    

Step 7: Resolve the config-audit SC2 blocker + port a structure-validator to okr/human-friendly-style

  • Files: docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.sh, docs/marketplace-polyrepo-migration/migration/templates/validate-plugin.generic.sh, docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.test.mjs
  • Changes: (new files) config-audit (F4 — corrected against verified test code): config-audit declares its runner as node --test 'tests/**/*.test.mjs' (52 files, CLAUDE.md:109). Two distinct portability defects exist (both verified directly): (a) The rebasable suite snapshot-default-output.test.mjs asserts byte-equal CLI output whose deep per-file paths are not normalized (normalizeScanOrchestrator scrubs only meta.target/timestamp/duration_ms, normalizeTokenHotspots only duration_ms), so it breaks at a new clone path. Fix: the script runs UPDATE_SNAPSHOT=1 node --test tests/snapshot-default-output.test.mjs inside the clean clone to re-seed those snapshots at the clone's own path — it then passes (the seam exists, line 33; this is the intended re-approval path). (b) The frozen back-compat tests json-backcompat.test.mjs + raw-backcompat.test.mjs load the tests/snapshots/v5.0.0/ fixtures, which embed the original capture machine's absolute path + a sibling marketplace (ktg-privat) + deleted plugins — regenerating them would defeat their byte-stability purpose. Fix: these two files are excluded by name from config-audit's SC2 gate, which runs over the file list tests/**/*.test.mjs minus those two (deterministic file enumeration, no invented env var). The exclusion + rationale is recorded in the config-audit repo's test README. This edges the §5 non-goal "NOT changing plugin code" (it re-seeds fixtures + names an exclusion; no functionality changes) — surfaced as a brief-correction requiring operator ratification (header) + Assumption 4. okr + human-friendly-style (H2): validate-plugin.generic.sh is a ported, parameterized copy of the existing claude-design/tests/validate-plugin.sh (valid plugin.json, expected command/skill/agent files present, frontmatter parses) so SC2 has a runnable gate for the two test-less plugins.
  • Reuses: plugins/claude-design/tests/validate-plugin.sh (the pattern); config-audit's own UPDATE_SNAPSHOT=1 seam (line 33) + declared runner (CLAUDE.md:109).
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.test.mjs (new)
    • Verifies: after the script runs against an extracted config-audit, the SC2 gate (full tests/**/*.test.mjs minus json-backcompat + raw-backcompat) passes; the gate file list does NOT include the two excluded files; the generic validator PASSes okr and FAILs a plugin with a deleted plugin.json.
    • Pattern: plugins/config-audit/tests/snapshot-default-output.test.mjs (the rebasable one)
  • Verify: bash …/50-config-audit-sc2.sh then in the extracted clone the emitted SC2 gate command (e.g. node --test $(ls tests/**/*.test.mjs | grep -vE 'json-backcompat|raw-backcompat')) → expected: pass; bash validate-plugin.generic.sh okr → expected: okr: STRUCTURE OK.
  • On failure: escalate — surface to the operator the choice: accept config-audit SC2 as "full suite minus 2 frozen back-compat tests" (recommended), or invest in normalizing the v5.0.0 snapshots' embedded paths (config-audit's own backlog, out of migration scope).
  • Checkpoint: git commit -m "fix(migration): config-audit SC2 gate (re-seed + back-compat exclusion) + generic validator"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.sh
        - docs/marketplace-polyrepo-migration/migration/templates/validate-plugin.generic.sh
        - docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.test.mjs
      min_file_count: 3
      commit_message_pattern: "^fix\\(migration\\): config-audit SC2 gate \\(re-seed \\+ back-compat exclusion\\) \\+ generic validator$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.sh
        - docs/marketplace-polyrepo-migration/migration/templates/validate-plugin.generic.sh
      forbidden_paths:
        - plugins/config-audit
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.sh
          pattern: "UPDATE_SNAPSHOT"
        - path: docs/marketplace-polyrepo-migration/migration/50-config-audit-sc2.sh
          pattern: "json-backcompat"
    

Step 8: marketplace.json rewriter (mixed-source aware, HTTPS + ref pin)

  • Files: docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs, docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs
  • Changes: (new files — authored, not run against the live file until the window) Rewrites a marketplace.json so a named subset of entries become external {"source":"url","url":"https://git.fromaitochitta.com/open/<name>.git","ref":"v<version>"} (F2 — HTTPS + discriminator source, ref = the plugin's release tag, D4) while the rest stay ./plugins/<name> — enabling the mixed-source intermediate states that keep the marketplace live (SC3/SC8). --only <names> flips a subset; --all flips everything (final state). Reads versions from plugin-map.json; validates output against the marketplace schema (each entry has name+source+description; external entries have a valid url+ref). Writes to a --out path by default (never the live file) so Step 11's dry-run exercises it without violating D8.
  • Reuses: plugin-map.json; the current .claude-plugin/marketplace.json as the input shape.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs (new)
    • Verifies: --only voyage produces a file where voyage is an external url+ref:v5.1.1 entry and the other 9 stay ./plugins/… (mixed-source proven); every external url starts https:// (no ssh://); --all leaves zero ./plugins/ sources; schema-validates.
    • Pattern: plugins/voyage/tests/parsers/*.test.mjs
  • Verify: node …/60-rewrite-marketplace.mjs --only voyage --out /tmp/mp-voyage.json then grep -c '"./plugins/' /tmp/mp-voyage.json → expected: 9; grep -c 'ssh://' /tmp/mp-voyage.json → expected: 0.
  • On failure: retry — pure transform, idempotent; revert leaves working tree clean.
  • Checkpoint: git commit -m "chore(migration): mixed-source marketplace.json rewriter (HTTPS+ref)"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs
        - docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): mixed-source marketplace.json rewriter \\(HTTPS\\+ref\\)$"
      bash_syntax_check: []
      forbidden_paths:
        - .claude-plugin/marketplace.json
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs
          pattern: "https://git.fromaitochitta.com/open/"
    

Step 9: Catalog-thinning script (staged for the window)

  • Files: docs/marketplace-polyrepo-migration/migration/70-thin-catalog.sh, docs/marketplace-polyrepo-migration/migration/70-thin-catalog.test.mjs
  • Changes: (new files — staged, not run until the window) Produces the thin-catalog state in a workspace: removes plugins/ + shared/ + scripts/sync-design-system.mjs (+ its test); extracts the marketplace-wide conventions from the root CLAUDE.md into a new CONVENTIONS.md (D6); thins the catalog CLAUDE.md to catalog-maintenance only; rewrites the landing README.md to reference the external plugin repos. The landing-README rewrite is the place the stale version/count drift is corrected (M15 — coupled explicitly, not a side ride-along): D6 mandates the README rewrite, and a rewrite that re-states the plugin roster MUST state the verified counts/versions (10 plugins, not ~8; linkedin-studio 0.4.0 not "4.1.0"; config-audit 5.1.0; voyage 5.1.1; …) or it ships a known-false document. The correction is therefore part of the D6 README deliverable, coupled to it, not a separable doc-fix. Verifies the result: ls shows no plugins/, no shared/ (SC4); the catalog retains marketplace.json, README.md, GOVERNANCE.md, CONVENTIONS.md, .mailmap, a .gitleaks baseline.
  • Reuses: root CLAUDE.md conventions block; GOVERNANCE.md as the conventions sibling.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/70-thin-catalog.test.mjs (new)
    • Verifies (against a workspace copy): post-thin tree has no plugins/, no shared/, no scripts/sync-design-system.mjs; CONVENTIONS.md exists and contains the Forgejo/conventional- commits/three-doc rules; marketplace.json + README.md + GOVERNANCE.md remain.
    • Pattern: plugins/config-audit/tests/*.test.mjs
  • Verify: bash …/70-thin-catalog.sh --workspace /tmp/thin-catalog then ls /tmp/thin-catalog → expected: no plugins/shared entries; test -f /tmp/thin-catalog/CONVENTIONS.md → expected: exit 0.
  • On failure: revert — operates only in --workspace; the live tree is untouched (forbidden path enforced).
  • Checkpoint: git commit -m "chore(migration): catalog-thinning script (staged for window)"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/70-thin-catalog.sh
        - docs/marketplace-polyrepo-migration/migration/70-thin-catalog.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): catalog-thinning script \\(staged for window\\)$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/70-thin-catalog.sh
      forbidden_paths:
        - plugins
        - shared
        - CLAUDE.md
        - README.md
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/70-thin-catalog.sh
          pattern: "CONVENTIONS.md"
    

Step 10: Operator-window runbook (pilot gate + batch order)

  • Files: docs/marketplace-polyrepo-migration/migration/RUNBOOK.md
  • Changes: (new file) The single document the operator follows in the authorized window. It encodes: (0) Pilot gate (B3 ordering fix — the flip MUST precede the install-smoke, else the test is vacuous). Recommend graceful-handoff (low-churn, has tests, no DS, no blob bloat). Steps, in order: (0a) create it on Forgejo (POST /api/v1/orgs/open/repos with {"name":"graceful-handoff","private":false,"auto_init":false,"default_branch":"main"}, header Authorization: token $FORGEJO_TOKEN); (0b) git remote add origin https://… && git push origin --all && git push origin --tags; (0c) flip graceful-handoff's entry in the catalog marketplace.json to the external url+ref form (60-rewrite-marketplace.mjs --only graceful-handoff) and push the catalog in its still-mixed state (9× ./plugins/x + 1× external; plugins/ still present, thinning comes last); (0d) in a fresh Claude Code session /plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git + /plugin install graceful-handoff@ktg-plugin-marketplace. **Because the entry is now external, the install resolves the Forgejo repo over HTTPS at ref: v2.1.0 — this is the actual test of the HTTPS + external-source
    • ref-pin + Forgejo chain** (F2 / Assumption 1, the highest-stakes unverified link). If 0d fails, STOP — revert the flip (graceful-handoff back to ./plugins/graceful-handoff, marketplace stays live) and diagnose before touching the other 10. (1) DS repo first, (2) high-churn (voyage → llm-security → linkedin-studio → ms-ai-architect — corrected order), (3) low-churn (config-audit, okr, ai-psychosis, human-friendly-style, claude-design), each: create repo → push → flip its marketplace.json entry to external (--only) → push catalog → install-smoke (SC8) before the next. (4) Thin the catalog last (Step 9), push catalog, push the pre-polyrepo-archive tag. Includes the visibility decision (public — §9), the auto_init:false requirement, the ref-update flow (/plugin marketplace update), the per-plugin install-smoke checklist ordered by surface (linkedin-studio 29 cmds first), and the rollback note (a flip is reversible until the ./plugins/x source is removed; never remove it before the external repo is pushed+verified — R1/SC8).
  • Reuses: all prior scripts (the runbook invokes them); research-scout's verified Forgejo/git commands.
  • Test first: (documentation deliverable — no unit test; verified by review + the dry-run in Step 11 exercising every script the runbook references)
  • Verify: test -f docs/marketplace-polyrepo-migration/migration/RUNBOOK.md && grep -q "auto_init" docs/marketplace-polyrepo-migration/migration/RUNBOOK.md && grep -q "Pilot" docs/marketplace-polyrepo-migration/migration/RUNBOOK.md → expected: exit 0.
  • On failure: revert — git checkout -- docs/marketplace-polyrepo-migration/migration/RUNBOOK.md.
  • Checkpoint: git commit -m "docs(migration): operator-window runbook (pilot gate + batch order)"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/RUNBOOK.md
      min_file_count: 1
      commit_message_pattern: "^docs\\(migration\\): operator-window runbook \\(pilot gate \\+ batch order\\)$"
      bash_syntax_check: []
      forbidden_paths:
        - .claude-plugin/marketplace.json
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/RUNBOOK.md
          pattern: "auto_init"
        - path: docs/marketplace-polyrepo-migration/migration/RUNBOOK.md
          pattern: "Pilot"
    

Step 11: Full local dry-run (all 11 targets, NULL push)

  • Files: docs/marketplace-polyrepo-migration/migration/99-dryrun.sh, docs/marketplace-polyrepo-migration/migration/99-dryrun.test.mjs
  • Changes: (new files) 99-dryrun.sh runs Steps 3→4→5→6 (+ Step 7 for config-audit/okr/ human-friendly-style) across all 11 targets (10 plugins + DS) into $WORK, then exercises Step 8's rewriter (--all--out) and Step 9's thinning (--workspace), and writes a generated dryrun-report.md (an output artifact, not committed): per-target extract commit count (F1 history retention), tag list (F5 clean re-tag), standalone PASS/FAIL (SC2), SC7 state-file check, and the marketplace.json/catalog-thin previews — with zero pushes and zero live-file mutation. It also runs the cross-repo DS-vendor validation (M9 — the gap the pilot can't cover): with both the DS extract and the ms-ai-architect extract present in $WORK, it runs the re-homed sync node scripts/sync-design-system.mjs ms-ai-architect --source "$WORK/playground-design-system" --target "$WORK/ms-ai-architect" then --check against the committed MANIFEST.json and asserts byte-parity — exercising the actual two-extracted-repo --source/--target invocation (SC5) locally, before the window. Honest scope of what the dry-run proves (M8): it proves the local mechanics — extraction, rename-aware history retention, re-rooting, reference rewrites, standalone validation, marketplace/ catalog transforms, and the DS-vendor chain. It cannot prove the window-only steps: (i) Forgejo accepting the push of post-strip history, (ii) auto_init:false repo creation via the API, (iii) Claude Code resolving an HTTPS url+ref source from self-hosted Forgejo (Assumption 1 — covered instead by the Step 10 pilot, not the dry-run). The dry-run + the pilot together gate the batch; neither alone is sufficient.
  • Reuses: every prior script.
  • Test first:
    • File: docs/marketplace-polyrepo-migration/migration/99-dryrun.test.mjs (new)
    • Verifies: dryrun-report.md lists all 11 targets; voyage shows ≥250 commits; config-audit shows PASS under its SC2 gate (full suite minus json-backcompat/raw-backcompat, after re-seed); no target shows a tracked state-file leak; the previewed all-external marketplace.json has zero ./plugins/ and zero ssh://.
    • Pattern: plugins/voyage/tests/integration/*.test.mjs
  • Verify: bash docs/marketplace-polyrepo-migration/migration/99-dryrun.sh → expected: DRY-RUN OK — 11/11 targets extracted + validated, 0 pushes; git -C $REPO_ROOT status → expected: working tree shows only the committed migration/ additions, no change to plugins/, shared/, or .claude-plugin/marketplace.json (D8 held).
  • On failure: escalate — any target FAIL is a real blocker to surface before the window; the dry-run is reversible (everything in $WORK/--out/--workspace).
  • Checkpoint: git commit -m "chore(migration): full local dry-run harness + report"
  • Manifest:
    manifest:
      expected_paths:
        - docs/marketplace-polyrepo-migration/migration/99-dryrun.sh
        - docs/marketplace-polyrepo-migration/migration/99-dryrun.test.mjs
      min_file_count: 2
      commit_message_pattern: "^chore\\(migration\\): full local dry-run harness \\+ report$"
      bash_syntax_check:
        - docs/marketplace-polyrepo-migration/migration/99-dryrun.sh
      forbidden_paths:
        - .claude-plugin/marketplace.json
        - plugins
        - shared
      must_contain:
        - path: docs/marketplace-polyrepo-migration/migration/99-dryrun.sh
          pattern: "no-local"
    

Manifest — objective completion predicate

Every step above carries a Manifest. The defining invariant across all of them: forbidden_paths always includes the live .claude-plugin/marketplace.json (and, for extraction/validation/thinning steps, plugins/ + shared/) — this is the machine-checkable enforcement of D8 (NULL push / no live-marketplace mutation) during the entire Claude-run local phase. The live catalog and the plugins source are mutated only in the operator window (Step 10's runbook), which is outside trekexecute's verified surface by design.

Failure recovery rules

  • revert — script-authoring steps: git checkout -- <files>; extraction/validation operate in $WORK so reverting is rm -rf $WORK/<key>.
  • retry — idempotent transforms (Steps 39, 11) re-run cleanly.
  • escalate — a standalone-validation FAIL (Step 6/7/11) or a missing tool (Step 1) stops and asks the operator; never mask an SC2/SC6 failure.
  • Checkpoint — each step commits locally (D8: no push), so a later failure cannot corrupt completed tooling.

Alternatives Considered

Approach Pros Cons Why rejected
--subdirectory-filter plugins/<name> for every plugin (brief's literal §9 form) Simplest one-flag invocation Silently drops 136+50+3 commits of pre-rename history for voyage/llm-security/linkedin-studio (F1) Violates SC6 for 3 high-churn plugins; multi---path is mandatory for renamed plugins
ssh://git@… url sources (brief §6) Matches the operator's SSH push workflow marketplace add rejects SSH (#9740); url schema names only https:///git@ F2 — HTTPS is the only verified-accepted form; SSH risks a dead marketplace
Hard-cut history (git init from current tree) for all repos Fast; no filter-repo Loses granular blame/history D2 ratified filter-repo preservation; only the archived monorepo keeps full history under hard-cut
Big-bang all-at-once cutover One window, less bookkeeping No reversible checkpoints; marketplace dark during cutover D5 ratified incremental mixed-source; R1
Run extraction in the working checkout No extra clone Races concurrent sessions on .git/index (R4); destructive to live tree §10 mandates a dedicated --no-local fresh clone
Skip the pilot, push all 11 then test Fewer window steps If Forgejo HTTPS/external-source resolution fails, all 11 are already pushed F2 — the Forgejo external-source chain is the highest-stakes unverified link; pilot de-risks it on 1 repo

Test Strategy

  • Framework: Node node:test (glob form node --test path/*.test.mjs — Node 25 gotcha), zero npm deps (house style); bash validate-plugin.sh for structure checks.
  • Existing patterns: per-plugin tests/**/*.test.mjs; claude-design/ms-ai-architect validate-plugin.sh; config-audit UPDATE_SNAPSHOT rebasing seam; the SHA-256 MANIFEST.json + detectDrift in sync-design-system.mjs.
  • New tests in this plan: one test per script step (Steps 19, 11), each asserting the script's contract against the live monorepo or a freshly-extracted repo in $WORK — so the tooling is proven before the dry-run, and the dry-run (Step 11) is the integration test over all 11 targets.

Tests to write

Type File Verifies Model test
Unit migration/00-preflight.test.mjs plugin-map shape, rename-awareness, HTTPS URLs voyage/tests/validators/*.test.mjs
Unit scripts/sync-design-system.test.mjs --source parity, --check drift voyage/tests/scripts/*.test.mjs
Integration migration/10-extract.test.mjs renamed-plugin history retained, clean re-tag voyage/tests/integration/*.test.mjs
Unit migration/20-rehome-config.test.mjs re-rooted gitignore/gitleaks, mailmap copied config-audit/tests/lib/*.test.mjs
Unit migration/30-fix-references.test.mjs no ../../README.md, URLs reconciled linkedin-studio/render/__tests__/*.test.mjs
Integration migration/40-validate-standalone.test.mjs SC2/SC7 gate fails on planted leak voyage/tests/synthetic/*.test.mjs
Unit migration/50-config-audit-snapshots.test.mjs portable snapshots pass; generic validator config-audit/tests/snapshot-default-output.test.mjs
Unit migration/60-rewrite-marketplace.test.mjs mixed-source, HTTPS, ref pin voyage/tests/parsers/*.test.mjs
Unit migration/70-thin-catalog.test.mjs no plugins/shared, CONVENTIONS.md present config-audit/tests/*.test.mjs
Integration migration/99-dryrun.test.mjs 11/11 targets, SC2/SC6/SC7, NULL push voyage/tests/integration/*.test.mjs

Risks and Mitigations

Priority Risk Location Impact Mitigation
Critical Renamed-plugin history silently dropped (F1) 10-extract.sh SC6 fails; voyage/linkedin-studio lose 3060% history Multi---path per plugin-map.json; Step 3 test asserts commit count from both names
Critical Forgejo HTTPS external-source resolution unverified (F2) window / RUNBOOK.md Marketplace could go dark if url source can't resolve self-hosted Forgejo Pilot gate (Step 10) proves the chain on 1 repo before the other 10; HTTPS not SSH; public repos
High ms-ai-architect 148 MB blob-bomb (F3) 10-extract.sh blob_strip Extracted repo bloated; slow push --strip-blobs-bigger-than 1M (default, operator-confirmed); decision surfaced in Step 3
High config-audit snapshots embed absolute path (F4) Step 7 SC2 blocker UPDATE_SNAPSHOT=1 re-seed of the rebasable snapshot-default-output in-clone; by-name exclude the 2 frozen back-compat tests (json-backcompat/raw-backcompat) from the SC2 gate; flagged for operator ratification (edges §5)
High Tag collision/leak across extracts (F5) 10-extract.sh Wrong tags in each repo; ambiguous v5.0.0 Strip all tags, re-create exactly v<version>; Step 3 test asserts single tag
High Marketplace goes dark mid-migration (R1/SC8) window Broken installs Mixed sources (Step 8 --only); never remove ./plugins/x before external repo verified; install-smoke each step
Medium README/metadata reference rot (F6) 30-fix-references.mjs 404 disclosure links, wrong repo URLs standalone Rewriter inlines disclosure + reconciles repository/homepage; Step 5 test asserts none remain
Medium Root-only .mailmap/.gitleaks.* don't travel (F6/M5) 20-rehome-config.sh Lost author dedup; re-flagged false positives Copy .mailmap; generate per-plugin .gitleaks with re-rooted fingerprints
Medium Concurrent-session corruption during surgery (R4) all extraction Index race in working tree git clone --no-local fresh clone; working checkout read-only; quiesce other writers in the window
Medium Untracked non-gitignored files leak on blanket add (M2) extraction progress.json + 2 briefs enter an extract filter-repo is history-based (untracked files excluded); Step 6 SC7 check catches leaks
Low okr.local.md.template is intentionally tracked Step 6 False SC7 failure Explicit allow-exception in the SC7 check
Low Forgejo no auto-update token for private repos (F2) window Background update of a private marketplace unsupported Recommend public repos (§9); manual /plugin marketplace update otherwise

Assumptions

# Assumption Why unverifiable Impact if wrong
1 Claude Code resolves a self-hosted Forgejo url source over HTTPS with a pinned ref Not testable until a repo exists on Forgejo (push is operator-gated, D8) If false, the whole external-source model fails — the Step 10 pilot is the explicit test that gates the rest
2 git filter-repo carries each plugin's CHANGELOG.md + history intact under multi---path filter-repo tag/history edge-cases ("tags not on path") lack a crisp documented rule Verified empirically by Step 3/11 tests (commit counts, tag list) before the window
3 Stripping blobs >1 MB from ms-ai-architect loses only screenshots, not source Depends on whether any non-screenshot blob exceeds 1 MB Step 3 surfaces the strip list as a decision; operator confirms before the window
4 Excluding config-audit's 2 frozen back-compat tests (json-backcompat/raw-backcompat) from SC2, plus re-seeding snapshot-default-output in-clone, is acceptable (edges §5 non-goal) A judgment on "what SC2 means" for fixtures that embed the original capture machine's path + a sibling marketplace + deleted plugins Flagged for operator ratification (header + Step 7); fallback = the config-audit-internal work of normalizing the v5.0.0 snapshot paths (out of migration scope)
5 Public visibility is acceptable for all open/ plugin repos Visibility is a policy choice, not technical Operator confirms at the window; private repos lose background auto-update (F2)

Verification

Per-step manifests run during execution (Steps 111). These are the cross-step, end-to-end checks mapping to the brief's SC1SC8.

  • SC6 (F1): git -C /tmp/polyrepo-migration/voyage log --oneline | wc -l → expected: ≥ 250 (both voyage + ultraplan-local history present).
  • SC6 (F5): git -C /tmp/polyrepo-migration/voyage tag → expected: exactly v5.1.1.
  • SC2/SC7: bash docs/marketplace-polyrepo-migration/migration/40-validate-standalone.sh --all → expected: every target PASS (config-audit gate = full tests/**/*.test.mjs minus json-backcompat + raw-backcompat, after the Step 7 re-seed; linkedin-studio .mjs core is the hard gate, TS analytics ADVISORY); no tracked *.local.md/STATE.md except okr/templates/okr.local.md.template.
  • SC5 (two extracted repos): with the DS + ms-ai-architect extracts in $WORK, node scripts/sync-design-system.mjs ms-ai-architect --source "$WORK/playground-design-system" --target "$WORK/ms-ai-architect" then node scripts/sync-design-system.mjs --check ms-ai-architect → expected: MANIFEST OK; git -C "$WORK/ms-ai-architect" diff --exit-code playground/vendor/ → expected: empty (byte-identical re-vendor).
  • SC3 (F2): node …/60-rewrite-marketplace.mjs --all --out /tmp/mp-all.json && grep -c '"./plugins/' /tmp/mp-all.json → expected: 0; grep -c 'ssh://' /tmp/mp-all.json0.
  • SC4: bash …/70-thin-catalog.sh --workspace /tmp/thin && ls /tmp/thin → expected: no plugins/, no shared/; CONVENTIONS.md present.
  • D8 held: after the full dry-run, git -C $REPO_ROOT status --porcelain .claude-plugin/marketplace.json plugins shared → expected: no output (live surfaces unchanged); git log origin/main..main --oneline shows only local migration/ commits, none pushed.
  • SC1/SC8 (operator window, post-pilot): per RUNBOOK.md, /plugin marketplace add https://…/open/ktg-plugin-marketplace.git + /plugin install <plugin>@ktg-plugin-marketplace succeeds for the pilot, then after each graduation.

Estimated Scope

  • Files to modify: 1 (scripts/sync-design-system.mjs).
  • Files to create: ~24 (11 migration scripts/docs + 11 tests + 2 templates), all under docs/marketplace-polyrepo-migration/migration/ + 1 root test.
  • Repos produced (local, unpushed): 11 (10 plugins + DS) in $WORK during the dry-run.
  • Complexity: high (rename-aware history surgery, mixed-source live-marketplace choreography, operator-gated irreversibility) — but the Claude-run local half is fully reversible (NULL push, throwaway clones); only Step 10's window is irreversible and operator-run.

Execution Strategy

Steps exceed 5 → grouped into sessions/waves. The local half (Sessions 15) is trekexecutable now; the window (Session 6) is operator-run from RUNBOOK.md, not via trekexecute.

Session 1: Foundation

  • Steps: 1, 2
  • Wave: 1
  • Depends on: none
  • Scope fence:
    • Touch: docs/marketplace-polyrepo-migration/migration/, scripts/sync-design-system.mjs(.test)
    • Never touch: .claude-plugin/marketplace.json, plugins/, shared/

Session 2: Extraction + per-repo prep scripts

  • Steps: 3, 4, 5
  • Wave: 2
  • Depends on: Session 1 (reads plugin-map.json)
  • Scope fence:
    • Touch: docs/marketplace-polyrepo-migration/migration/
    • Never touch: plugins/, shared/, .claude-plugin/marketplace.json (extraction operates in $WORK)

Session 3: Validation + blockers

  • Steps: 6, 7
  • Wave: 3
  • Depends on: Session 2 (validates extracted repos)
  • Scope fence:
    • Touch: docs/marketplace-polyrepo-migration/migration/
    • Never touch: plugins/config-audit/ live (Step 7 operates on the extract in $WORK)

Session 4: Catalog + window staging

  • Steps: 8, 9, 10
  • Wave: 3 (parallel with Session 3 — independent scripts)
  • Depends on: Session 1 (versions/URLs)
  • Scope fence:
    • Touch: docs/marketplace-polyrepo-migration/migration/
    • Never touch: live .claude-plugin/marketplace.json, CLAUDE.md, README.md (rewriters write to --out/--workspace)

Session 5: Local dry-run (de-risk proof)

  • Steps: 11
  • Wave: 4
  • Depends on: Sessions 2, 3, 4 (exercises every script)
  • Scope fence:
    • Touch: docs/marketplace-polyrepo-migration/migration/, $WORK
    • Never touch: any live source/catalog surface (D8 asserted by the step's own verify)

Session 6: Operator window (NOT trekexecute — operator-run from RUNBOOK.md)

  • Steps: runbook only (pilot → DS → high-churn → low-churn → thin catalog)
  • Wave: 5 (authorized weekend window, D8)
  • Depends on: Session 5 green dry-run + operator authorization
  • Scope fence:
    • Touch: Forgejo (repo creation + push), live .claude-plugin/marketplace.json (mixed→external), catalog thinning
    • Never touch: anything before the pilot passes

Execution Order

  • Wave 1: Session 1
  • Wave 2: Session 2 (after Session 1)
  • Wave 3: Sessions 3 + 4 (parallel, after Session 2 / Session 1 respectively)
  • Wave 4: Session 5 (after Sessions 24)
  • Wave 5: Session 6 — operator window, gated on the dry-run + explicit authorization

Grouping rules applied

  • Steps sharing the plugin-map.json contract → Session 1 first.
  • Extraction/prep scripts (independent files) → Session 2; validation depends on them → Session 3.
  • Catalog/window staging is file-independent → Session 4 parallel with Session 3.
  • The dry-run gates everything before the irreversible window.

Plan Quality Score

Dimension Weight Score Notes
Structural integrity 0.15 88 $WORK lifecycle now defined via a dedicated mirror clone; blob/re-tag ordering fixed (single-pass filter-repo)
Step quality 0.20 84 Deterministic blob decision (no runtime prompt); config-audit step corrected to the verified UPDATE_SNAPSHOT=1 seam; gitleaks fingerprint format fixed
Coverage completeness 0.20 86 SC1SC8 + D1D8 + R1R8 + §9 traced; F1F6 absorbed; DS cross-repo chain + .forgejo survival now covered
Specification quality 0.15 85 filter-repo/Forgejo/marketplace commands verified; config-audit framing corrected to the real test behaviour
Risk & pre-mortem 0.15 86 Pilot now flips-then-installs (actually tests the ref-pin chain); dry-run honestly scoped to local mechanics
Headless readiness 0.10 80 Local steps deterministic & headless-ready; window operator-run by design; TS analytics explicitly advisory
Manifest quality 0.05 85 Every step has a checkable manifest; expected_paths ⊆ Files:; D8 enforced via forbidden_paths
Weighted total 1.00 85 Grade: B+

Adversarial review (Phase 9):

  • Plan critic: REVISE on the first draft (63/C) — 3 blockers + 8 major + 6 minor. **All 3 blockers
    • all 8 majors + the actionable minors addressed** (see Revisions). Contested config-audit facts re-verified directly against the test code before revising.
  • Scope guardian: ALIGNED — 0 scope gaps; all SC1SC8 / D1D8 / §9 / R1R8 covered; 4 creep items all ACCEPTABLE (2 forced by relocation, 2 operator-ratified); 1 DEVIATION (ssh→https) surfaced for operator ratification in the header.

Revisions

# Finding (reviewer) Severity Resolution
1 Step 7 misdescribed config-audit tests; invented SKIP_ENV_SNAPSHOTS; wrong-glob under-ran the suite (critic) blocker Re-verified the real tests. Rewrote Step 7 to use the actual UPDATE_SNAPSHOT=1 seam (re-seed snapshot-default-output) + by-name exclusion of the 2 frozen back-compat tests + the declared runner node --test 'tests/**/*.test.mjs'
2 Pilot couldn't test the ref-pin chain — install resolved the in-repo source (critic) blocker Step 10 pilot reordered: create→push→flip the entry to external url+ref & push catalog→then install-smoke, so the HTTPS+ref chain is actually exercised
3 blob_strip left as a runtime prompt (non-headless) (critic) major Step 1 preflight enumerates >1 MB blobs and emits blob_strip_safe; Step 3 picks size-strip vs surgical path-glob deterministically — no prompt
4 filter-repo second-pass + re-tag ordering hazard (critic) major Single filter-repo invocation composing --path/--path-rename/--strip-blobs-bigger-than; re-tag as the final op
5 $WORK/$REPO_ROOT undefined; concurrency race reintroduced (critic) major Step 1 builds a dedicated $WORK/_mirror once; all extractions clone from it; per-step verifies self-contained
6 .gitleaksignore re-rooting used wrong format path:line (critic) major Step 4 fixed to path:rule-id:line (keep generic-api-key/linkedin-client-id + line); test assertion corrected
7 DS cross-repo vendor chain never validated before window (critic) major Step 11 runs the re-homed sync --source/--target across the two extracted repos + --check parity (SC5)
8 Dry-run oversold as proving the migration (critic) major Step 11 reworded to local-mechanics only; window-only modes (push/create/resolve) explicitly listed as pilot-covered
9 expected_paths not ⊆ Files: in every step (critic) major Added each .test.mjs to its step's Files: line
10 linkedin-studio TS suite silently waived for SC2 (critic) major Step 6 states two-tier SC2: .mjs core = hard gate; TS analytics = explicit ADVISORY (network-gated)
11 Preflight HEAD/status assertions go stale (critic) minor Relaxed to porcelain on plugins/ shared/ marketplace.json scripts only; idempotent
12 Step 5 inline-vs-link either/or; ai-psychosis mis-scoped (critic) minor Inline chosen; ai-psychosis noted verify-only, real work = 9 monorepo-pointing plugins
13 Step 9 doc-fix coupling; .forgejo CI uncovered; pre-claimed grade (critic) minor Version correction coupled to the D6 README rewrite; Step 6 asserts .forgejo/ survives; grade set post-review
14 ssh→https overrides a ratified brief literal without annotation (guardian) deviation Surfaced as a brief correction requiring operator ratification in the header; kept HTTPS (correct per #9740)