name: cktovd
description: "Migrate from claudekit (ck) to the vd-cli control plane — install clean-room hooks, convert .ck.json to .vd.json, audit CK_*→VD_* env consumers, and move a repo's plans/ artifacts into feature-first .workbench folders under /.workbench/features//{plans,reports,journals,visuals,state}. Use when the user says 'cktovd', 'migrate to vd', 'migrate plans to .workbench', 'enable the .workbench umbrella', or 'switch this repo off claudekit'."
license: MIT
argument-hint: "[repo-path] [--machine] [--check]"
metadata:
author: vanducng
version: "1.1.0"
cktovd — claudekit → vd-cli migration
Migrate two layers, in order:
- Machine (once): vd-cli owns the Claude Code control plane — clean-room hooks in
~/.claude/hooks/, config in~/.claude/.vd.json. - Repo (per project): opt into the feature-first
.workbenchumbrella via local<git-root>/.vd.jsonand physically moveplans/artifacts into per-feature folders.
Detect scope from the argument: --machine → machine layer only; a repo path (default: cwd git root) → repo layer (runs a machine preflight first); --check → verify only, change nothing.
Layout contract (source of truth: vd-cli hooks/lib/paths.cjs)
| Artifact | Legacy (no umbrella) | Feature-first .workbench |
|---|---|---|
| Plans | <cwd>/plans/ |
<git-root>/.workbench/features/<feature>/plans/ |
| Reports | plans/reports/ |
<git-root>/.workbench/features/<feature>/reports/ |
| Journals | plans/journals/ |
<git-root>/.workbench/features/<feature>/journals/ |
| Visuals | plans/visuals/ |
<git-root>/.workbench/features/<feature>/visuals/ |
| State | plans/goals/ |
<git-root>/.workbench/features/<feature>/state/ (renamed: goals → state) |
| Docs | <cwd>/docs/ |
<cwd>/docs/ — never moves: git-tracked team deliverables, umbrella-blind by design |
The umbrella dir is named tool-neutrally (.workbench, not .vd) because multiple agents share the repo. Git worktrees are a separate top-level <git-root>/.worktrees/ (not under .workbench); artifacts written from inside a worktree resolve to the main repo's .workbench/ (they survive git worktree remove), while docs/ stays branch-local.
Config precedence: DEFAULT ← global ~/.claude/.vd.json ← project <git-root>/.vd.json. There is no .ck.json read fallback (removed): vd reads .vd.json only. A lingering .ck.json without its .vd.json is no longer silently honored — it raises a migration error ("run the cktovd skill / rename to .vd.json"). So the cutover is: create the .vd.json (from .ck.json if present). Once .vd.json exists the old file is inert (ignored); it's safe to delete — the master backup is your real safety net. vd never writes .ck.json.
paths.umbrella must be a relative path with no ../empty segments that resolves inside the git root. An invalid value (absolute, traversal, empty) never errors — it's silently coerced to null, which restores the byte-identical legacy layout, so a bad value presents as "migration didn't take effect". A plausible typo (.wrok) passes sanitization and creates a wrong-named umbrella dir. Umbrella resolution anchors via git rev-parse --show-toplevel — a non-git dir never activates it; git init first.
Machine layer
- Master backup (the cutover is destructive; the full-restore recipe depends on this):
ts=$(date +%Y%m%dT%H%M%S); bak=~/.claude-ck-removal-backup-$ts mkdir -p "$bak" && cp -Rp ~/.claude/hooks "$bak"/ && cp ~/.claude/settings.json "$bak"/ cp ~/.claude/.ck.json "$bak"/ 2>/dev/null || true - Preflight:
vd --version(hooks support landed in 2.4.0; the.vd.jsonrename is post-2.5.0 —brew upgrade vdif older). Never patch deployed hooks in place — ck'sck updateclobbers its files, and vd's are redeployed byvd install hooks. - Cut over:
npm uninstall -g claudekit-cli vd install hooks grep -n '\$HOME/.claude/hooks' ~/.claude/settings.json # all entries must show literal $HOMEvd install hooksis idempotent: byte-identical files are skipped, a differing vd-owned file is backed up once as<name>.bak.<UTC-ts>.cjs, unknown files are never touched. settings.json edits are surgical (only vd's hook entries + the top-levelstatusLinekey are patched; a one-timesettings.json.bakis taken first). Registered commands keep$HOMEliteral — the hook runner shell-expands it; never bake a personal absolute path in. Caution: registration matches existing entries by.cjsfilename substring and rewrites them to vd's canonical command — a custom wrapper referencing the same filename gets clobbered. - Config rename (required — prevents the migration error):
[ -f ~/.claude/.ck.json ] && [ ! -f ~/.claude/.vd.json ] && cp ~/.claude/.ck.json ~/.claude/.vd.json. With the fallback gone, a global~/.claude/.ck.jsonwithout~/.claude/.vd.jsonerrors on every session, so thiscpis mandatory when a legacy global config exists. After.vd.jsonexists,rm ~/.claude/.ck.json(it's inert and backed up). Do not setpaths.umbrellaglobally — that flips every repo at once and strands un-migratedplans/content. Go per-repo; consider the global flip only after all active repos are migrated. - CK_ consumer audit* — the env rename is a hard cut: every
CK_*session var has aVD_*successor (VD_REPORTS_PATH,VD_PLANS_PATH,VD_GIT_ROOT,VD_ACTIVE_PLAN, …) and anything still readingCK_*silently gets nothing. Temp files renamedck-session-*→vd-session-*.
When fixing hits, work from an explicit inventory of your variable names — a blanketgrep -rn 'CK_' ~/.claude/ --include='*.cjs' --include='*.json' --include='*.sh' \ | grep -v node_modules | grep -v '.bak'CK_→VD_replace hits substring false-positives (SLACK_WEBHOOK_URL,BLOCK_LOW_AND_ABOVE, …) and third-party vars.
Repo layer
- Opt in at the git root. Default to local-only repo config unless the team explicitly wants to share it:
cd "$(git rev-parse --show-toplevel)" printf '{\n "paths": {\n "umbrella": ".workbench",\n "layout": "feature-first"\n }\n}\n' > .vd.json grep -qxF '.vd.json' .gitignore 2>/dev/null || printf '\n.vd.json\n' >> .gitignore - Move artifacts into one feature folder per ticket/topic. Prefer
vd:workbench new <slug> --ticket <ticket>to createfeature.json, then move legacy files into that feature's type folders:
Missing sources are fine — absent subdirs appear lazily on first use. If the current session is writing into a plan dir, move that dir last (or from a copy). Follow-ups that bite: rewrite embeddednode ~/.claude/skills/workbench/scripts/workbench.cjs new my-feature --ticket PROJ-123 feature=.workbench/features/proj-123-my-feature mkdir -p "$feature"/{plans,reports,journals,visuals,state} [ -d plans/reports ] && find plans/reports -mindepth 1 -maxdepth 1 -exec mv {} "$feature/reports/" \; [ -d plans/journals ] && find plans/journals -mindepth 1 -maxdepth 1 -exec mv {} "$feature/journals/" \; [ -d plans/visuals ] && find plans/visuals -mindepth 1 -maxdepth 1 -exec mv {} "$feature/visuals/" \; [ -d plans/goals ] && find plans/goals -mindepth 1 -maxdepth 1 -exec mv {} "$feature/state/" \; find plans -mindepth 1 -maxdepth 1 -type d -exec mv {} "$feature/plans/" \; 2>/dev/null rmdir plansplans/goalspaths inside moved state files;git ls-files .workbench/ | xargs -r git rm -r --cachedfor anything previously tracked underplans/(amvinto an ignored dir doesn't untrack it). - Gitignore: replace the
plansentry with.workbench. Don't keep both — if a non-migrated tool recreatesplans/, it should show up untracked as a signal..vd.jsonstays local-only via.gitignoreunless the team explicitly chooses a shared config. - Stale references: grep the repo (
CLAUDE.md,AGENTS.md,docs/,.github/, scripts) for hardcodedplans/paths and point them at the hook-injected paths instead.
Already on the legacy .work umbrella? A repo migrated before the .workbench rename just needs the dir + config flipped: git mv .work .workbench (or mv if untracked), set paths.umbrella to .workbench in .vd.json, and swap the .gitignore entry .work/ → .workbench/. The hook re-resolves to .workbench/ on the next prompt; legacy .work/ is still recognized read-only by the resume probes during the transition.
Verify (also the whole of --check)
Run from inside the target repo — loadConfig() resolves the project .vd.json from process.cwd(); the payload cwd only anchors path strings, so piping from elsewhere false-negatives into legacy output.
cd "$(git rev-parse --show-toplevel)"
git check-ignore -v .workbench/features/example/reports/x && echo "OK .workbench ignored"
git check-ignore -v docs/x; [ $? -eq 1 ] && echo "OK docs NOT ignored"
echo "{\"cwd\":\"$PWD\",\"session_id\":\"check\"}" | node ~/.claude/hooks/dev-rules-reminder.cjs
Pass = the injected ## Paths block shows six paths (Reports/Plans/Docs/Visuals/Journals/State) with everything except Docs under .workbench/. Three paths = umbrella did not activate (invalid umbrella value, missing .vd.json, or you ran from outside the repo). A malformed .vd.json doesn't crash — it silently falls back to the 3-path legacy injection, so assert on output, never trust the config edit. No paths at all = a legacy .ck.json lingers without a .vd.json: the loader now raises a migration error and the fail-open hook injects nothing — create the .vd.json (step 4 / repo step 1). New paths take effect on the next prompt; the current session's earlier injection still shows old paths — don't chase that as a bug.
Rollback
- Repo: delete
<git-root>/.vd.json(or set umbrella null) and reverse the gitignore swap → legacy layout resumes byte-identically;mvthe.workbenchtrees back if needed. - Machine:
vd hooks rollback(restore newest*.bak.*+settings.json.bak) orvd hooks uninstall(remove + unregister vd-managed files only; third-party hooks untouched). Both support--dry-run. Caution: the uninstall allowlist can lag the deployed asset set — check--dry-runoutput and remove orphans by hand. - Full restore: copy the master backup back over
~/.claude/hooks+settings.json, thennpm i -g claudekit-cli.
Gotchas
- Reports under feature-first live in the feature root, not inside a plan dir:
features/<feature>/reports, alongsidefeatures/<feature>/plans. Anything string-concatenatingplans/reports/...breaks; always use the hook-injectedReports:path. task-completed-handler.cjs/teammate-idle-handler.cjslive in~/.claude/hooksbut are deliberately not in settings.json (team-runtime invoked) — an "unregistered hook" finding there is not a bug; don't register or delete them.- Subagent prompts must pass the work-context repo's
.workbench/paths, not the cwd's, when editing another project. - Monorepos: the umbrella anchors to the git root, not cwd — one
.vd.jsonand one.workbench/per repo, even when working from a subdirectory. - Dotfiles repos at
$HOME: stray-home protection anchors child projects to themselves by default; setpaths.allowHomeRoot: trueonly when$HOMEis intentionally the artifact root. - A corrupted ancestor
.git/config(e.g. a stray repo at$HOME) silently disables git-root resolution for loose dirs beneath it — umbrella just stays off, no error.