@brainervirus/workit-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,167 +1,39 @@
1
- # Workit
1
+ # @brainervirus/workit-core
2
2
 
3
- Multi-platform Superpowers workflow plugin for **Cursor** and **OpenCode**: verify, PR, changelog, commits, SDD implementation, session handoff, YouTrack, and deterministic UI presentation.
3
+ Shared core for the workit plugins the single source of workflow logic that the OpenCode, Cursor, and CLI packages adapt to their native surfaces.
4
4
 
5
- | Platform | Path | Version |
6
- | --- | --- | --- |
7
- | **OpenCode** | `packages/workit-opencode/src/plugin.ts` | ![npm](https://img.shields.io/npm/v/@brainervirus/workit-opencode) |
8
- | **Cursor** | `packages/workit-cursor/` (MCP + hooks + rules + skills) | ![npm](https://img.shields.io/npm/v/@brainervirus/workit-cursor) |
9
- | **Shared core** | `packages/workit-core/` (src, skills, commands, scripts, templates) | ![npm](https://img.shields.io/npm/v/@brainervirus/workit-core) |
10
- | **CLI** | `packages/workit-cli/` (Ink wizard, bin `workit`) | ![npm](https://img.shields.io/npm/v/@brainervirus/workit-cli) |
5
+ ## When you need it directly
11
6
 
12
- Config directory (both platforms): `~/.config/workit/`legacy `~/.config/workflow-toolkit/` is auto-migrated on first run and kept as a fallback.
7
+ End users do **not** install this package directly install `@brainervirus/workit-cli` (wizard) or the platform plugin (`@brainervirus/workit-opencode`, `@brainervirus/workit-cursor`). The adapters pull core in as a dependency and ship it with their bundles.
13
8
 
14
- [![npm version](https://img.shields.io/npm/v/@brainervirus/workit-opencode)](https://www.npmjs.com/package/@brainervirus/workit-opencode)
15
- [![CI](https://img.shields.io/github/actions/workflow/status/BrainerVirus/workit/ci.yml?branch=main&label=CI)](https://github.com/BrainerVirus/workit/actions)
16
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+ You consume `@brainervirus/workit-core` directly only when:
17
10
 
18
- ## Install
11
+ - Building a new host adapter (e.g. a future `codex/` adapter) that maps a host's native surfaces to the shared `src/core/` logic.
12
+ - Reading the canonical source of workflow behavior: specs/plans, approval flow, commit/PR, changelog, YouTrack, presentation, doctor, and diagnostics.
19
13
 
20
- **Quick start (wizard)** — the interactive wizard picks OpenCode and/or Cursor and configures tokens and project files:
14
+ ## Layout and ownership
21
15
 
22
- Requires bun (`curl -fsSL https://bun.sh/install | bash`).
16
+ | Path | Contents |
17
+ | --- | --- |
18
+ | `src/core/` | Shared TS logic (setup, registration, doctor, logger, branch, PR, changelog, YouTrack, presentation, docs, SDD, support matrix). |
19
+ | `src/core.ts` | Package entry; adapters import `@brainervirus/workit-core/src/*`. |
20
+ | `skills/` | 12 OpenCode-native `wk-*` skills. |
21
+ | `commands/` | 12 OpenCode `wk-*` command prompts. |
22
+ | `scripts/` | Shared shell installers/launchers and the release-time workspace-dep rewrite. |
23
+ | `templates/` | Execution and Superpowers contract templates. |
24
+ | `vendor/superpowers/skills/` | 14 vendored Superpowers skills. |
23
25
 
24
- ```bash
25
- npm i @brainervirus/workit-core # shared core (skills, commands, scripts)
26
- npm i @brainervirus/workit-opencode # OpenCode plugin (thin over the core)
27
- npm i @brainervirus/workit-cursor # Cursor plugin (MCP + hooks + rules)
28
- npm i @brainervirus/workit-cli # interactive wizard
29
- npx @brainervirus/workit-cli init
30
- ```
31
-
32
- **Local development** — use the repo path instead; no package cache, disk is the source of truth:
33
-
34
- ```bash
35
- bun i
36
- ```
37
-
38
- **Manual setup (skip the wizard)** — configure each tool by hand; see [Usage](#usage) for the OpenCode plugin entry and Cursor MCP server.
39
-
40
- ## Usage
41
-
42
- Manual setup for those who skipped the wizard (`npx @brainervirus/workit-cli init`):
43
-
44
- **OpenCode** — reference the plugin entry in `opencode.json` / `opencode.jsonc` (`~/.config/opencode/opencode.json`):
45
-
46
- ```json
47
- {
48
- "$schema": "https://opencode.ai/config.json",
49
- "plugin": ["@brainervirus/workit-opencode"]
50
- }
51
- ```
52
-
53
- Local dev variant (absolute path to this repo):
26
+ Adapters map host-native surfaces to `src/core/`; they never re-implement core logic. Keep shared behavior here and host-specific presentation in each adapter.
54
27
 
55
- ```json
56
- {
57
- "$schema": "https://opencode.ai/config.json",
58
- "plugin": ["file:///path/to/workit/packages/workit-opencode/src/plugin.ts"]
59
- }
60
- ```
61
-
62
- **Cursor** — the MCP server runs from the package's `cursor/` directory. Add to `.cursor/mcp.json`:
63
-
64
- ```json
65
- {
66
- "mcpServers": {
67
- "workit": {
68
- "command": "bun",
69
- "args": ["run", "node_modules/@brainervirus/workit-cursor/mcp/server.ts"]
70
- }
71
- }
72
- }
73
- ```
28
+ ## Exports
74
29
 
75
- Local dev variant: point `WORKFLOW_TOOLKIT_ROOT` at this repo's `packages/workit-core` (the MCP launcher `packages/workit-core/scripts/run-cursor-mcp.sh` resolves it), or run the install script from the repo.
30
+ - Main entry: `./src/core.ts`.
31
+ - Subpath exports: `./src/*.ts` (e.g. `@brainervirus/workit-core/src/core/doctor`), plus `./package.json`.
76
32
 
77
- ## Requirements
78
-
79
- - **Bun ≥ 1.0** — runtime for the shared `src/core` logic, the OpenCode plugin, and the Cursor MCP server (`bun server.ts`). Install once:
33
+ ## Package scripts
80
34
 
81
35
  ```bash
82
- curl -fsSL https://bun.sh/install | bash
83
- ```
84
-
85
- Then add to your shell profile (or rely on the MCP launcher's `~/.bun/bin/bun` fallback):
86
-
87
- ```bash
88
- export PATH="$HOME/.bun/bin:$PATH"
89
- ```
90
-
91
- - **Git** — branch resolution, SDD review diffs, and verify gates.
92
-
93
- ## Repo layout
94
-
36
+ bun run typecheck # tsc --noEmit
95
37
  ```
96
- workit/
97
- ├── packages/
98
- │ ├── workit-core/ # @brainervirus/workit-core — shared core
99
- │ │ ├── src/ # core TS (src/core, src/tools, src/state)
100
- │ │ ├── skills/ # OpenCode-native skills (wk-*)
101
- │ │ ├── commands/ # OpenCode commands (wk-*)
102
- │ │ ├── scripts/ # shared shell + installers (all logic in src/, TS via bun)
103
- │ │ ├── templates/ # execution + superpowers contracts
104
- │ │ └── vendor/ # vendored superpowers skills
105
- │ ├── workit-opencode/ # @brainervirus/workit-opencode — OpenCode plugin (src/plugin.ts)
106
- │ ├── workit-cursor/ # @brainervirus/workit-cursor — Cursor plugin (MCP, hooks, rules, skills)
107
- │ └── workit-cli/ # @brainervirus/workit-cli — Ink setup wizard (bin: workit)
108
- ├── .github/workflows/ # CI + release
109
- └── test/ # bun tests (OpenCode plugin)
110
- ```
111
-
112
- ## CI / local checks
113
-
114
- ```bash
115
- bun run check # bun test + tsc + cursor MCP regressions
116
- ```
117
-
118
- GitHub Actions:
119
-
120
- - **CI** — on push/PR to `main`: matrix of 3 OS (ubuntu, macos, windows) running `actions/checkout@v7` + `oven-sh/setup-bun@v2`, then `bun install --frozen-lockfile` + `bun run check`
121
- - **Release** — on push to `main`: [semantic-release](https://github.com/semantic-release/semantic-release) computes the next version from Conventional Commits, publishes the four workspaces to npm in dependency order — `workit-core`, `workit-opencode`, `workit-cursor`, `workit-cli` (with provenance), and creates the git tag + GitHub Release
122
-
123
- No manual tags — semantic-release owns the version/tag flow:
124
-
125
- ```bash
126
- git push origin main
127
- ```
128
-
129
- ## Quality gates
130
-
131
- - **CI checks** — the per-package check jobs (workit-core/opencode/cursor/cli/shared) are required in branch protection; the PR cannot merge while any is red.
132
- - **Subagent review** — `wk-implement` runs a two-stage review (spec compliance + code quality) per task with fresh `general` agents.
133
-
134
- ## Architecture
135
-
136
- | Concern | OpenCode | Cursor |
137
- | --- | --- | --- |
138
- | Tools | native plugin | MCP server |
139
- | Session contract | `messages.transform` | `sessionStart` hook |
140
- | Handoff | spawns OpenCode session | handoff prompt |
141
- | Shared logic | `scripts/` | `scripts/` via `WORKFLOW_TOOLKIT_ROOT` |
142
- | Install root | GitHub plugin pin | `~/.local/share/workflow-toolkit` + local plugin copy |
143
-
144
- ## Future: Codex CLI
145
-
146
- Add `codex/` adapter; reuse `scripts/` and `templates/`.
147
-
148
- ## Workflow docs layout
149
-
150
- Features live in `docs/<slug>/`:
151
-
152
- - `docs/<slug>/spec.md` and `docs/<slug>/plan.md` are **committed** (they travel with the branch).
153
- - `docs/<slug>/sdd/` (progress ledger, `flow.json` approval state, briefs, review diffs) is **gitignored** — working state for the current cycle.
154
-
155
- Consequences:
156
-
157
- - A fresh clone starts every workflow at `draft` — the flow gates (`workflow_spec_approve` / `workflow_plan_approve` / `workflow_plan_menu`) must be re-run after checkout.
158
- - The SDD state does not travel with the branch; spec/plan do.
159
-
160
- ## Contributing
161
-
162
- Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
163
-
164
- ## Kudos
165
38
 
166
- - **Superpowers** — the vendored `vendor/superpowers/skills/` (brainstorming, writing-plans, executing-plans, TDD, …) is by [Adam Wiggins](https://github.com/obra), vendored with attribution.
167
- - **Ponytail mode** — the lazy-engineer skill (ponytail, ponytail-review, ponytail-audit, …) by [Dietrich Gebert](https://github.com/dietrichgebert), active in this project's OpenCode config and installed as a shared skill.
39
+ Core has no build step — the adapters bundle it at build time. See the root [README](../../README.md) for the full development, CI, and versioning notes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -51,12 +51,11 @@ if [ -d "$SKILLS_DIR" ]; then
51
51
  rm -f "$SKILLS_DIR"/wf-* "$SKILLS_DIR"/wk-*
52
52
  fi
53
53
 
54
- PLUGIN_DIR="$HOME/.cursor/plugins/local/workflow-toolkit"
54
+ PLUGIN_DIR="$HOME/.cursor/plugins/local/workit"
55
55
  REGISTRATION_TS="$ROOT/packages/workit-core/src/core/registration.ts"
56
56
  CURSOR_SETTINGS="$HOME/.cursor/settings.json" CURSOR_MCP="$HOME/.cursor/mcp.json" PLUGIN_DIR="$PLUGIN_DIR" REGISTRATION_TS="$REGISTRATION_TS" bun -e '
57
57
  import fs from "node:fs";
58
- import path from "node:path";
59
- const { mergeCursorSettings, mergeCursorMcp, mergeCursorHooks, cursorMcpServerEntry } =
58
+ const { mergeCursorSettings, mergeCursorMcp, cursorMcpServerEntry } =
60
59
  await import(process.env.REGISTRATION_TS!);
61
60
 
62
61
  // RR-06: collapse current + legacy identities; never replace unrelated settings.
@@ -67,8 +66,8 @@ const settings = fs.existsSync(settingsPath)
67
66
  const merged = mergeCursorSettings(settings, process.env.PLUGIN_DIR!);
68
67
  fs.writeFileSync(settingsPath, JSON.stringify(merged.config, null, 2) + "\n");
69
68
 
70
- // RR-06: one portable workit MCP registration (node dist when present, bash
71
- // shim in a dist-less dev checkout); stale "workflow-toolkit" entries removed.
69
+ // RR-06/CA-17: one portable workit MCP registration via the published npx bin;
70
+ // stale "workflow-toolkit" entries removed.
72
71
  const server = cursorMcpServerEntry(process.env.PLUGIN_DIR!);
73
72
  const mcpPath = process.env.CURSOR_MCP!;
74
73
  const mcp = fs.existsSync(mcpPath)
@@ -76,23 +75,6 @@ const mcp = fs.existsSync(mcpPath)
76
75
  : { mcpServers: {} };
77
76
  const mergedMcp = mergeCursorMcp(mcp, "workit", server);
78
77
  fs.writeFileSync(mcpPath, JSON.stringify(mergedMcp.config, null, 2) + "\n");
79
-
80
- // Keep the synced plugin manifests consistent with the current checkout state:
81
- // only in a dist-less dev checkout, point them at the shims so Cursor still
82
- // launches MCP/hooks (the shipped manifests already reference dist).
83
- const distMcp = path.join(process.env.PLUGIN_DIR!, "dist", "mcp-server.js");
84
- const pluginMcp = path.join(process.env.PLUGIN_DIR!, "mcp.json");
85
- if (!fs.existsSync(distMcp) && fs.existsSync(pluginMcp)) {
86
- const inPkg = JSON.parse(fs.readFileSync(pluginMcp, "utf8"));
87
- const patched = mergeCursorMcp(inPkg, "workit", server);
88
- fs.writeFileSync(pluginMcp, JSON.stringify(patched.config, null, 2) + "\n");
89
- }
90
- const pluginHooks = path.join(process.env.PLUGIN_DIR!, "hooks", "hooks-cursor.json");
91
- if (!fs.existsSync(path.join(process.env.PLUGIN_DIR!, "dist", "cursor-session-start.js")) && fs.existsSync(pluginHooks)) {
92
- const inPkg = JSON.parse(fs.readFileSync(pluginHooks, "utf8"));
93
- const patched = mergeCursorHooks(inPkg, { command: "./hooks/session-start" });
94
- fs.writeFileSync(pluginHooks, JSON.stringify(patched.config, null, 2) + "\n");
95
- }
96
78
  '
97
79
 
98
80
  # DG-09: verify the just-written Cursor registration with the shared offline doctor.
@@ -101,6 +83,20 @@ if ! bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" cursor; then
101
83
  exit 1
102
84
  fi
103
85
 
86
+ # CA-08/CA-09: migrate the legacy local plugin identity to `workit` only after
87
+ # the canonical registration succeeded; carry the legacy user rules forward first.
88
+ LEGACY_PLUGIN_DIR="${HOME}/.cursor/plugins/local/workflow-toolkit"
89
+ if [ -d "$LEGACY_PLUGIN_DIR" ] && [ "$LEGACY_PLUGIN_DIR" != "$PLUGIN_DIR" ]; then
90
+ if [ -d "$LEGACY_PLUGIN_DIR/rules" ]; then
91
+ mkdir -p "$PLUGIN_DIR/rules"
92
+ for rule in "$LEGACY_PLUGIN_DIR"/rules/*.mdc; do
93
+ [ -e "$rule" ] || continue
94
+ [ -e "$PLUGIN_DIR/rules/$(basename "$rule")" ] || cp "$rule" "$PLUGIN_DIR/rules/"
95
+ done
96
+ fi
97
+ rm -rf "$LEGACY_PLUGIN_DIR"
98
+ fi
99
+
104
100
  echo "Cursor plugin installed + auto-sync enabled (sessionStart)."
105
101
  echo "Share: $SHARE"
106
- ls "$HOME/.cursor/plugins/local/workflow-toolkit/skills" | grep '^wk-' || true
102
+ ls "$HOME/.cursor/plugins/local/workit/skills" | grep '^wk-' || true
@@ -24,9 +24,10 @@ for (const pkg of ["workit-opencode", "workit-cursor", "workit-cli"]) {
24
24
  }
25
25
  writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
26
26
  }
27
+ // The root marketplace index carries no release version; only the package
28
+ // plugin manifest is versioned at release time.
27
29
  for (const file of [
28
30
  resolve(root, "packages/workit-cursor/.cursor-plugin/plugin.json"),
29
- resolve(root, "packages/workit-cursor/marketplace.json"),
30
31
  ]) {
31
32
  const data = JSON.parse(readFileSync(file, "utf8"));
32
33
  data.version = core.version;
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # Stable entrypoint for Cursor MCP deeplink / ~/.cursor/mcp.json
3
- # Prefer the installed plugin copy (has node_modules); fall back to share.
2
+ # Stable entrypoint for Cursor MCP deeplink / ~/.cursor/mcp.json (CA-17).
3
+ # Launch the published package's MCP bin through npx, never a repo-relative
4
+ # dist or share clone; a startup/network failure surfaces via npx's nonzero exit.
4
5
  set -euo pipefail
5
- PLUGIN_MCP="${HOME}/.cursor/plugins/local/workflow-toolkit/mcp/run-server.sh"
6
- SHARE_MCP="${HOME}/.local/share/workflow-toolkit/packages/workit-cursor/mcp/run-server.sh"
7
- if [ -x "$PLUGIN_MCP" ]; then
8
- exec "$PLUGIN_MCP" "$@"
9
- fi
10
- exec "$SHARE_MCP" "$@"
6
+ exec npx -y --package=@brainervirus/workit-cursor@latest workit-cursor-mcp "$@"
@@ -7,7 +7,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
7
7
  . "$SCRIPT_DIR/lib/config-dir.sh"
8
8
 
9
9
  SHARE="${HOME}/.local/share/workflow-toolkit"
10
- PLUGIN_DIR="${HOME}/.cursor/plugins/local/workflow-toolkit"
10
+ PLUGIN_DIR="${HOME}/.cursor/plugins/local/workit"
11
11
  OPENCODE_PLUGINS="${HOME}/.config/opencode/plugins"
12
12
  DEV_DEFAULT="${HOME}/Documents/projects/personal/workflow-toolkit"
13
13
  DEV="${WORKFLOW_TOOLKIT_DEV:-$DEV_DEFAULT}"
@@ -127,7 +127,20 @@ if [ -d "$CONFIG_RULES_DIR" ]; then
127
127
  " >/dev/null 2>&1 || true
128
128
  fi
129
129
  printf '%s\n' "$SHARE/packages/workit-core" >"$PLUGIN_DIR/.workflow-toolkit-root"
130
- chmod +x "$PLUGIN_DIR/hooks/session-start" "$PLUGIN_DIR/mcp/run-server.sh" 2>/dev/null || true
130
+
131
+ # CA-08/CA-09: migrate the legacy local plugin identity to `workit` only after
132
+ # the canonical sync succeeded; carry the legacy user rules forward first.
133
+ LEGACY_DIR="${HOME}/.cursor/plugins/local/workflow-toolkit"
134
+ if [ -d "$LEGACY_DIR" ] && [ "$LEGACY_DIR" != "$PLUGIN_DIR" ]; then
135
+ if [ -d "$LEGACY_DIR/rules" ]; then
136
+ mkdir -p "$PLUGIN_DIR/rules"
137
+ for rule in "$LEGACY_DIR"/rules/*.mdc; do
138
+ [ -e "$rule" ] || continue
139
+ [ -e "$PLUGIN_DIR/rules/$(basename "$rule")" ] || cp "$rule" "$PLUGIN_DIR/rules/"
140
+ done
141
+ fi
142
+ rm -rf "$LEGACY_DIR"
143
+ fi
131
144
 
132
145
  # Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished)
133
146
  rm -f "${OPENCODE_PLUGINS}/workflow-toolkit.ts"
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env bun
2
+ // Cursor Marketplace validator (Task 9, CA-13/CA-15/CA-17/CA-21): validates the
3
+ // tracked Marketplace artifact against the official Cursor JSON schemas plus the
4
+ // repository-specific invariants that a JSON Schema cannot express. Runs from a
5
+ // clean `git ls-files` checkout — it never reads dist/ or other ignored build
6
+ // output, so `bun run validate:cursor-marketplace` is a truthful clean-checkout
7
+ // gate for CI.
8
+ //
9
+ // Official schema snapshots (test/fixtures/cursor-schemas/) are verbatim copies
10
+ // of the current official schemas, retrieved 2026-08-13 from:
11
+ // - https://raw.githubusercontent.com/cursor/plugins/main/schemas/plugin.schema.json
12
+ // - https://raw.githubusercontent.com/cursor/plugins/main/schemas/marketplace.schema.json
13
+ //
14
+ // `source` path resolution (Task 7 carry-forward, RESOLVED): the official
15
+ // marketplace.schema.json documents `plugins[].source` as "Path to the plugin
16
+ // directory (relative to the marketplace root)". The marketplace root is the
17
+ // directory that CONTAINS `.cursor-plugin/marketplace.json` — the repo root —
18
+ // NOT `.cursor-plugin/` itself. Evidence: the official cursor/plugins repo keeps
19
+ // `.cursor-plugin/marketplace.json` at the repo root and lists sources such as
20
+ // `continual-learning` and `third_party/gmail` with no `../` prefix, even though
21
+ // `.cursor-plugin/` is a sibling of those directories. Therefore
22
+ // `source: "packages/workit-cursor"` is correct and kept verbatim.
23
+ import {
24
+ existsSync,
25
+ mkdtempSync,
26
+ readFileSync,
27
+ readdirSync,
28
+ rmSync,
29
+ } from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import Ajv from "ajv";
34
+ import addFormats from "ajv-formats";
35
+ import { CANONICAL_SKILLS, validateSkillManifests } from "../src/core/skill-manifests";
36
+ import { copySanitizedVendor } from "./vendor-assets";
37
+
38
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
39
+ const repoRoot = path.resolve(scriptDir, "..", "..", "..");
40
+ const root = process.argv[2] ? path.resolve(process.argv[2]) : repoRoot;
41
+
42
+ const MARKETPLACE_REL = ".cursor-plugin/marketplace.json";
43
+
44
+ const frontmatterKeys = (file: string): Set<string> => {
45
+ const head = readFileSync(file, "utf8").slice(0, 4096);
46
+ const keys = new Set<string>();
47
+ if (!head.startsWith("---")) return keys;
48
+ const end = head.indexOf("\n---", 3);
49
+ if (end === -1) return keys;
50
+ for (const line of head.slice(3, end).split("\n")) {
51
+ const m = /^([A-Za-z0-9_-]+):/.exec(line);
52
+ if (m) keys.add(m[1]);
53
+ }
54
+ return keys;
55
+ };
56
+
57
+ const treesEqual = (a: string, b: string): string[] => {
58
+ const diffs: string[] = [];
59
+ // Thread the tree root so each file is keyed by its ROOT-relative path —
60
+ // otherwise every `SKILL.md` collapses to one map entry and only the
61
+ // last-read (readdir-order-dependent) skill is actually compared.
62
+ const walk = (rootDir: string, dir: string, into: Map<string, string>): void => {
63
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
64
+ const p = path.join(dir, entry.name);
65
+ if (entry.isDirectory()) walk(rootDir, p, into);
66
+ else into.set(path.relative(rootDir, p).split(path.sep).join("/"), readFileSync(p, "utf8"));
67
+ }
68
+ };
69
+ const left = new Map<string, string>();
70
+ const right = new Map<string, string>();
71
+ walk(a, a, left);
72
+ walk(b, b, right);
73
+ for (const [rel, content] of left) {
74
+ if (!right.has(rel)) diffs.push(`missing in rebuilt: ${rel}`);
75
+ else if (right.get(rel) !== content) diffs.push(`content drift: ${rel}`);
76
+ }
77
+ for (const rel of right.keys()) if (!left.has(rel)) diffs.push(`extra in rebuilt: ${rel}`);
78
+ return diffs;
79
+ };
80
+
81
+ export const validateMarketplace = (rootArg: string): string[] => {
82
+ const errors: string[] = [];
83
+
84
+ if (!existsSync(path.join(rootArg, MARKETPLACE_REL))) {
85
+ errors.push(`missing ${MARKETPLACE_REL}`);
86
+ return errors;
87
+ }
88
+
89
+ // 1. Official JSON Schema evaluation (AJV + ajv-formats), verbatim snapshots.
90
+ const validate = new Ajv({ strict: true, allErrors: true });
91
+ addFormats(validate);
92
+ const schemaDir = path.join(rootArg, "test/fixtures/cursor-schemas");
93
+ const pluginSchema = JSON.parse(
94
+ readFileSync(path.join(schemaDir, "plugin.schema.json"), "utf8"),
95
+ );
96
+ const marketSchema = JSON.parse(
97
+ readFileSync(path.join(schemaDir, "marketplace.schema.json"), "utf8"),
98
+ );
99
+ const market = JSON.parse(readFileSync(path.join(rootArg, MARKETPLACE_REL), "utf8"));
100
+ if (!validate.validate(marketSchema, market)) {
101
+ errors.push(
102
+ `marketplace.json invalid: ${(validate.errors ?? []).map((e) => e.message).join("; ")}`,
103
+ );
104
+ }
105
+
106
+ // 2. Resolve each plugin source relative to the repo root; name must match.
107
+ for (const entry of (market.plugins ?? []) as { name: string; source: string }[]) {
108
+ const pluginDir = path.join(rootArg, entry.source);
109
+ const manifestRel = path.join(entry.source, ".cursor-plugin/plugin.json");
110
+ if (!existsSync(path.join(pluginDir, ".cursor-plugin/plugin.json"))) {
111
+ errors.push(`plugin ${entry.name}: source ${entry.source} has no .cursor-plugin/plugin.json`);
112
+ continue;
113
+ }
114
+ const plugin = JSON.parse(readFileSync(path.join(pluginDir, ".cursor-plugin/plugin.json"), "utf8"));
115
+ if (!validate.validate(pluginSchema, plugin)) {
116
+ errors.push(
117
+ `plugin ${entry.name} plugin.json invalid: ${(validate.errors ?? []).map((e) => e.message).join("; ")}`,
118
+ );
119
+ }
120
+ if (plugin.name !== entry.name) {
121
+ errors.push(`plugin ${entry.name}: plugin.json name ${plugin.name} does not match index`);
122
+ }
123
+
124
+ // 3. Component paths resolve inside the plugin root (no `..`, no absolute).
125
+ // `skills`/`rules` are path-like (string or string[]). `mcpServers`/`hooks`
126
+ // may also be inline objects or arrays of objects, which the JSON-schema
127
+ // check already validates — only string/string[] values get the
128
+ // path-escape/existence check; non-string forms are skipped gracefully.
129
+ for (const field of ["skills", "rules", "mcpServers", "hooks"] as const) {
130
+ const value = plugin[field];
131
+ const rels: string[] =
132
+ typeof value === "string"
133
+ ? [value]
134
+ : Array.isArray(value)
135
+ ? value.filter((v): v is string => typeof v === "string")
136
+ : [];
137
+ for (const rel of rels) {
138
+ if (rel.includes("..") || rel.startsWith("/")) {
139
+ errors.push(`plugin ${entry.name}: ${field} path escapes root: ${rel}`);
140
+ continue;
141
+ }
142
+ if (!existsSync(path.join(pluginDir, rel))) {
143
+ errors.push(`plugin ${entry.name}: ${field} path missing: ${rel}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ // 4. Logo resolves inside the plugin root and exists.
149
+ const logo = plugin.logo as string | undefined;
150
+ if (logo && (logo.includes("..") || logo.startsWith("/"))) {
151
+ errors.push(`plugin ${entry.name}: logo path escapes root: ${logo}`);
152
+ } else if (logo && !existsSync(path.join(pluginDir, logo))) {
153
+ errors.push(`plugin ${entry.name}: logo missing: ${logo}`);
154
+ }
155
+
156
+ // 5. Skills and rules carry valid frontmatter.
157
+ const skillRoots: [string, readonly string[]][] = [
158
+ [path.join(pluginDir, "skills"), CANONICAL_SKILLS.workit],
159
+ [path.join(pluginDir, "vendor/superpowers/skills"), CANONICAL_SKILLS.superpowers],
160
+ ];
161
+ for (const [dir, expected] of skillRoots) {
162
+ const mismatch = validateSkillManifests(dir, expected, "skills");
163
+ if (mismatch) errors.push(`plugin ${entry.name}: ${mismatch}`);
164
+ for (const skill of expected) {
165
+ const keys = frontmatterKeys(path.join(dir, skill, "SKILL.md"));
166
+ if (!keys.has("name")) errors.push(`plugin ${entry.name}: ${skill}/SKILL.md missing frontmatter name`);
167
+ if (!keys.has("description")) errors.push(`plugin ${entry.name}: ${skill}/SKILL.md missing frontmatter description`);
168
+ }
169
+ }
170
+ const rulesDir = path.join(pluginDir, "rules");
171
+ if (existsSync(rulesDir)) {
172
+ for (const rule of readdirSync(rulesDir).filter((f) => f.endsWith(".mdc"))) {
173
+ const keys = frontmatterKeys(path.join(rulesDir, rule));
174
+ if (!keys.has("description")) errors.push(`plugin ${entry.name}: ${rule} missing frontmatter description`);
175
+ }
176
+ }
177
+
178
+ // 6. No active runtime path targets an ignored dist file.
179
+ for (const rel of ["mcp.json", "hooks/hooks-cursor.json"]) {
180
+ const p = path.join(pluginDir, rel);
181
+ if (!existsSync(p)) continue;
182
+ const raw = readFileSync(p, "utf8");
183
+ if (raw.includes("dist/") || raw.includes("run-server") || raw.includes("session-start.js")) {
184
+ errors.push(`plugin ${entry.name}: ${rel} references ignored runtime output`);
185
+ }
186
+ }
187
+
188
+ // 7. Rebuilding the sanitized vendor tree yields no diff.
189
+ const sourceVendor = path.join(rootArg, "packages/workit-core/vendor/superpowers/skills");
190
+ const trackedVendor = path.join(pluginDir, "vendor/superpowers/skills");
191
+ if (existsSync(sourceVendor) && existsSync(trackedVendor)) {
192
+ const rebuilt = mkdtempSync(path.join(os.tmpdir(), "wk-vendor-rebuild-"));
193
+ try {
194
+ copySanitizedVendor(sourceVendor, rebuilt);
195
+ for (const diff of treesEqual(rebuilt, trackedVendor)) {
196
+ errors.push(`plugin ${entry.name}: vendor drift: ${diff}`);
197
+ }
198
+ } finally {
199
+ rmSync(rebuilt, { recursive: true, force: true });
200
+ }
201
+ } else {
202
+ errors.push(`plugin ${entry.name}: missing vendor source or tracked tree`);
203
+ }
204
+ }
205
+
206
+ return errors;
207
+ };
208
+
209
+ if (import.meta.main) {
210
+ const errors = validateMarketplace(root);
211
+ if (errors.length > 0) {
212
+ for (const e of errors) process.stderr.write(`${e}\n`);
213
+ process.stderr.write(`marketplace validation FAILED (${errors.length} error${errors.length === 1 ? "" : "s"})\n`);
214
+ process.exit(1);
215
+ }
216
+ console.log(`marketplace validation passed (root: ${root})`);
217
+ }
@@ -125,7 +125,7 @@ const resolve = (options: DoctorOptions): Resolved => {
125
125
  cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
126
126
  cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
127
127
  cursorPluginDir:
128
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workflow-toolkit"),
128
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
129
129
  env,
130
130
  installer: options.installer ?? false,
131
131
  };
@@ -323,7 +323,6 @@ const assetPathsFor = (host: DoctorHost, dev: string): string[] => {
323
323
  return [
324
324
  path.join(pkg, "assets", "templates", "spec-template.md"),
325
325
  path.join(pkg, "mcp.json"),
326
- path.join(pkg, "marketplace.json"),
327
326
  path.join(pkg, ".cursor-plugin"),
328
327
  ];
329
328
  case "cli":
@@ -377,12 +376,10 @@ const launcherSlotsFor = (host: DoctorHost, dev: string): string[][] => {
377
376
  case "opencode":
378
377
  return [[path.join(pkg, "src", "plugin.ts"), path.join(pkg, "dist", "plugin.js")]];
379
378
  case "cursor":
379
+ // The dist entries are the npm bin targets the npx launcher executes.
380
380
  return [
381
- [path.join(pkg, "dist", "mcp-server.js"), path.join(pkg, "mcp", "run-server.sh")],
382
- [
383
- path.join(pkg, "dist", "cursor-session-start.js"),
384
- path.join(pkg, "hooks", "session-start"),
385
- ],
381
+ [path.join(pkg, "dist", "mcp-server.js")],
382
+ [path.join(pkg, "dist", "cursor-session-start.js")],
386
383
  ];
387
384
  case "cli":
388
385
  return [[path.join(pkg, "src", "index.tsx"), path.join(pkg, "dist", "index.js")]];
@@ -405,9 +402,9 @@ const validNodeEntry = (entry: string, runtime: string, env: NodeJS.ProcessEnv):
405
402
  }
406
403
  };
407
404
 
408
- const registeredCursorLauncher = (
409
- res: Resolved,
410
- ): { runtime: string; entry: string } | null | "invalid" => {
405
+ type CursorLauncher = { kind: "node"; runtime: string; entry: string } | { kind: "npx" };
406
+
407
+ const registeredCursorLauncher = (res: Resolved): CursorLauncher | null | "invalid" => {
411
408
  if (!existsSync(res.cursorMcp)) return "invalid";
412
409
  const config = readJson(res.cursorMcp);
413
410
  if (!config) return null; // malformed_config owns malformed JSON/object reporting
@@ -419,8 +416,19 @@ const registeredCursorLauncher = (
419
416
  return "invalid";
420
417
  }
421
418
  const executable = path.basename(command).toLowerCase();
419
+ // CA-17: the canonical launcher runs the published package through npx; the
420
+ // offline doctor validates its shape (never the registry reachability).
421
+ if (executable === "npx" || executable === "npx.exe" || executable === "npx.cmd") {
422
+ // CA-17: exact positional tokens — a substring match would accept
423
+ // `@latest-alpha` or `workit-cursor-mcp-foo`.
424
+ if (args[0] !== "-y") return "invalid";
425
+ if (args[1] !== "--package=@brainervirus/workit-cursor@latest") return "invalid";
426
+ if (args[2] !== "workit-cursor-mcp") return "invalid";
427
+ return { kind: "npx" };
428
+ }
422
429
  if (executable !== "node" && executable !== "node.exe") return "invalid";
423
430
  return {
431
+ kind: "node",
424
432
  runtime: command,
425
433
  entry: path.isAbsolute(args[0]) ? args[0] : path.resolve(path.dirname(res.cursorMcp), args[0]),
426
434
  };
@@ -430,7 +438,10 @@ const checkLauncher = (res: Resolved): DoctorCheck => {
430
438
  const dev = res.dev;
431
439
  const hosts = hostsFor(res.host);
432
440
  const registered = hosts.includes("cursor") ? registeredCursorLauncher(res) : null;
433
- const runtime = registered && registered !== "invalid" ? registered.runtime : "node";
441
+ const runtime =
442
+ registered && registered !== "invalid" && registered.kind === "node"
443
+ ? registered.runtime
444
+ : "node";
434
445
  const missing = hosts.includes("cursor")
435
446
  ? ["dist/mcp-server.js", "dist/cursor-session-start.js"]
436
447
  .map((rel) => path.join(res.cursorPluginDir, rel))
@@ -440,7 +451,11 @@ const checkLauncher = (res: Resolved): DoctorCheck => {
440
451
  if (hosts.includes("cursor")) {
441
452
  if (registered === "invalid") {
442
453
  missing.push(`cursor: canonical workit MCP launcher in ${res.cursorMcp}`);
443
- } else if (registered && !validNodeEntry(registered.entry, registered.runtime, res.env)) {
454
+ } else if (
455
+ registered &&
456
+ registered.kind === "node" &&
457
+ !validNodeEntry(registered.entry, registered.runtime, res.env)
458
+ ) {
444
459
  missing.push(`cursor: registered ${registered.entry}`);
445
460
  }
446
461
  }
@@ -569,9 +584,15 @@ const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
569
584
  );
570
585
  }
571
586
  const dirs = Array.isArray(settings?.plugin_dirs)
572
- ? settings.plugin_dirs
573
- .map(String)
574
- .filter((d) => isWorkitPlugin(d) || d.includes("workflow-toolkit"))
587
+ ? settings.plugin_dirs.map(String).filter((d) => {
588
+ // Exact local plugin-dir identities only (CA-09): a similarly-named
589
+ // unrelated dir (e.g. `local/workflow-toolkit-extra`) is preserved
590
+ // and must never be counted as a Workit entry.
591
+ const n = d.replaceAll("\\", "/").replace(/\/+$/, "");
592
+ return (
593
+ isWorkitPlugin(d) || n.endsWith("local/workit") || n.endsWith("local/workflow-toolkit")
594
+ );
595
+ })
575
596
  : [];
576
597
  if (dirs.length > 1) problems.push(`cursor plugin_dirs has ${dirs.length} workit entries`);
577
598
  }
@@ -4,7 +4,6 @@
4
4
  // never rewritten — their values round-trip JSON-identical. The install
5
5
  // scripts (`packages/workit-core/scripts/install-*-plugin.sh`) import these so
6
6
  // there is exactly one source of truth for registration merging.
7
- import { existsSync } from "node:fs";
8
7
  import path from "node:path";
9
8
 
10
9
  export interface MergeResult<T> {
@@ -37,6 +36,8 @@ export function isWorkitPlugin(value: unknown): boolean {
37
36
  named(s, "workflow-toolkit") ||
38
37
  named(s, "workflow-toolkit-opencode") ||
39
38
  named(s, "local/workflow-toolkit") ||
39
+ named(s, "workit") ||
40
+ named(s, "local/workit") ||
40
41
  named(s, "@brainervirus/workit-opencode") ||
41
42
  named(s, "@brainervirus/workit-cursor") ||
42
43
  (url && pkgPath) ||
@@ -84,16 +85,17 @@ export function mergeOpenCodeConfig(
84
85
  return { config: base, changed };
85
86
  }
86
87
 
87
- /** Collapse current + legacy Cursor plugin identities to a single one. */
88
+ /** Collapse current + legacy Cursor plugin identities to the canonical `workit`. */
88
89
  export function mergeCursorEnabledPlugins(enabled: unknown): MergeResult<Record<string, boolean>> {
89
90
  const prev = isRecord(enabled) ? { ...(enabled as Record<string, boolean>) } : {};
90
- const next: Record<string, boolean> = { ...prev, "workflow-toolkit": true };
91
+ const next: Record<string, boolean> = { ...prev, workit: true };
92
+ delete next["workflow-toolkit"]; // legacy identity
91
93
  delete next["local/workflow-toolkit"]; // legacy duplicate identity
92
94
  const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["enabled_plugins"] : [];
93
95
  return { config: next, changed };
94
96
  }
95
97
 
96
- /** Append the plugin dir once, preserving any existing plugin directories. */
98
+ /** Append the plugin dir once, dropping the exact legacy sibling directory. */
97
99
  export function mergeCursorPluginDirs(
98
100
  pluginDirs: unknown,
99
101
  pluginDir: string,
@@ -105,13 +107,17 @@ export function mergeCursorPluginDirs(
105
107
  return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
106
108
  };
107
109
  const normalized = strip(pluginDir);
110
+ // CA-08: the legacy local plugin dir is the exact sibling of the canonical
111
+ // dir; remove only that entry, never a similarly-named unrelated dir (D3).
112
+ const legacy = strip(path.join(path.dirname(pluginDir), "workflow-toolkit"));
108
113
  const prev = Array.isArray(pluginDirs) ? pluginDirs.map(String) : [];
114
+ const kept = prev.filter((d) => strip(d) !== legacy);
109
115
  // Normalize both sides for comparison so a trailing-slash variant of an
110
116
  // existing entry is not appended as a duplicate; existing entries are kept
111
117
  // verbatim.
112
- const exists = prev.some((d) => strip(d) === normalized);
113
- const next = exists ? prev : [...prev, normalized];
114
- const changed = next.length !== prev.length ? ["plugin_dirs"] : [];
118
+ const exists = kept.some((d) => strip(d) === normalized);
119
+ const next = exists ? kept : [...kept, normalized];
120
+ const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["plugin_dirs"] : [];
115
121
  return { config: next, changed };
116
122
  }
117
123
 
@@ -174,42 +180,35 @@ export function mergeCursorHooks(
174
180
  }
175
181
 
176
182
  /**
177
- * Portable Cursor MCP server entry for an installed plugin dir: prefer the
178
- * self-contained node dist bundle (PT-10); fall back to the bash shim for a
179
- * dist-less dev checkout.
183
+ * Portable Cursor MCP server entry (CA-16/CA-17): launch the published package
184
+ * through npx against its npm bin, so the Marketplace plugin never depends on a
185
+ * repo-relative or untracked dist file.
180
186
  */
181
- export function cursorMcpServerEntry(packageDir: string): {
187
+ export function cursorMcpServerEntry(_packageDir: string): {
182
188
  command: string;
183
189
  args: string[];
184
190
  } {
185
- if (existsSync(path.join(packageDir, "dist", "mcp-server.js"))) {
186
- return {
187
- command: "node",
188
- args: [path.join(packageDir, "dist", "mcp-server.js"), "${workspaceFolder}"],
189
- };
190
- }
191
191
  return {
192
- command: "bash",
193
- args: [path.join(packageDir, "mcp", "run-server.sh"), "${workspaceFolder}"],
192
+ command: "npx",
193
+ args: [
194
+ "-y",
195
+ "--package=@brainervirus/workit-cursor@latest",
196
+ "workit-cursor-mcp",
197
+ "${workspaceFolder}",
198
+ ],
194
199
  };
195
200
  }
196
201
 
197
202
  /**
198
- * Portable Cursor sessionStart hook entry: prefer the self-contained node dist
199
- * bundle; fall back to the bash shim for a dist-less dev checkout (AR-06).
203
+ * Portable Cursor sessionStart hook entry (CA-17): a single command string in
204
+ * Cursor's documented format (no args array).
200
205
  */
201
- export function cursorHooksEntry(packageDir: string): {
206
+ export function cursorHooksEntry(_packageDir: string): {
202
207
  command: string;
203
208
  args: string[];
204
209
  } {
205
- if (existsSync(path.join(packageDir, "dist", "cursor-session-start.js"))) {
206
- return {
207
- command: "node",
208
- args: [path.join(packageDir, "dist", "cursor-session-start.js")],
209
- };
210
- }
211
210
  return {
212
- command: "bash",
213
- args: [path.join(packageDir, "hooks", "session-start")],
211
+ command: "npx -y --package=@brainervirus/workit-cursor@latest workit-cursor-session-start",
212
+ args: [],
214
213
  };
215
214
  }
package/src/core/setup.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import {
2
- chmodSync,
2
+ copyFileSync,
3
3
  cpSync,
4
4
  existsSync,
5
5
  mkdirSync,
@@ -505,7 +505,7 @@ const resolveSetupPaths = (
505
505
  cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
506
506
  cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
507
507
  cursorPluginDir:
508
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workflow-toolkit"),
508
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
509
509
  };
510
510
  };
511
511
 
@@ -804,22 +804,14 @@ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
804
804
  };
805
805
  }
806
806
 
807
- // Mirror the sync-runtime plugin mirror: copy the package (minus node_modules),
808
- // keep launchers executable, and write the same `.workflow-toolkit-root` marker
809
- // so a re-sync from the same source is a truthful Skipped.
810
- //
811
- // The installed plugin's mcp.json is a derived artifact: the shipped manifest
812
- // stays package-relative (PT-10), but Cursor spawns plugin MCP servers with
813
- // the workspace as cwd, so the installed copy must carry an absolute entry.
814
- const cursorMcpManifest = (dir: string): string =>
815
- JSON.stringify({ mcpServers: { workit: cursorMcpServerEntry(dir) } }, null, 2) + "\n";
816
-
807
+ // Mirror the sync-runtime plugin mirror: copy the package (minus node_modules)
808
+ // and write the same `.workflow-toolkit-root` marker so a re-sync from the same
809
+ // source is a truthful Skipped. The shipped mcp.json is the Marketplace-safe
810
+ // npx command (CA-17), so it is copied verbatim — no absolute dist derivation.
817
811
  const samePluginContent = (src: string, dest: string, relative = ""): boolean => {
818
812
  try {
819
813
  for (const entry of readdirSync(src, { withFileTypes: true })) {
820
814
  if (entry.name === "node_modules") continue;
821
- // mcp.json is derived at install time (cursorMcpManifest), not copied.
822
- if (relative === "" && entry.name === "mcp.json") continue;
823
815
  const source = path.join(src, entry.name);
824
816
  const installed = path.join(dest, entry.name);
825
817
  if (entry.isDirectory()) {
@@ -837,7 +829,6 @@ const samePluginContent = (src: string, dest: string, relative = ""): boolean =>
837
829
  const rel = path.join(relative, entry.name);
838
830
  if (rel === ".workflow-toolkit-root") continue;
839
831
  if (relative === "rules" && entry.isFile() && entry.name.endsWith(".mdc")) continue;
840
- if (relative === "" && entry.name === "mcp.json") continue;
841
832
  return false;
842
833
  }
843
834
  return true;
@@ -864,12 +855,7 @@ const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> =>
864
855
 
865
856
  function copyPluginDir(src: string, dest: string): SetupResultStatus {
866
857
  const marker = path.join(dest, ".workflow-toolkit-root");
867
- const synced =
868
- readFileSafe(marker)?.trim() === src &&
869
- samePluginContent(src, dest) &&
870
- // The mcp.json equality makes the Skipped verdict truthful: the derived
871
- // manifest on disk must be the one this install would write.
872
- readFileSafe(path.join(dest, "mcp.json")) === cursorMcpManifest(dest);
858
+ const synced = readFileSafe(marker)?.trim() === src && samePluginContent(src, dest);
873
859
  if (synced) return "Skipped";
874
860
  const hadDir = existsSync(dest);
875
861
  const rules = preservedCursorRules(src, dest);
@@ -888,13 +874,6 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
888
874
  writeFileSync(path.join(stage, "rules", name), content);
889
875
  }
890
876
  writeFileSync(path.join(stage, ".workflow-toolkit-root"), src + "\n", "utf8");
891
- for (const rel of ["hooks/session-start", "mcp/run-server.sh"]) {
892
- try {
893
- chmodSync(path.join(stage, rel), 0o755);
894
- } catch {
895
- /* optional launcher */
896
- }
897
- }
898
877
  if (!samePluginContent(src, stage)) throw new Error("staged adapter content is incomplete");
899
878
  if (hadDir) renameSync(dest, backup);
900
879
  try {
@@ -903,10 +882,6 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
903
882
  if (hadDir && !existsSync(dest)) renameSync(backup, dest);
904
883
  throw error;
905
884
  }
906
- // Derive the installed manifest against the FINAL installed path: the
907
- // stage path dies with the swap dir, and the entry's dist check needs the
908
- // live plugin dir to exist.
909
- writeFileSync(path.join(dest, "mcp.json"), cursorMcpManifest(dest), "utf8");
910
885
  rmSync(backup, { recursive: true, force: true });
911
886
  } finally {
912
887
  rmSync(swap, { recursive: true, force: true });
@@ -914,6 +889,30 @@ function copyPluginDir(src: string, dest: string): SetupResultStatus {
914
889
  return hadDir ? "Configured" : "Installed";
915
890
  }
916
891
 
892
+ // CA-08/CA-09: remove the exact legacy local plugin identity only AFTER the
893
+ // canonical `workit` copy and registration succeeded. The legacy dir's own
894
+ // user-compiled rules are carried forward first so no user data is lost; the
895
+ // `.workflow-toolkit-root` marker, share path, and unrelated sibling dirs are
896
+ // left untouched.
897
+ const removeLegacyCursorDir = (res: ResolvedApply): void => {
898
+ const legacy = path.join(res.home, ".cursor", "plugins", "local", "workflow-toolkit");
899
+ if (legacy === res.cursorPluginDir || !existsSync(legacy)) return;
900
+ const legacyRules = path.join(legacy, "rules");
901
+ if (existsSync(legacyRules)) {
902
+ try {
903
+ mkdirSync(path.join(res.cursorPluginDir, "rules"), { recursive: true });
904
+ for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
905
+ if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
906
+ const target = path.join(res.cursorPluginDir, "rules", entry.name);
907
+ if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
908
+ }
909
+ } catch {
910
+ /* best-effort rule carry-forward */
911
+ }
912
+ }
913
+ rmSync(legacy, { recursive: true, force: true });
914
+ };
915
+
917
916
  // One reviewed mutation per Cursor write target (AR-09): the settings merge and
918
917
  // the mcp merge are dispatched independently, exactly like the adapter copy.
919
918
  function applyCursorSettings(root: string, res: ResolvedApply): SetupResultEntry {
@@ -1042,6 +1041,10 @@ export function applySetupPreview(
1042
1041
  // path (a caller that resolved the preview with different options) fails
1043
1042
  // fast — Failed, no write — instead of silently writing an unreviewed path.
1044
1043
  const rootFor = new Map<Platform, string | null>();
1044
+ // CA-09: cursor registration and legacy cleanup only proceed after the
1045
+ // canonical copy succeeded — a failed replacement must never remove the
1046
+ // legacy identity or its registration.
1047
+ let cursorCopyOk = false;
1045
1048
  for (const mutation of preview.mutations) {
1046
1049
  if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
1047
1050
  entries.push(applyMutation(mutation));
@@ -1081,7 +1084,9 @@ export function applySetupPreview(
1081
1084
  }
1082
1085
  if (mutation.type === "install-adapter") {
1083
1086
  try {
1084
- entries.push({ platform, file: mutation.path, status: copyPluginDir(root, mutation.path) });
1087
+ const status = copyPluginDir(root, mutation.path);
1088
+ cursorCopyOk = true;
1089
+ entries.push({ platform, file: mutation.path, status });
1085
1090
  } catch (error) {
1086
1091
  entries.push({
1087
1092
  platform,
@@ -1092,6 +1097,9 @@ export function applySetupPreview(
1092
1097
  }
1093
1098
  } else if (platform === "opencode") {
1094
1099
  entries.push(applyOpenCode(root, res));
1100
+ } else if (!cursorCopyOk) {
1101
+ // CA-09: the canonical copy failed — leave the legacy registration intact.
1102
+ continue;
1095
1103
  } else {
1096
1104
  entries.push(
1097
1105
  mutation.path === res.cursorSettings
@@ -1100,6 +1108,15 @@ export function applySetupPreview(
1100
1108
  );
1101
1109
  }
1102
1110
  }
1111
+ // CA-08/CA-09: migrate the legacy local identity only after the canonical
1112
+ // copy AND registration both succeeded.
1113
+ if (
1114
+ preview.platforms.includes("cursor") &&
1115
+ cursorCopyOk &&
1116
+ !entries.some((e) => e.platform === "cursor" && e.status === "Failed")
1117
+ ) {
1118
+ removeLegacyCursorDir(res);
1119
+ }
1103
1120
  for (const preserved of preview.preserved) {
1104
1121
  entries.push({
1105
1122
  platform: "core",
@@ -6,7 +6,7 @@
6
6
  // host that has no executable artifact/test evidence.
7
7
  export const SUPPORT_MATRIX = {
8
8
  bun: "1.3.14",
9
- node: { minimum: "20", current: "22" },
9
+ node: { minimum: "22", current: "22" },
10
10
  opencode: { minimum: "1.15.0", current: "1.17.7" },
11
11
  os: ["ubuntu-latest", "macos-latest", "windows-latest"],
12
12
  } as const;
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
2
  import {
3
3
  accessSync,
4
4
  constants,
5
+ copyFileSync,
5
6
  existsSync,
6
7
  mkdirSync,
7
8
  readFileSync,
@@ -107,7 +108,7 @@ export async function syncRuntime(options: SyncRuntimeOptions = {}): Promise<Syn
107
108
  path.join(home, "Documents/projects/personal/workflow-toolkit");
108
109
  const repoSlug = options.repoSlug ?? env.WORKFLOW_TOOLKIT_REPO ?? "BrainerVirus/workit";
109
110
  const share = path.join(home, ".local/share/workflow-toolkit");
110
- const pluginDir = path.join(home, ".cursor/plugins/local/workflow-toolkit");
111
+ const pluginDir = path.join(home, ".cursor/plugins/local/workit");
111
112
  const opencodePlugins = path.join(home, ".config/opencode/plugins");
112
113
  const lock = path.join(
113
114
  options.lockDir ?? env.XDG_RUNTIME_DIR ?? "/tmp",
@@ -275,6 +276,23 @@ export async function syncRuntime(options: SyncRuntimeOptions = {}): Promise<Syn
275
276
  `${share}/packages/workit-core\n`,
276
277
  );
277
278
 
279
+ // CA-08/CA-09: migrate the legacy local plugin identity to `workit` only
280
+ // after the canonical sync succeeded; carry the legacy user rules forward
281
+ // first so no user data is lost.
282
+ const legacyDir = path.join(home, ".cursor", "plugins", "local", "workflow-toolkit");
283
+ if (legacyDir !== pluginDir && existsSync(legacyDir)) {
284
+ const legacyRules = path.join(legacyDir, "rules");
285
+ if (existsSync(legacyRules)) {
286
+ mkdirSync(path.join(pluginDir, "rules"), { recursive: true });
287
+ for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
288
+ if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
289
+ const target = path.join(pluginDir, "rules", entry.name);
290
+ if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
291
+ }
292
+ }
293
+ rmSync(legacyDir, { recursive: true, force: true });
294
+ }
295
+
278
296
  // Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished).
279
297
  rmSync(path.join(opencodePlugins, "workflow-toolkit.ts"), { force: true });
280
298