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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-17 11:49:22 +02:00
commit a44e37b71f

View file

@ -0,0 +1,985 @@
# 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.** `voyage``ultraplan-local` (136 commits), `llm-security`
`llm-security-copilot` (3), `linkedin-studio``linkedin-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 shared`
**yes**, 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 **2**`llm-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
```mermaid
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`).
- `.gitignore``plugins/*/` 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](https://manpages.debian.org/testing/git-filter-repo/git-filter-repo.1.en.html) | `--subdirectory-filter X``--path X/ --path-rename X/:`; removes `origin`; rewrites tags; aborts on non-fresh clone | High |
| Local-clone freshness | [filter-repo #38](https://github.com/newren/git-filter-repo/issues/38) | `git clone --no-local` from a local source (not `--force`) | High |
| marketplace.json external/mixed/ref | [plugin-marketplaces](https://code.claude.com/docs/en/plugin-marketplaces), [plugins-reference](https://code.claude.com/docs/en/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](https://github.com/anthropics/claude-code/issues/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](https://forgejo.org/docs/latest/user/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:**
```yaml
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:**
```yaml
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:**
```yaml
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:**
```yaml
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"
```
### Step 5: Reference-rot rewriter (README links + plugin.json/package.json URLs)
- **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:**
```yaml
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 declares** — `node --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:**
```yaml
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:**
```yaml
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:**
```yaml
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:**
```yaml
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:**
```yaml
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:**
```yaml
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.json` → `0`.
- [ ] **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) |