diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..9075eec9 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "workit", + "description": "Workit for Claude Code: workflow rails for agentic coding (task context, branch policy, method skills, and verification agents)", + "owner": { + "name": "BrainerVirus", + "url": "https://github.com/BrainerVirus" + }, + "plugins": [ + { + "name": "workit", + "description": "Session and per-turn task context, branch policy on git shell commands, the workit method skills, and verifier/reviewer/implementer agents", + "source": { + "source": "npm", + "package": "@brainervirus/workit-claude-code" + }, + "homepage": "https://github.com/BrainerVirus/workit#readme", + "license": "MIT", + "category": "development" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aba3839..6ab54c36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ env: NODE_CURRENT: "24.20.0" OPENCODE_MINIMUM: "1.18.30" OPENCODE_CURRENT: "1.18.34" + CLAUDE_CODE_VERSION: "2.1.288" ACTIONLINT_VERSION: "1.7.12" ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" ZIZMOR_VERSION: "1.30.1" @@ -142,6 +143,23 @@ jobs: - name: Build packages run: bun run build + # S14 PR gate: the real Claude Code CLI (pinned in the support matrix) + # validates the built plugin and the root marketplace in strict mode. + # No credential is needed; the packaging tier reuses this binary through + # WORKIT_CLAUDE_BIN to validate and install the packed plugin. + - name: Validate Claude Code plugin and marketplace + run: | # zizmor: ignore[adhoc-packages] -- exact support-matrix pin + set -euo pipefail + # Exact version pinned in the support matrix (no lockfile for a CLI tool). + npm install --no-save --prefix "$RUNNER_TEMP/claude" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + claude="$RUNNER_TEMP/claude/node_modules/.bin/claude" + export HOME="$RUNNER_TEMP/claude-home" CLAUDE_CONFIG_DIR="$RUNNER_TEMP/claude-home/.claude" + mkdir -p "$CLAUDE_CONFIG_DIR" + "$claude" --version + "$claude" plugin validate --strict packages/workit-claude-code + "$claude" plugin validate --strict . + echo "WORKIT_CLAUDE_BIN=$claude" >> "$GITHUB_ENV" + # Both tiers together cover every test directory (scripts/test.ts): # the unit tier excludes exactly the packaging suites. - name: Test (unit tier) diff --git a/.github/workflows/claude-eval.yml b/.github/workflows/claude-eval.yml new file mode 100644 index 00000000..4eddaf54 --- /dev/null +++ b/.github/workflows/claude-eval.yml @@ -0,0 +1,84 @@ +name: Claude Code plugin eval + +# Opt-in behavioral eval of the Claude Code plugin (design S14, §0 #15): it +# needs a Claude credential, costs money, and is nondeterministic, so it is a +# nightly / `eval`-label job, never a PR gate. The deterministic PR gate is +# `claude plugin validate --strict` plus the hook-fixture suite in ci.yml. +on: + schedule: + - cron: "17 5 * * *" + pull_request: + types: [labeled, synchronize] + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: claude-eval-${{ github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: "1.4.1" + NODE_CURRENT: "24.20.0" + CLAUDE_CODE_VERSION: "2.1.288" + +jobs: + eval: + name: claude plugin eval + if: > + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'eval') + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ env.BUN_VERSION }} + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_CURRENT }} + + - name: Install deps + run: bun install --frozen-lockfile + + # The suite runs against the checkout (local-pin layout): the hooks and + # bin/workit run from source with bun; only skills are generated. + - name: Generate plugin skills + run: bun packages/workit-claude-code/scripts/build.ts --skills-only + + - name: Run eval suite + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | # zizmor: ignore[adhoc-packages] -- exact support-matrix pin + set -euo pipefail + if [[ -z "${ANTHROPIC_API_KEY}" ]]; then + echo "::warning::ANTHROPIC_API_KEY is not configured; skipping the Claude Code eval" + exit 0 + fi + # Exact version pinned in the support matrix (no lockfile for a CLI tool). + npm install --no-save --prefix "$RUNNER_TEMP/claude" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + export HOME="$RUNNER_TEMP/claude-home" CLAUDE_CONFIG_DIR="$RUNNER_TEMP/claude-home/.claude" + mkdir -p "$CLAUDE_CONFIG_DIR" + "$RUNNER_TEMP/claude/node_modules/.bin/claude" plugin eval packages/workit-claude-code \ + --trust-plugin --scaffold --allow-tools Bash \ + --runs 1 -j 2 --max-cost-usd 2 --threshold 0.8 \ + --no-publish --json "$RUNNER_TEMP/eval-results.json" \ + --output-dir "$RUNNER_TEMP/eval-out" + + - name: Upload eval results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: claude-eval + path: | + ${{ runner.temp }}/eval-results.json + ${{ runner.temp }}/eval-out + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f999de7..aa6b3cb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: # checks-triggering runner must then finish it). GH_TOKEN: ${{ secrets.RELEASE_SYNC_TOKEN || secrets.GITHUB_TOKEN }} run: | - MANIFESTS=(package.json 'packages/*/package.json' 'packages/workit-cursor/.cursor-plugin/plugin.json' 'packages/workit-codex/.codex-plugin/plugin.json') + MANIFESTS=(package.json 'packages/*/package.json' 'packages/workit-cursor/.cursor-plugin/plugin.json' 'packages/workit-codex/.codex-plugin/plugin.json' 'packages/workit-claude-code/.claude-plugin/plugin.json') git checkout -- "${MANIFESTS[@]}" 2>/dev/null || true bun packages/workit-core/scripts/sync-release-manifests.ts if [[ -z "$(git status --porcelain -- "${MANIFESTS[@]}")" ]]; then diff --git a/.gitignore b/.gitignore index 01ce9244..bc418946 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ Thumbs.db dist/ *.log .cache/ + +# workit-claude-code: generated plugin skills and bundled CLI assets (scripts/build.ts) +packages/workit-claude-code/skills/ +packages/workit-claude-code/assets/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 88e1b121..dc490ddb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,6 +7,8 @@ ".cursor-plugin/**", "**/.cursor-plugin/**", "**/.codex-plugin/**", + ".claude-plugin/**", + "**/.claude-plugin/**", "packages/workit-cursor/mcp.json" ] } diff --git a/AGENTS.md b/AGENTS.md index 9284052f..0c2a5298 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Contract -Multi-platform workit: OpenCode, Cursor, Codex CLI/desktop, Pi, and the CLI share one core. Every feature must ship with **feature parity across hosts, implemented the best way each host allows**. +Multi-platform workit: OpenCode, Cursor, Codex CLI/desktop, Pi, Claude Code, and the CLI share one core. Every feature must ship with **feature parity across hosts, implemented the best way each host allows**. ## Host-native adaptation @@ -59,6 +59,14 @@ human may bind a writer to a Codex session explicitly with `workit writer acquire --actor `; the hook honors exactly that session and nothing else. +Claude Code uses the `packages/workit-claude-code` plugin: one exec-form hook +launcher maps every event through the shared host-hook protocol (never emitting +`allow`), skills are generated from `packages/workit-core/skills` at build time +(never committed), and `workit` reaches the Bash tool through the plugin `bin/`. +It installs either as latest published (root `.claude-plugin/marketplace.json`, +npm source) or as a local pin (`claude --plugin-dir` / `CLAUDE_CODE_PLUGIN_DIRS`), +where hooks and `bin/workit` run the sources with Bun. + Pi uses the stock 0.85.1 package contract. Its extension is self-contained apart from the Pi peer, reports native session/UI provenance truthfully, and bundles a coordinator for fresh stock-Pi reviewer/investigator and scoped @@ -82,7 +90,7 @@ is never available to supervised children. (`docs/workit-v1/qualification.md`). Do not invoke `scripts/run-v1-evaluation.ts` or fabricate batch results without explicit model/run/time/usage authorization. 7. Stale-install auto-load repair is automatic and fail-open: the doctor's `stale_install` finding (legacy `mcp.json`/hook selectors, a local-dist install behind the current/published runtime, or an OpenCode `@latest` package cache frozen behind the published `workit-opencode`) is enforced by `install-cursor-plugin.sh` via a `doctor-check.ts cursor --stale` pre-check for Cursor — exit 2 triggers a refresh + canonical re-registration, a healthy install is byte-untouched, and a registry-unreachable comparison warns as `registry_unreachable` (never `stale_install`, never an install failure). Canonical Cursor `@latest` installs never fail on version metadata. OpenCode npm pins keep the bare `@brainervirus/workit-opencode` identity; when OpenCode's `~/.cache/opencode/packages/@brainervirus/workit-opencode@latest` lags the registry, doctor fails with the exact cache path to delete so the next launch re-resolves. -8. Agent-facing behavior rules ship in the distributed surfaces: the invariant bootstrap (injected on OpenCode, Pi, Cursor, and Codex), the method skills (copied to every host), and adapter messages. This file documents this repository's development contract; a rule that lives only here never reaches installed workit instances. +8. Agent-facing behavior rules ship in the distributed surfaces: the invariant bootstrap (injected on OpenCode, Pi, Cursor, Codex, and Claude Code), the method skills (copied to every host), and adapter messages. This file documents this repository's development contract; a rule that lives only here never reaches installed workit instances. ### Where a rule lives diff --git a/README.md b/README.md index 2039aa97..68b58605 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Workit Multi-platform Workit workflow support for Cursor, OpenCode, Codex CLI/desktop, -Pi, and the CLI. The hosts share one task contract and eight operation families +Pi, Claude Code, and the CLI. The hosts share one task contract and eight operation families while adapting authority and lifecycle behavior to the native surfaces each host documents. @@ -17,6 +17,7 @@ completion; a local commit does not prove a remote push. | OpenCode | Native plugin with fourteen method skills, ten tools (eight shared families plus read-only context and init apply), and provider-safe schemas | | Cursor | MCP transport, one native hook dispatcher, one contract rule, and fourteen skills | | Codex | Native plugin manifest, shared MCP transport, documented lifecycle hooks, and fourteen skills | +| Claude Code | Native plugin: session/per-turn task context hooks, branch policy on git shell commands, fourteen skills, and verifier/reviewer/implementer agents | | Pi | Native npm extension with nine tools (eight shared families plus external action), fourteen skills, and session continuity | | Shared MCP | Low-level transport for the eight core operation families | | Shared core | Task, policy, evidence, finding, decision, worker, writer, and continuity state | @@ -25,7 +26,7 @@ completion; a local commit does not prove a remote push. ## Install Requires **Node.js 24 or newer**. The wizard detects your hosts, configures the -OpenCode, Cursor, Codex and Pi installations you pick, and writes your global config and optional project files: +OpenCode, Cursor, Codex, Pi and Claude Code installations you pick, and writes your global config and optional project files: ```bash npx @brainervirus/workit-cli init @@ -171,6 +172,71 @@ bound session and nothing else. +
+Claude Code — plugin (latest published, or pinned to a checkout) + +**Latest published.** The repository root is a Claude Code marketplace +(`.claude-plugin/marketplace.json`) whose entry installs the published +`@brainervirus/workit-claude-code` npm package. Select Claude Code in the +wizard, or install natively: + +```bash +claude plugin marketplace add BrainerVirus/workit +claude plugin install workit@workit +# later: refresh the marketplace, then update (Claude auto-update is off by default) +claude plugin marketplace update workit && claude plugin update workit@workit +``` + +`workit doctor` warns (`claude_plugin`) when a newer plugin version is +published than the one installed. `workit uninstall` previews and runs the +native `claude plugin uninstall workit@ --scope ` for each +Workit install that applies where you run it: user scope, plus project/local +scope installs of the current project (run from that project). + +**Local pin to a checkout.** Load the package straight from this repository; +hooks and `workit` on the Bash tool then run the TypeScript sources with Bun, +so edits apply without rebuilding the plugin. Only the generated skills need a +build step: + +```bash +bun install +bun packages/workit-claude-code/scripts/build.ts --skills-only # generate skills/ +claude --plugin-dir "$PWD/packages/workit-claude-code" # one session +# every session: export it from your shell profile instead +export CLAUDE_CODE_PLUGIN_DIRS="$HOME/path/to/workit/packages/workit-claude-code" +``` + +Disable the marketplace install while pinning (`claude plugin disable +workit@workit`) so the two copies do not both load. A pinned checkout needs +Bun on `PATH`; an installed plugin needs only Node.js 24+. + +The plugin ships: + +- hooks: `SessionStart` (startup/resume/clear/compact/fork) injects the Workit + contract and task context and exports `WORKIT_HOST`/`WORKIT_SESSION_ID` to + the session's shell; `UserPromptSubmit` re-injects task context only when it + changed since it was last injected; `PreToolUse` on `Bash`/`PowerShell` + `git *` commands denies protected or non-compliant branch operations with a + structured `permissionDecision: "deny"` (Claude Code 2.1.288 shows it as + `PreToolUse:Bash hook error: `; it never answers `allow`, so your + permission prompts stay in charge); `SubagentStart` tells the `implementer` + it works in its own worktree and other subagents that they are read-only. + No other events are registered; +- fail-open: if the hook runtime cannot start (no Bun for a pin, a missing or + unloadable `dist/`), the hook answers nothing and prints one + `[workit] Claude Code hook unavailable: …` line, and Claude runs as if Workit + were not installed; +- skills: the fourteen method skills, namespaced as `/workit:` + (`/workit:review`, `/workit:plan`, …); +- agents: `verifier` and `reviewer` (read-only) and `implementer` + (`isolation: worktree`); +- `workit` on the Bash tool's `PATH` (the plugin `bin/`). + +No MCP server is registered: Claude has a shell, and tool schemas cost +resident context. To opt in, add `workit-mcp` to your own Claude settings. + +
+
Pi — native extension @@ -376,6 +442,21 @@ native arbitrary-question receipt or attested writer delegation.
+
+Claude Code + +Claude Code runs one hook process per event (`node bin/workit-hook.mjs`, exec +form, no shell) through the shared host-hook protocol. Branch policy denies use +`permissionDecision: "deny"` with the unblock hint in the reason; Workit never +emits `allow`. `PreCompact` cannot inject context, so the task context is +restored by `SessionStart` with `source: "compact"` (no `PreCompact` hook is +registered). `SubagentStart` can only add context, never block or bind: the +worktree `implementer` is told it may edit and commit in its own worktree after +switching to a policy-compliant branch (Claude names worktree branches itself), +and every other subagent is observed as read-only/agent-guided. + +
+
Pi @@ -564,7 +645,7 @@ blocks publication on missing deterministic or live evidence. The 90-run live ba requires explicit authorization; see `docs/workit-v1/qualification.md`. Published bundles are built with Bun and run on Node. The Cursor, OpenCode, -Codex, and Pi package builds copy the fourteen canonical skills from `packages/workit-core`; no +Codex, Pi, and Claude Code package builds copy the fourteen canonical skills from `packages/workit-core`; no host-specific skill forks are maintained. ## Repository layout @@ -578,7 +659,9 @@ workit/ │ ├── workit-cursor/ # Cursor MCP, hooks, rule, and skills │ ├── workit-codex/ # Codex CLI/desktop MCP, hooks, and skills │ ├── workit-pi/ # Pi native extension, bundled core, and skills +│ ├── workit-claude-code/ # Claude Code plugin: hooks, agents, generated skills │ └── workit-cli/ # CLI setup wizard ├── .cursor-plugin/ # Marketplace metadata +├── .claude-plugin/ # Claude Code marketplace (npm-sourced plugin entry) └── test/ # repository verification ``` diff --git a/bun.lock b/bun.lock index 6b101fbd..256a9ed4 100644 --- a/bun.lock +++ b/bun.lock @@ -33,9 +33,17 @@ "typescript": "7.0.2", }, }, + "packages/workit-claude-code": { + "name": "@brainervirus/workit-claude-code", + "version": "2.1.5", + "devDependencies": { + "@brainervirus/workit-cli": "workspace:*", + "@brainervirus/workit-core": "workspace:*", + }, + }, "packages/workit-cli": { "name": "@brainervirus/workit-cli", - "version": "2.1.3", + "version": "2.1.5", "bin": { "workit": "./dist/index.js", }, @@ -50,7 +58,7 @@ }, "packages/workit-codex": { "name": "@brainervirus/workit-codex", - "version": "2.1.3", + "version": "2.1.5", "bin": { "workit-codex-hook": "./dist/workit-hook.js", "workit-codex-mcp": "./dist/launch-mcp.js", @@ -62,7 +70,7 @@ }, "packages/workit-core": { "name": "@brainervirus/workit-core", - "version": "2.1.3", + "version": "2.1.5", "dependencies": { "@openclaw/fs-safe": "0.8.1", "zod": "4.6.5", @@ -70,7 +78,7 @@ }, "packages/workit-cursor": { "name": "@brainervirus/workit-cursor", - "version": "2.1.3", + "version": "2.1.5", "bin": { "workit-cursor-hook": "./dist/workit-hook.js", "workit-cursor-mcp": "./dist/mcp-server.js", @@ -83,7 +91,7 @@ }, "packages/workit-mcp": { "name": "@brainervirus/workit-mcp", - "version": "2.1.3", + "version": "2.1.5", "bin": { "workit-mcp": "./dist/index.js", }, @@ -95,7 +103,7 @@ }, "packages/workit-opencode": { "name": "@brainervirus/workit-opencode", - "version": "2.1.3", + "version": "2.1.5", "dependencies": { "@brainervirus/workit-core": "workspace:*", }, @@ -106,7 +114,7 @@ }, "packages/workit-pi": { "name": "@brainervirus/workit-pi", - "version": "2.1.3", + "version": "2.1.5", "devDependencies": { "@earendil-works/pi-coding-agent": "0.85.1", }, @@ -222,6 +230,8 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@brainervirus/workit-claude-code": ["@brainervirus/workit-claude-code@workspace:packages/workit-claude-code"], + "@brainervirus/workit-cli": ["@brainervirus/workit-cli@workspace:packages/workit-cli"], "@brainervirus/workit-codex": ["@brainervirus/workit-codex@workspace:packages/workit-codex"], diff --git a/knip.json b/knip.json index 065e944c..d1bd43d0 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,8 @@ "packages/workit-cursor/scripts/build.ts", "packages/workit-codex/scripts/build.ts", "packages/workit-pi/scripts/build.ts", - "packages/workit-mcp/scripts/build.ts" + "packages/workit-mcp/scripts/build.ts", + "packages/workit-claude-code/scripts/build.ts" ], "workspaces": { ".": { @@ -55,6 +56,11 @@ "project": ["src/**/*.ts!", "extensions/**/*.ts!", "scripts/**/*.ts!"], "ignoreDependencies": ["@brainervirus/workit-core"] }, + "packages/workit-claude-code": { + "entry": ["src/hook.ts!", "src/run.ts!", "scripts/build.ts!"], + "project": ["src/**/*.ts!", "scripts/**/*.ts!"], + "ignoreDependencies": ["@brainervirus/workit-cli"] + }, "packages/workit-mcp": { "entry": ["scripts/build.ts!"], "project": ["src/**/*.ts!", "scripts/**/*.ts!"] diff --git a/package.json b/package.json index 14e1ad8f..ac7b907b 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "workit", "version": "2.5.0", "private": true, - "description": "Workit — workflow rails for agentic coding: one shared task and policy core with native OpenCode, Cursor, Codex, Pi, and CLI surfaces", + "description": "Workit — workflow rails for agentic coding: one shared task and policy core with native OpenCode, Cursor, Codex, Pi, Claude Code, and CLI surfaces", "keywords": [ "agentic-coding", "ai-agents", + "claude-code", "cursor", "mcp", "opencode", @@ -28,7 +29,7 @@ ".": "./packages/workit-opencode/src/plugin.ts" }, "scripts": { - "build": "bun packages/workit-mcp/scripts/build.ts && bun packages/workit-opencode/scripts/build.ts && bun packages/workit-cursor/scripts/build.ts && bun packages/workit-codex/scripts/build.ts && bun packages/workit-pi/scripts/build.ts && bun packages/workit-cli/scripts/build.ts", + "build": "bun packages/workit-mcp/scripts/build.ts && bun packages/workit-opencode/scripts/build.ts && bun packages/workit-cursor/scripts/build.ts && bun packages/workit-codex/scripts/build.ts && bun packages/workit-pi/scripts/build.ts && bun packages/workit-cli/scripts/build.ts && bun packages/workit-claude-code/scripts/build.ts", "verify:release-candidate": "bun scripts/verify-release-candidate.ts", "release": "semantic-release", "test:acceptance": "bun test test/acceptance/deterministic.test.ts", diff --git a/packages/workit-claude-code/.claude-plugin/plugin.json b/packages/workit-claude-code/.claude-plugin/plugin.json new file mode 100644 index 00000000..186317e2 --- /dev/null +++ b/packages/workit-claude-code/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "workit", + "displayName": "Workit", + "version": "2.4.0", + "description": "Workflow rails for agentic coding: task context on every session, branch policy on shell commands, and the workit method skills", + "author": { + "name": "BrainerVirus" + }, + "homepage": "https://github.com/BrainerVirus/workit#readme", + "repository": "https://github.com/BrainerVirus/workit", + "license": "MIT", + "keywords": [ + "agentic-coding", + "branch-policy", + "skills", + "task-management", + "workflow" + ] +} diff --git a/packages/workit-claude-code/README.md b/packages/workit-claude-code/README.md new file mode 100644 index 00000000..bf1902fa --- /dev/null +++ b/packages/workit-claude-code/README.md @@ -0,0 +1,46 @@ +# @brainervirus/workit-claude-code + +[![CI](https://github.com/BrainerVirus/workit/actions/workflows/ci.yml/badge.svg)](https://github.com/BrainerVirus/workit/actions/workflows/ci.yml) +[![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../../LICENSE) + +Workit plugin for Claude Code: task context on session start and per turn, +branch policy on `git` shell commands, fourteen method skills (`/workit:`), +and `verifier`, `reviewer` and `implementer` agents. + +## Install + +Latest published (the repository root is the marketplace; its entry installs +this npm package): + +```bash +claude plugin marketplace add BrainerVirus/workit +claude plugin install workit@workit +claude plugin marketplace update workit && claude plugin update workit@workit # update +``` + +Local pin to a checkout (hooks and `bin/workit` run the sources with Bun): + +```bash +bun install +bun packages/workit-claude-code/scripts/build.ts --skills-only +claude --plugin-dir "$PWD/packages/workit-claude-code" +# or, for every session: +export CLAUDE_CODE_PLUGIN_DIRS="$HOME/path/to/workit/packages/workit-claude-code" +``` + +`WORKIT_CLAUDE_RUNTIME=source|dist` forces one runtime; +`WORKIT_SHIM_TRACE=1 workit …` prints which `workit` entry ran. + +## Layout + +| Path | Purpose | +| --- | --- | +| `.claude-plugin/plugin.json` | Manifest (version synced at release) | +| `hooks/hooks.json` | SessionStart (startup/resume/clear/compact/fork), UserPromptSubmit, PreToolUse (Bash/PowerShell `git *`), SubagentStart | +| `bin/workit-hook.mjs` | Hook launcher: source with Bun in a checkout, `dist/workit-hook.js` when installed; fails open (`{}` + one stderr line) when neither can run | +| `bin/workit` | `workit` on the Bash tool's PATH (same source/dist switch) | +| `agents/` | `verifier`, `reviewer` (read-only), `implementer` (`isolation: worktree`) | +| `skills/` | Generated by `scripts/build.ts` from `packages/workit-core/skills` (not committed) | +| `evals/` | Opt-in `claude plugin eval` suite (nightly / `eval` label, not a PR gate) | + +Requires Node.js 24+ (installed plugin) or Bun (local pin). diff --git a/packages/workit-claude-code/agents/implementer.md b/packages/workit-claude-code/agents/implementer.md new file mode 100644 index 00000000..6da76d45 --- /dev/null +++ b/packages/workit-claude-code/agents/implementer.md @@ -0,0 +1,26 @@ +--- +name: implementer +description: Implements one scoped workit brief in an isolated git worktree and reports what changed and how it was verified. Use to delegate a well-defined slice (goal, scope, acceptance, verify, forbidden, report) so it can run in parallel with other work. +isolation: worktree +--- + +You are a workit implementer working in your own git worktree. + +Refuse to start without a complete brief. It must state: goal, scope (files +or areas you may touch), acceptance (observable outcomes), verify (exact +commands), forbidden (what you must not touch or do), and report (what to +hand back). If anything is missing, report which field is missing and stop. + +1. Your worktree starts on a branch name chosen by Claude Code, which may not + satisfy the repository's branch policy. Before any commit, create or switch + to a policy-compliant branch (`workit` CLI branch setup when available, + otherwise `git switch -c /`). A hook denies protected or + non-compliant branch names; follow the unblock hint it prints. +2. Stay inside the declared scope. Anything outside it goes in the report as + a follow-up, not into the diff. +3. Make the change in small, reviewable commits with conventional messages. +4. Run every verify command from the brief and keep the real exit codes. +5. Report: branch and head SHA, files changed, each verify command with its + exit code, acceptance items met or not, and any deviation from the brief. + +Never push, open a PR, or merge unless the brief explicitly asks for it. diff --git a/packages/workit-claude-code/agents/reviewer.md b/packages/workit-claude-code/agents/reviewer.md new file mode 100644 index 00000000..828bafc9 --- /dev/null +++ b/packages/workit-claude-code/agents/reviewer.md @@ -0,0 +1,23 @@ +--- +name: reviewer +description: Fresh-context code reviewer for a workit candidate. Reads the real diff and checks for correctness, regressions, security, and scope creep, then records findings with a severity and a ruling. Use for independent review of a branch or PR. +tools: Read, Grep, Glob, Bash +disallowedTools: Write, Edit, NotebookEdit +--- + +You are the workit reviewer. Review the real candidate, not the author's +description of it. + +1. Identify the candidate: `git rev-parse HEAD`, the base branch, and + `git diff ...HEAD`. For a PR, read its status and unresolved threads + with `gh`/`glab` (or `workit pr status` when the CLI offers it). +2. Read the task brief: goal, scope, forbidden areas, acceptance. +3. Examine the diff for correctness, regression risk, security or data + consequences, missing tests, and changes outside the declared scope. + Confirm claims by reading code and check output; never infer evidence. +4. Report each finding as: file:line, severity (blocker / major / minor / + nit), what is wrong, and the concrete fix. End with a ruling: approve, + approve with nits, or request changes. + +Stay read-only: you may run read and test commands, never commits, pushes, +merges, or edits. diff --git a/packages/workit-claude-code/agents/verifier.md b/packages/workit-claude-code/agents/verifier.md new file mode 100644 index 00000000..ff6e14f8 --- /dev/null +++ b/packages/workit-claude-code/agents/verifier.md @@ -0,0 +1,26 @@ +--- +name: verifier +description: Independently verifies a workit candidate (a branch, commit, or PR) by running its real checks and reporting a pass/fail verdict with evidence. Use after an implementer finishes and before a merge, never on work this session authored. +tools: Read, Grep, Glob, Bash +disallowedTools: Write, Edit, NotebookEdit +--- + +You are the workit verifier. You did not write the candidate, and you must not +change it: you only observe and report. + +1. Pin the candidate first: `git rev-parse HEAD` and `git status --short`. A + dirty tree or a moving HEAD is itself a finding. +2. Read the task brief (goal, scope, acceptance, verify commands). If the + repository has a `verify-` script or skill, run it; otherwise run the + verify commands from the brief exactly as written. Never substitute an + easier command. +3. Run each check through the workit CLI when it is available (`workit + --help` lists the verbs), so the result is CLI-observed rather than + claimed. Capture the exit code and the failing output tail. +4. Check every acceptance item against observed behavior, not against the + author's summary. +5. Report: candidate SHA, each check with its exit code, each acceptance item + as met / not met / not verifiable, and an overall verdict (pass or fail). + When the CLI offers a verdict verb, record the verdict with it. + +Do not fix anything you find. A failing check is a verdict, not a task. diff --git a/packages/workit-claude-code/bin/workit b/packages/workit-claude-code/bin/workit new file mode 100755 index 00000000..78f7f61f --- /dev/null +++ b/packages/workit-claude-code/bin/workit @@ -0,0 +1,24 @@ +#!/bin/sh +# `workit` on the Claude Code Bash tool's PATH (Claude adds a plugin's bin/). +# Local pin: run the CLI from the monorepo sources with bun. Installed +# package: run the bundled dist/workit.js with node. +# WORKIT_CLAUDE_RUNTIME=source|dist forces one; WORKIT_SHIM_TRACE=1 prints +# the resolved entry on stderr. +set -e +here=$(dirname "$0") +root=$(cd "$here/.." && pwd) +main="$root/../workit-cli/src/main.ts" +entry="" +if [ "${WORKIT_CLAUDE_RUNTIME:-}" != "dist" ] && [ -f "$root/../workit-core/src/core.ts" ] && [ -f "$main" ]; then + entry="$main" +fi +if [ "${WORKIT_CLAUDE_RUNTIME:-}" = "source" ] && [ -z "$entry" ]; then + echo "workit: WORKIT_CLAUDE_RUNTIME=source but no monorepo CLI source next to $root" >&2 + exit 1 +fi +if [ -n "$entry" ]; then + [ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: source $entry" >&2 + exec bun "$entry" "$@" +fi +[ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: dist $root/dist/workit.js" >&2 +exec node "$root/dist/workit.js" "$@" diff --git a/packages/workit-claude-code/bin/workit-hook.mjs b/packages/workit-claude-code/bin/workit-hook.mjs new file mode 100755 index 00000000..0ac11bf7 --- /dev/null +++ b/packages/workit-claude-code/bin/workit-hook.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Claude Code hook launcher (hooks/hooks.json runs `node `, exec +// form, so no shell is involved on any OS). Two layouts: +// - local pin (`claude --plugin-dir /packages/workit-claude-code`): +// the monorepo sources sit next to this package, so the TypeScript entry +// runs from source with bun: edits apply without a rebuild; +// - installed package (npm/marketplace): dist/workit-hook.js is imported. +// WORKIT_CLAUDE_RUNTIME=source|dist forces one. +// Fail-open: when the runtime cannot start (no bun for the pin, a missing or +// unloadable dist/), the launcher answers `{}` with exit 0 and one +// `[workit] Claude Code hook unavailable: …` line on stderr. Claude then +// proceeds as if no Workit hook were installed (no context, no branch +// policy): a broken hook must never brick the host. +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = path.join(root, "src", "run.ts"); +const dist = path.join(root, "dist", "workit-hook.js"); +const mode = process.env.WORKIT_CLAUDE_RUNTIME; +const fromSource = + mode === "source" || + (mode !== "dist" && + existsSync(source) && + existsSync(path.join(root, "..", "workit-core", "src", "hooks", "index.ts"))); + +const failOpen = (reason) => { + process.stderr.write(`[workit] Claude Code hook unavailable: ${reason}\n`); + process.stdout.write("{}\n"); +}; + +if (fromSource) { + const run = spawnSync(process.env.WORKIT_BUN ?? "bun", [source], { + stdio: "inherit", + windowsHide: true, + }); + if (run.error) failOpen(`bun is required for the local pin (${run.error.message})`); + else process.exitCode = run.status ?? 0; +} else if (existsSync(dist)) { + let runClaudeHook; + try { + ({ runClaudeHook } = await import(pathToFileURL(dist).href)); + } catch (error) { + failOpen(`cannot load ${dist} (${error instanceof Error ? error.message : String(error)})`); + } + if (runClaudeHook) process.exitCode = await runClaudeHook(process.stdin, process.stdout); +} else { + failOpen(`${dist} is missing; run \`bun scripts/build.ts\` in ${root}`); +} diff --git a/packages/workit-claude-code/evals/branch-policy-deny/case.yaml b/packages/workit-claude-code/evals/branch-policy-deny/case.yaml new file mode 100644 index 00000000..f83b4b79 --- /dev/null +++ b/packages/workit-claude-code/evals/branch-policy-deny/case.yaml @@ -0,0 +1,24 @@ +schema_version: "1.0" +name: branch-policy-deny +description: Creating a protected branch through the Bash tool is denied by the PreToolUse hook. +tags: [hooks, policy] +context: + scaffold_script: ../scaffold/git-repo.sh +execution: + prompt: >- + Run exactly this command with the Bash tool and nothing else: + git checkout -b main + Then report the exact denial or error text you received, verbatim. + max_turns: 4 + allowed_tools: [Bash] +runs: 1 +graders: + - type: regex + name: hook-denied + target: trace + pattern: "branch_policy_denied" + - type: regex + name: reports-protected-ref + target: last_message + pattern: "protected" + flags: i diff --git a/packages/workit-claude-code/evals/scaffold/git-repo.sh b/packages/workit-claude-code/evals/scaffold/git-repo.sh new file mode 100755 index 00000000..83369a39 --- /dev/null +++ b/packages/workit-claude-code/evals/scaffold/git-repo.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Eval scaffold: a git repository on a protected `main` with one commit, so +# the plugin's SessionStart context and PreToolUse branch policy apply. +# Runs in the eval's working directory (claude plugin eval --scaffold). +set -euo pipefail +git init -q -b main . +git config user.email "eval@workit.invalid" +git config user.name "workit eval" +printf 'export const add = (a: number, b: number) => a + b;\n' > add.ts +git add add.ts +git commit -q -m "feat: add" +printf 'export const add = (a: number, b: number) => a - b;\n' > add.ts +git commit -q -am "fix: adjust add" diff --git a/packages/workit-claude-code/evals/session-context/case.yaml b/packages/workit-claude-code/evals/session-context/case.yaml new file mode 100644 index 00000000..ff4fd3af --- /dev/null +++ b/packages/workit-claude-code/evals/session-context/case.yaml @@ -0,0 +1,24 @@ +schema_version: "1.0" +name: session-context +description: SessionStart injects the workit contract into a fresh session. +tags: [hooks, context] +context: + scaffold_script: ../scaffold/git-repo.sh +execution: + prompt: >- + Without running any tools, tell me whether this session received workit + coordination context at startup. If it did, quote the exact name of the + XML-style tag that wraps it. + max_turns: 2 + allowed_tools: [] +runs: 1 +graders: + - type: regex + name: contract-tag-quoted + target: last_message + pattern: "workit-contract" + - type: llm + name: answers-from-context + criteria: >- + The response states that workit context was received at session start + and names the wrapping tag (workit-contract) rather than guessing. diff --git a/packages/workit-claude-code/evals/skill-triggers/case.yaml b/packages/workit-claude-code/evals/skill-triggers/case.yaml new file mode 100644 index 00000000..425155ef --- /dev/null +++ b/packages/workit-claude-code/evals/skill-triggers/case.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: skill-triggers +description: An independent-review request loads the plugin's review skill. +tags: [skills] +context: + scaffold_script: ../scaffold/git-repo.sh +execution: + prompt: >- + Do an independent correctness and regression review of the latest commit + in this repository and report your findings. + max_turns: 12 + allowed_tools: [Read, Grep, Glob, Bash, Skill] +runs: 1 +graders: + - type: tool_used + name: review-skill-loaded + tool: Skill + input_match: "review" + - type: llm + name: finds-the-regression + criteria: >- + The review identifies that the latest commit changes add() to subtract + (a - b), which is a correctness regression. diff --git a/packages/workit-claude-code/hooks/hooks.json b/packages/workit-claude-code/hooks/hooks.json new file mode 100644 index 00000000..16296ea0 --- /dev/null +++ b/packages/workit-claude-code/hooks/hooks.json @@ -0,0 +1,68 @@ +{ + "description": "Workit host hooks: task context on session start (incl. compaction restore and forks) and when it changes per turn, branch policy on git shell commands, and worktree guidance for subagents. Only events that change Claude's behavior are registered.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|fork", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git *)", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + }, + { + "matcher": "PowerShell", + "hooks": [ + { + "type": "command", + "if": "PowerShell(git *)", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + } + ] + } +} diff --git a/packages/workit-claude-code/package.json b/packages/workit-claude-code/package.json new file mode 100644 index 00000000..5dc9af64 --- /dev/null +++ b/packages/workit-claude-code/package.json @@ -0,0 +1,48 @@ +{ + "name": "@brainervirus/workit-claude-code", + "version": "2.4.0", + "private": false, + "description": "Workit Claude Code plugin — session and per-turn task context, branch policy on git shell commands, workit method skills, and verifier/reviewer/implementer agents", + "keywords": [ + "agentic-coding", + "ai-agents", + "claude-code", + "hooks", + "plugin", + "skills", + "workflow" + ], + "homepage": "https://github.com/BrainerVirus/workit#readme", + "bugs": { + "url": "https://github.com/BrainerVirus/workit/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/BrainerVirus/workit.git" + }, + "files": [ + ".claude-plugin/", + "hooks/hooks.json", + "bin/", + "agents/", + "skills/", + "assets/", + "dist/", + "README.md" + ], + "type": "module", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun scripts/build.ts" + }, + "devDependencies": { + "@brainervirus/workit-cli": "workspace:*", + "@brainervirus/workit-core": "workspace:*" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/workit-claude-code/scripts/build.ts b/packages/workit-claude-code/scripts/build.ts new file mode 100644 index 00000000..f02386c4 --- /dev/null +++ b/packages/workit-claude-code/scripts/build.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +// Build the Claude Code plugin payload: +// dist/workit-hook.js the hook entry (src/hook.ts → core/hooks claude-code) +// dist/workit.js the bundled workit CLI for bin/workit +// assets/templates/ templates the bundled CLI resolves at runtime +// skills// generated from workit-core/skills: `workit-review` +// becomes plugin skill `review` (invoked /workit:review) +// Usage: bun scripts/build.ts [target-dir] [--skills-only] +// The target defaults to this package; the pack sandbox passes its copy. +// `--skills-only` is the local-pin step: the hooks and bin/workit run from +// source there, so only the generated skills are needed. +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WORKIT_METHOD_SKILLS } from "../../workit-core/src/core/skill-manifests"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packagesDir = path.resolve(packageDir, ".."); +const coreDir = path.join(packagesDir, "workit-core"); +const args = process.argv.slice(2); +const skillsOnly = args.includes("--skills-only"); +const targetArg = args.find((arg) => !arg.startsWith("--")); +const target = targetArg ? path.resolve(targetArg) : packageDir; + +const PREFIX = "workit-"; + +/** Plugin skills are namespaced by the plugin (`/workit:`), so the + * `workit-` prefix is dropped from the directory and the frontmatter name. */ +const pluginSkillName = (name: string): string => + name.startsWith(PREFIX) ? name.slice(PREFIX.length) : name; + +const CLAUDE_NOTE = ` + +## In Claude Code + +Workit operations (\`task\`, \`evidence\`, \`policy\`, \`decision\`, …) run through +the \`workit\` CLI on the Bash tool: \`workit --json\` +(\`workit --help\` lists the verbs). The plugin's \`verifier\`, \`reviewer\` +and \`implementer\` agents take independent verification, fresh-context +review and isolated implementation. +`; + +const transformSkill = (text: string, from: string, to: string): string => { + const renamed = text.replace(new RegExp(`^name:\\s*${from}\\s*$`, "m"), `name: ${to}`); + if (renamed === text) throw new Error(`skill ${from}: frontmatter name: ${from} not found`); + return `${renamed.trimEnd()}\n${CLAUDE_NOTE}`; +}; + +const buildSkills = () => { + const skills = path.join(target, "skills"); + rmSync(skills, { recursive: true, force: true }); + mkdirSync(skills, { recursive: true }); + for (const name of WORKIT_METHOD_SKILLS) { + const source = path.join(coreDir, "skills", name); + if (!existsSync(path.join(source, "SKILL.md"))) + throw new Error(`missing canonical Workit method skill in core: ${name}`); + const out = path.join(skills, pluginSkillName(name)); + cpSync(source, out, { recursive: true }); + const file = path.join(out, "SKILL.md"); + writeFileSync(file, transformSkill(readFileSync(file, "utf8"), name, pluginSkillName(name))); + } +}; + +const bundle = (entry: string, outfile: string, minify = false) => { + const result = spawnSync( + process.execPath, + [ + "build", + entry, + "--outfile", + outfile, + "--target", + "node", + "--format", + "esm", + "--banner", + "#!/usr/bin/env node", + ...(minify ? ["--minify"] : []), + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(1); + } +}; + +if (!skillsOnly) { + const dist = path.join(target, "dist"); + rmSync(dist, { recursive: true, force: true }); + mkdirSync(dist, { recursive: true }); + // Minified: every hook is a fresh node process, so parse time is startup time. + bundle(path.join(packageDir, "src", "hook.ts"), path.join(dist, "workit-hook.js"), true); + bundle(path.join(packagesDir, "workit-cli", "src", "main.ts"), path.join(dist, "workit.js")); + const assets = path.join(target, "assets"); + rmSync(assets, { recursive: true, force: true }); + const templates = path.join(coreDir, "templates"); + if (existsSync(templates)) cpSync(templates, path.join(assets, "templates"), { recursive: true }); +} +buildSkills(); + +console.log( + `claude-code: built ${skillsOnly ? "" : "dist/ (hook + CLI), assets/ and "}${WORKIT_METHOD_SKILLS.length} plugin skills (${target})`, +); diff --git a/packages/workit-claude-code/src/hook.ts b/packages/workit-claude-code/src/hook.ts new file mode 100644 index 00000000..c89acca4 --- /dev/null +++ b/packages/workit-claude-code/src/hook.ts @@ -0,0 +1,169 @@ +// Claude Code hook entry. Every hook in hooks/hooks.json runs one process +// (bin/workit-hook.mjs): stdin (native payload) → core/hooks claude-code +// adapter → stdout. +// The plugin adds only what is Claude-specific and process-scoped: +// - SessionStart exports WORKIT_HOST/WORKIT_SESSION_ID through +// $CLAUDE_ENV_FILE, so `workit` calls on the Bash tool know their host; +// - UserPromptSubmit re-injects task context only when it changed since the +// last injection for the session (each hook is a fresh process, so the +// cache lives in ${CLAUDE_PLUGIN_DATA}/ctx/.json). +import { createHash } from "node:crypto"; +import { + appendFileSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { claudeCodeAdapter, dispatchHook, type HostAdapter } from "@brainervirus/workit-core/hooks"; + +type Sink = { write(chunk: string): unknown }; + +/** Session-start addendum: how the shared contract's names map onto this plugin. */ +export const CLAUDE_ADDENDUM = + "Claude Code: the workit- skills are this plugin's /workit: skills " + + "(workit-steer is /workit:steer). Run Workit operations with the `workit` CLI on the " + + "Bash tool (`workit --help`). The plugin's verifier and reviewer agents are read-only; " + + "its implementer agent works in an isolated worktree."; + +const adapter: HostAdapter = { ...claudeCodeAdapter, addendum: () => CLAUDE_ADDENDUM }; +type Payload = Record; + +const isRecord = (value: unknown): value is Payload => + value !== null && typeof value === "object" && !Array.isArray(value); + +const text = (value: unknown): string | null => + typeof value === "string" && value.trim() !== "" ? value : null; + +/** A filename-safe key for a session id (ids are opaque host strings). */ +const sessionKey = (id: string) => createHash("sha256").update(id).digest("hex").slice(0, 32); + +const contextCache = (env: NodeJS.ProcessEnv, sessionId: string): string | null => { + const data = text(env.CLAUDE_PLUGIN_DATA); + return data ? path.join(data, "ctx", `${sessionKey(sessionId)}.json`) : null; +}; + +const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + +/** SessionStart side effect: best effort, never changes the hook decision. */ +const exportSessionEnv = (env: NodeJS.ProcessEnv, sessionId: string): void => { + const file = text(env.CLAUDE_ENV_FILE); + if (!file) return; + try { + appendFileSync( + file, + `export WORKIT_HOST=claude_code\nexport WORKIT_SESSION_ID=${shellQuote(sessionId)}\n`, + ); + } catch { + // An unwritable env file only loses the convenience export. + } +}; + +const digestOf = (context: string) => createHash("sha256").update(context).digest("hex"); + +const recordTurnContext = (file: string, digest: string): void => { + try { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ digest })}\n`); + } catch { + // Unwritable plugin data dir: context is simply sent every turn. + } +}; + +/** + * Per-turn dedup: returns true when `context` equals the last context + * injected for this session (and records it otherwise). Any cache failure + * answers false, so context is re-sent rather than lost. + */ +const unchangedTurnContext = (file: string | null, context: string): boolean => { + if (!file) return false; + const digest = digestOf(context); + try { + const previous = JSON.parse(readFileSync(file, "utf8")) as { digest?: unknown }; + if (previous.digest === digest) return true; + } catch { + // Missing or unreadable cache: treat as changed. + } + recordTurnContext(file, digest); + return false; +}; + +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Drops per-session caches untouched for a week (sessions that ended). */ +const pruneContextCaches = (dir: string, now: number): void => { + try { + for (const name of readdirSync(dir)) { + const file = path.join(dir, name); + try { + if (now - statSync(file).mtimeMs > CACHE_TTL_MS) rmSync(file, { force: true }); + } catch { + // Raced with another session: nothing to prune. + } + } + } catch { + // No cache dir yet. + } +}; + +/** + * SessionStart already injected the task context (inside the contract), so + * the session's cache is seeded with the per-turn context the next prompt + * would carry: the first turn after a start, resume or compaction does not + * resend it. A session without task context clears the cache instead. + */ +const seedTurnContext = ( + file: string | null, + payload: Payload, + env: NodeJS.ProcessEnv, + now: number, +): void => { + if (!file) return; + pruneContextCaches(path.dirname(file), now); + const turn = dispatchHook(adapter, { ...payload, hook_event_name: "UserPromptSubmit" }, env); + const output = isRecord(turn.json.hookSpecificOutput) ? turn.json.hookSpecificOutput : null; + const context = output ? text(output.additionalContext) : null; + if (context && !turn.error) recordTurnContext(file, digestOf(context)); + else rmSync(file, { force: true }); +}; + +/** Runs one Claude Code hook invocation and returns the process exit code. */ +export async function runClaudeHook( + stdin: AsyncIterable | Iterable, + stdout: Sink, + stderr: Sink = process.stderr, + env: NodeJS.ProcessEnv = process.env, +): Promise { + let input = ""; + for await (const chunk of stdin) input += String(chunk); + let raw: unknown; + try { + raw = JSON.parse(input || "{}"); + } catch { + raw = undefined; + } + const result = dispatchHook(adapter, raw, env); + if (raw === undefined || result.error) + stderr.write( + `[workit] hook input rejected: ${raw === undefined ? "invalid JSON hook input" : result.error}\n`, + ); + let json = result.json; + const payload = isRecord(raw) ? raw : {}; + const sessionId = text(payload.session_id); + if (sessionId && !result.error) { + const cache = contextCache(env, sessionId); + if (payload.hook_event_name === "SessionStart") { + exportSessionEnv(env, sessionId); + seedTurnContext(cache, payload, env, Date.now()); + } else if (payload.hook_event_name === "UserPromptSubmit") { + const output = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : null; + const context = output ? text(output.additionalContext) : null; + if (context && unchangedTurnContext(cache, context)) json = {}; + } + } + stdout.write(`${JSON.stringify(json)}\n`); + return result.exitCode; +} diff --git a/packages/workit-claude-code/src/run.ts b/packages/workit-claude-code/src/run.ts new file mode 100644 index 00000000..effbcb71 --- /dev/null +++ b/packages/workit-claude-code/src/run.ts @@ -0,0 +1,5 @@ +// Local-pin hook process (bin/workit-hook.mjs spawns `bun src/run.ts`). The +// installed package imports dist/workit-hook.js and calls runClaudeHook itself. +import { runClaudeHook } from "./hook"; + +process.exitCode = await runClaudeHook(process.stdin, process.stdout); diff --git a/packages/workit-cli/src/index.tsx b/packages/workit-cli/src/index.tsx index cbe751ad..b953c78b 100755 --- a/packages/workit-cli/src/index.tsx +++ b/packages/workit-cli/src/index.tsx @@ -166,6 +166,7 @@ const UNINSTALL_HOST_OPTIONS = [ { label: "Cursor", value: "cursor" as const }, { label: "Codex", value: "codex" as const }, { label: "Pi", value: "pi" as const }, + { label: "Claude Code", value: "claude-code" as const }, ]; function UninstallWizard({ onExit }: { onExit: (outcome: UninstallOutcome) => void }): JSX.Element { diff --git a/packages/workit-cli/src/steps.tsx b/packages/workit-cli/src/steps.tsx index cc5a0de6..70e9a0a1 100644 --- a/packages/workit-cli/src/steps.tsx +++ b/packages/workit-cli/src/steps.tsx @@ -55,6 +55,7 @@ const PLATFORM_LABELS: { label: string; value: HostId }[] = [ { label: "Cursor", value: "cursor" }, { label: "Codex", value: "codex" }, { label: "Pi", value: "pi" }, + { label: "Claude Code", value: "claude-code" }, ]; /** Wizard platform options with auto-detect tags (pure: takes the detection). */ diff --git a/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index 031dcd12..2117bc21 100644 --- a/packages/workit-core/scripts/analyze-release-scope.ts +++ b/packages/workit-core/scripts/analyze-release-scope.ts @@ -6,6 +6,7 @@ // them; merge-backs and the release's own manifest sync never do. import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; +import BUNDLED_DEPS_JSON from "./bundled-deps.json" with { type: "json" }; export const RELEASE_PACKAGES = [ "workit-core", @@ -15,8 +16,90 @@ export const RELEASE_PACKAGES = [ "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ] as const; +type ReleasePackage = (typeof RELEASE_PACKAGES)[number]; + +/** + * Sources a package's published dist/ inlines from OUTSIDE its own directory. + * Every adapter build is a no-external `bun build`, so whatever it imports + * from another workspace package is copied into its bundle: a runtime + * dependency on that package does not reach the shipped code. A change in + * these sources must therefore republish the bundling package too. Verified + * against the build entries' metafiles by + * test/workit-core/bundled-sources.test.ts. + */ +const CORE = "packages/workit-core/"; // sources, skills, templates and package.json +// Cursor and Codex inline the MCP transport and its declared dependencies. +const MCP = ["packages/workit-mcp/src/", "packages/workit-mcp/package.json"]; +// The bundled CLI inlines its sources, package.json (`workit --version`) and +// its third-party dependencies. +const CLI = ["packages/workit-cli/src/", "packages/workit-cli/package.json"]; +export const BUNDLED_SOURCES: Partial> = { + "workit-mcp": [CORE], + "workit-cli": [CORE], + "workit-opencode": [CORE], + "workit-cursor": [CORE, ...MCP], + "workit-codex": [CORE, ...MCP], + "workit-pi": [CORE], + "workit-claude-code": [CORE, ...CLI], +}; + +/** + * Third-party packages each dist/ inlines, as bun.lock `packages` keys + * (`zod`, or `@opencode-ai/plugin/zod` for a nested copy). A lockfile change + * republishes a package only when one of these resolves to a different + * version, so dev-tooling bumps never release. Kept in sync with the bundle + * metafiles by test/workit-core/bundled-sources.test.ts. + */ +export const BUNDLED_DEPS = BUNDLED_DEPS_JSON as Partial>; + +const LOCKFILE = "bun.lock"; + +/** `packages` key → resolved `name@version` from a bun.lock text (JSONC). */ +export const lockResolutions = (text: string): Map => { + // bun.lock is JSON with trailing commas. + const parsed = JSON.parse(text.replace(/,(\s*[}\]])/g, "$1")) as { + packages?: Record; + }; + const resolved = new Map(); + for (const [key, entry] of Object.entries(parsed.packages ?? {})) + if (Array.isArray(entry) && typeof entry[0] === "string") resolved.set(key, entry[0]); + return resolved; +}; + +const lockAt = (root: string, rev: string): Map | null => { + try { + return lockResolutions( + execFileSync("git", ["show", `${rev}:${LOCKFILE}`], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: 64 * 1024 * 1024, + }), + ); + } catch { + return null; + } +}; + +/** Packages whose inlined third-party resolutions differ between two revisions. */ +export const lockChangedPackages = (root: string, from: string, to: string): ReleasePackage[] => { + const before = lockAt(root, from); + const after = lockAt(root, to); + if (before === null && after === null) return []; + return RELEASE_PACKAGES.filter((pkg) => + (BUNDLED_DEPS[pkg] ?? []).some((key) => before?.get(key) !== after?.get(key)), + ); +}; + +/** Every repository path whose change alters `pkg`'s published payload. */ +export const payloadPaths = (pkg: ReleasePackage): string[] => [ + `packages/${pkg}/`, + ...(BUNDLED_SOURCES[pkg] ?? []), +]; + /** The release pipeline's own version-sync commit: never a release trigger. */ const RELEASE_SYNC = /^chore\(release\): sync manifests\b/; @@ -56,11 +139,15 @@ const subjectLevel = (commit: string): Level | null => { // combined diff drops files identical to either parent — e.g. hotfix-branch // back-merges), and -z returns raw NUL-delimited paths so spaces/non-ASCII // are never C-quoted. NUL is fine in captured output, never in argv. -const commitsSince = (root: string, from: string): { message: string; files: string[] }[] => { +const commitsSince = ( + root: string, + from: string, +): { hash: string; message: string; files: string[] }[] => { const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`]) .split("\n") .filter(Boolean); return hashes.map((h) => ({ + hash: h, message: g(root, ["show", "-s", "--format=%B", h]), files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h]) .split("\0") @@ -80,20 +167,24 @@ export function analyzeReleaseScope(root = process.cwd()): { const levels: Level[] = []; const pkgs = new Set(); let payloadOnly = false; - for (const { message, files } of commits) { + for (const { hash, message, files } of commits) { const subject = (message.split("\n")[0] ?? "").trim(); if (RELEASE_SYNC.test(subject)) continue; const touched = files.filter((f) => - RELEASE_PACKAGES.some((p) => f.startsWith(`packages/${p}/`)), + RELEASE_PACKAGES.some((p) => payloadPaths(p).some((prefix) => f.startsWith(prefix))), ); - if (touched.length === 0) continue; + // A lockfile edit counts only for packages whose inlined deps moved. + const relocked = files.includes(LOCKFILE) ? lockChangedPackages(root, `${hash}^`, hash) : []; + if (touched.length === 0 && relocked.length === 0) continue; const lvl = subjectLevel(message); if (lvl) levels.push(lvl); else if (!subject.startsWith("Merge ")) payloadOnly = true; - for (const f of touched) { - const pkg = RELEASE_PACKAGES.find((p) => f.startsWith(`packages/${p}/`)); - if (pkg) pkgs.add(pkg); - } + for (const pkg of RELEASE_PACKAGES) + if ( + relocked.includes(pkg) || + touched.some((f) => payloadPaths(pkg).some((prefix) => f.startsWith(prefix))) + ) + pkgs.add(pkg); } if (levels.length === 0) return { level: payloadOnly ? "patch" : null, productPkgs: [...pkgs] }; const level = levels.reduce( diff --git a/packages/workit-core/scripts/bundled-deps.json b/packages/workit-core/scripts/bundled-deps.json new file mode 100644 index 00000000..c6ae9af1 --- /dev/null +++ b/packages/workit-core/scripts/bundled-deps.json @@ -0,0 +1,133 @@ +{ + "workit-mcp": [ + "@modelcontextprotocol/sdk", + "@openclaw/fs-safe", + "ajv", + "ajv-formats", + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "zod", + "zod-to-json-schema" + ], + "workit-cli": [ + "@alcalzone/ansi-tokenize", + "@inkjs/ui", + "@openclaw/fs-safe", + "ansi-escapes", + "ansi-regex", + "ansi-styles", + "auto-bind", + "chalk", + "cli-boxes", + "cli-cursor", + "cli-truncate", + "code-excerpt", + "convert-to-spaces", + "deepmerge", + "environment", + "es-toolkit", + "escape-string-regexp", + "figures", + "get-east-asian-width", + "indent-string", + "ink", + "is-fullwidth-code-point", + "is-in-ci", + "is-unicode-supported", + "patch-console", + "react", + "react-devtools-core", + "react-reconciler", + "restore-cursor", + "restore-cursor/onetime", + "restore-cursor/onetime/mimic-fn", + "scheduler", + "signal-exit", + "slice-ansi", + "stack-utils", + "string-width", + "strip-ansi", + "terminal-size", + "widest-line", + "wrap-ansi", + "ws", + "yoga-layout", + "zod" + ], + "workit-opencode": [ + "@openclaw/fs-safe", + "@opencode-ai/plugin", + "@opencode-ai/plugin/zod", + "@opencode/plugin", + "zod" + ], + "workit-cursor": [ + "@modelcontextprotocol/sdk", + "@openclaw/fs-safe", + "ajv", + "ajv-formats", + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "zod", + "zod-to-json-schema" + ], + "workit-codex": [ + "@modelcontextprotocol/sdk", + "@openclaw/fs-safe", + "ajv", + "ajv-formats", + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "zod", + "zod-to-json-schema" + ], + "workit-pi": ["@openclaw/fs-safe", "zod"], + "workit-claude-code": [ + "@alcalzone/ansi-tokenize", + "@inkjs/ui", + "@openclaw/fs-safe", + "ansi-escapes", + "ansi-regex", + "ansi-styles", + "auto-bind", + "chalk", + "cli-boxes", + "cli-cursor", + "cli-truncate", + "code-excerpt", + "convert-to-spaces", + "deepmerge", + "environment", + "es-toolkit", + "escape-string-regexp", + "figures", + "get-east-asian-width", + "indent-string", + "ink", + "is-fullwidth-code-point", + "is-in-ci", + "is-unicode-supported", + "patch-console", + "react", + "react-devtools-core", + "react-reconciler", + "restore-cursor", + "restore-cursor/onetime", + "restore-cursor/onetime/mimic-fn", + "scheduler", + "signal-exit", + "slice-ansi", + "stack-utils", + "string-width", + "strip-ansi", + "terminal-size", + "widest-line", + "wrap-ansi", + "ws", + "yoga-layout", + "zod" + ] +} diff --git a/packages/workit-core/scripts/publish-changed-packages.ts b/packages/workit-core/scripts/publish-changed-packages.ts index aec95a43..e5b1c0d0 100644 --- a/packages/workit-core/scripts/publish-changed-packages.ts +++ b/packages/workit-core/scripts/publish-changed-packages.ts @@ -4,7 +4,12 @@ // package so release logs answer "what shipped?" without leaving the terminal. import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; -import { latestTag, RELEASE_PACKAGES } from "./analyze-release-scope"; +import { + latestTag, + lockChangedPackages, + payloadPaths, + RELEASE_PACKAGES, +} from "./analyze-release-scope"; const git = (root: string, args: string[]): string => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); @@ -12,8 +17,13 @@ const git = (root: string, args: string[]): string => export function changedPackages(root: string, fromTag: string): string[] { // Committed state only: ..HEAD, never the working tree — unreviewed // local edits must not decide what ships. + // Plus packages whose inlined third-party deps resolve differently in the + // lockfile (BUNDLED_DEPS); a dev-tooling-only lockfile bump changes none. + const relocked = new Set(lockChangedPackages(root, fromTag, "HEAD")); return RELEASE_PACKAGES.filter((pkg) => { - const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", `packages/${pkg}`]); + if (relocked.has(pkg)) return true; + // Own directory plus any sources bundled into its dist/ (BUNDLED_SOURCES). + const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", ...payloadPaths(pkg)]); return out !== ""; }); } @@ -38,20 +48,15 @@ export function publishChanged(opts: { execFileSync(cmd, args, { cwd: o.cwd, encoding: "utf8", stdio: "inherit" })); const tag = opts.fromTag !== undefined ? (opts.fromTag === "" ? null : opts.fromTag) : latestTag(root); - if (tag === null) { - // First-ever release: everything ships. - const published: string[] = []; - for (const pkg of RELEASE_PACKAGES) { - const cwd = resolve(root, "packages", pkg); - if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd }); - published.push(pkg); - console.log(`published ${pkg} @ ${cwd}`); - } - return { published, skipped: [], tag: null }; - } - const changed = new Set(changedPackages(root, tag)); + // First-ever release: everything ships. + const changed = + tag === null ? new Set(RELEASE_PACKAGES) : new Set(changedPackages(root, tag)); const published: string[] = []; const skipped: string[] = []; + const failed: Array<{ pkg: string; error: string }> = []; + // Every changed package is attempted even after a failure: one package that + // cannot publish (e.g. a first publish of a new npm name the token does not + // cover) must not leave the others unreleased. Failures throw at the end. for (const pkg of RELEASE_PACKAGES) { if (!changed.has(pkg)) { skipped.push(pkg); @@ -62,12 +67,19 @@ export function publishChanged(opts: { try { if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd }); } catch (e) { - console.log(`publish failed ${pkg}: ${e instanceof Error ? e.message : String(e)}`); - throw e; + const error = e instanceof Error ? e.message : String(e); + failed.push({ pkg, error }); + console.log(`publish failed ${pkg}: ${error}`); + continue; } published.push(pkg); console.log(`published ${pkg} @ ${cwd}`); } + if (failed.length > 0) { + const summary = `publish failed for ${failed.length} package(s): ${failed.map((f) => f.pkg).join(", ")}; published: ${published.join(", ") || "none"}. A first publish of a new @brainervirus package needs an npm token allowed to create packages in the scope.`; + console.log(summary); + throw new Error(summary); + } return { published, skipped, tag }; } diff --git a/packages/workit-core/scripts/rewrite-workspace-deps.ts b/packages/workit-core/scripts/rewrite-workspace-deps.ts index 017ba2e3..39bab4cf 100644 --- a/packages/workit-core/scripts/rewrite-workspace-deps.ts +++ b/packages/workit-core/scripts/rewrite-workspace-deps.ts @@ -25,14 +25,19 @@ for (const pkg of [ "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ]) { const file = resolve(root, `packages/${pkg}/package.json`); const data = JSON.parse(readFileSync(file, "utf8")); - const deps = data.dependencies; - if (!deps) continue; - for (const name of Object.keys(deps)) { - if (name.startsWith("@brainervirus/")) deps[name] = `^${core.version}`; - } + // devDependencies too: the Claude Code plugin bundles core and the CLI into + // dist/, so they are build-time only, but a published manifest must still + // never carry workspace:*. + const groups = [data.dependencies, data.devDependencies].filter(Boolean); + if (groups.length === 0) continue; + for (const deps of groups) + for (const name of Object.keys(deps)) { + if (name.startsWith("@brainervirus/")) deps[name] = `^${core.version}`; + } writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); } // The root marketplace index carries no release version; only the package @@ -40,6 +45,7 @@ for (const pkg of [ for (const file of [ resolve(root, "packages/workit-cursor/.cursor-plugin/plugin.json"), resolve(root, "packages/workit-codex/.codex-plugin/plugin.json"), + resolve(root, "packages/workit-claude-code/.claude-plugin/plugin.json"), ]) { const data = JSON.parse(readFileSync(file, "utf8")); data.version = core.version; diff --git a/packages/workit-core/scripts/sync-release-manifests.ts b/packages/workit-core/scripts/sync-release-manifests.ts index c78f2675..36ead071 100644 --- a/packages/workit-core/scripts/sync-release-manifests.ts +++ b/packages/workit-core/scripts/sync-release-manifests.ts @@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -const SYNC_MANIFEST_PATHS = [ +export const SYNC_MANIFEST_PATHS = [ "package.json", "packages/workit-core/package.json", "packages/workit-mcp/package.json", @@ -18,9 +18,13 @@ const SYNC_MANIFEST_PATHS = [ "packages/workit-cursor/package.json", "packages/workit-codex/package.json", "packages/workit-pi/package.json", + "packages/workit-claude-code/package.json", // Kept in lockstep with packages/workit-core/package.json by contract test. "packages/workit-cursor/.cursor-plugin/plugin.json", "packages/workit-codex/.codex-plugin/plugin.json", + // Claude Code reads the plugin version from here (the marketplace entry + // carries none), so it must track the published npm version. + "packages/workit-claude-code/.claude-plugin/plugin.json", ]; export type ManifestSyncResult = { version: string; changed: string[] }; diff --git a/packages/workit-core/src/core/detect-hosts.ts b/packages/workit-core/src/core/detect-hosts.ts index 1620595e..da2837c8 100644 --- a/packages/workit-core/src/core/detect-hosts.ts +++ b/packages/workit-core/src/core/detect-hosts.ts @@ -9,6 +9,7 @@ import { planUninstall, type UninstallPaths } from "./uninstall"; import { findHostExecutable, installedHostApp, + isClaudeWorkitInstalled, isCodexWorkitInstalled, isPiWorkitInstalled, type HostId, @@ -24,7 +25,7 @@ export type HostDetection = { export type DetectHostsOptions = UninstallPaths; -const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"]; +const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi", "claude-code"]; /** Hosts the setup wizard configures through their native install paths. */ export const WIZARD_HOSTS: HostId[] = [...HOSTS]; @@ -40,6 +41,7 @@ export function emptyDetection(): Record { cursor: { detected: false, configured: false }, codex: { detected: false, configured: false }, pi: { detected: false, configured: false }, + "claude-code": { detected: false, configured: false }, }; } @@ -52,8 +54,10 @@ export function detectHosts(options: DetectHostsOptions = {}): Record [h.host, h.installed])); const found = emptyDetection(); for (const host of HOSTS) { + // Claude Code ships as the `claude` CLI (the desktop app embeds it). + const executable = host === "claude-code" ? "claude" : host; const detected = - findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env); + findHostExecutable(executable, { home, env }) !== null || installedHostApp(host, home, env); found[host] = { detected, configured: @@ -61,7 +65,9 @@ export function detectHosts(options: DetectHostsOptions = {}): Record { }; }; +const CLAUDE_PLUGIN_PACKAGE = "@brainervirus/workit-claude-code"; + +const versionBehind = (version: string, latest: string): boolean => + version !== latest && semverAtLeast(latest, version); + +/** + * Claude Code installs the marketplace plugin as a snapshot of the published + * package (auto-update is off by default), so an install can lag the release. + * It warns only when a newer plugin version is actually published: a plugin + * older than this CLI is normal when no plugin payload changed since (the + * plugin is republished only when its own or its bundled sources change). + * The plugin keeps working; the fix is one native update. A `--plugin-dir` + * local pin is per-session, never recorded, and never checked here. + */ +const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => { + // Only installs Claude loads here: user scope, plus this project's. + const installs = claudeWorkitInstalls(res.home, res.env, res.cwd); + if (installs.length === 0) + return { + id: "claude_plugin", + status: "pass", + detail: "no Workit Claude Code plugin install recorded — skipping", + }; + const fix = (install: ClaudeWorkitInstall) => + `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && ${ + install.projectPath ? `cd ${JSON.stringify(install.projectPath)} && ` : "" + }claude plugin update ${install.id}${install.scope === "user" ? "" : ` --scope ${install.scope}`}`; + const latest = registryLatestVersion(res, CLAUDE_PLUGIN_PACKAGE); + const problems: string[] = []; + let repair: string | undefined; + for (const install of installs) { + const label = `${install.id} ${install.version ?? "(unknown version)"}${install.scope === "user" ? "" : ` (${install.scope} scope)`}`; + if (latest && install.version && versionBehind(install.version, latest)) { + problems.push(`stale_install: ${label} is behind published ${latest}`); + repair ??= fix(install); + } + } + const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION; + if (problems.length === 0) + return { + id: "claude_plugin", + status: "pass", + detail: `Claude Code plugin ${installs.map((i) => `${i.id} ${i.version ?? "?"}`).join(", ")} is current`, + registryProbed, + }; + return { + id: "claude_plugin", + status: "warn", + detail: problems.join("; "), + fix: repair, + registryProbed, + }; +}; + const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [ checkRuntime, checkVersions, checkCodexPin, + checkClaudePlugin, checkAssets, checkLauncher, checkUtility, diff --git a/packages/workit-core/src/core/host-install.ts b/packages/workit-core/src/core/host-install.ts index 42c911b5..fe80b627 100644 --- a/packages/workit-core/src/core/host-install.ts +++ b/packages/workit-core/src/core/host-install.ts @@ -11,7 +11,7 @@ import { import os from "node:os"; import path from "node:path"; -export type HostId = "opencode" | "cursor" | "codex" | "pi"; +export type HostId = "opencode" | "cursor" | "codex" | "pi" | "claude-code"; /** A reviewed argv vector. Arguments are never composed into a shell string. */ export type HostInstallCommand = { @@ -89,6 +89,12 @@ export type HostInstallOptions = { const MARKETPLACE = "https://github.com/BrainerVirus/workit.git"; const CODEX_MARKETPLACE_NAME = "workflow-toolkit"; +/** Claude Code reads the git-hosted marketplace at the repo root + * (`.claude-plugin/marketplace.json`, name `workit`); its entry installs the + * published npm package. */ +export const CLAUDE_MARKETPLACE_REPO = "BrainerVirus/workit"; +export const CLAUDE_MARKETPLACE_NAME = "workit"; +export const CLAUDE_PLUGIN_ID = `workit@${CLAUDE_MARKETPLACE_NAME}`; const validPackageVersion = (value: string): boolean => value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value); @@ -224,6 +230,109 @@ export const isCodexWorkitInstalled = ( return false; }; +/** Claude Code's config root (`CLAUDE_CONFIG_DIR`, default `~/.claude`). */ +export const claudeConfigDir = (home: string, env: NodeJS.ProcessEnv = process.env): string => + env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"); + +export type ClaudeInstallScope = "user" | "project" | "local"; + +export type ClaudeWorkitInstall = { + id: string; + version: string | null; + installPath: string; + scope: ClaudeInstallScope; + /** The project a project/local-scope install belongs to; null for user scope. */ + projectPath: string | null; +}; + +const CLAUDE_SCOPES = new Set(["user", "project", "local"]); + +/** A project/local install applies to `cwd` when cwd is inside its project. */ +const appliesTo = (install: ClaudeWorkitInstall, cwd: string | null): boolean => { + if (install.scope === "user") return true; + if (cwd === null || install.projectPath === null) return false; + const relative = path.relative(path.resolve(install.projectPath), path.resolve(cwd)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; + +const isCanonicalClaudePlugin = (root: string): boolean => { + try { + const manifest = JSON.parse( + readFileSync(path.join(root, ".claude-plugin", "plugin.json"), "utf8"), + ) as { name?: string; repository?: string | { url?: string } }; + const repository = + typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url; + return ( + manifest.name === "workit" && + repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit" + ); + } catch { + return false; + } +}; + +/** + * Workit installs recorded in Claude Code's plugin registry + * (`/plugins/installed_plugins.json`, v2: `{plugins: {"name@market": + * [{installPath, version}]}}`). Only entries whose install directory carries + * the canonical Workit manifest count, whatever marketplace they came from. + * Only installs that apply here count: user scope always, project/local + * scope only when `cwd` is inside the recorded project (`cwd: null` keeps + * user scope only). A `--plugin-dir` pin is per-session and never recorded. + */ +export const claudeWorkitInstalls = ( + home: string, + env: NodeJS.ProcessEnv = process.env, + cwd: string | null = process.cwd(), +): ClaudeWorkitInstall[] => { + const file = path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json"); + let registry: unknown; + try { + registry = JSON.parse(readFileSync(file, "utf8")); + } catch { + return []; + } + const plugins = + registry && typeof registry === "object" && "plugins" in registry ? registry.plugins : null; + if (!plugins || typeof plugins !== "object") return []; + const found: ClaudeWorkitInstall[] = []; + for (const [id, entries] of Object.entries(plugins as Record)) { + if (!id.startsWith("workit@") || !Array.isArray(entries)) continue; + for (const entry of entries as Array<{ + installPath?: unknown; + version?: unknown; + scope?: unknown; + projectPath?: unknown; + }>) { + if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath)) + continue; + // Older registries omit scope; Claude's default install scope is user. + const scope = entry.scope === undefined ? "user" : String(entry.scope); + if (!CLAUDE_SCOPES.has(scope)) continue; // e.g. managed: not ours to change + const install: ClaudeWorkitInstall = { + id, + installPath: entry.installPath, + version: + typeof entry.version === "string" && /^\d+\.\d+\.\d+/.test(entry.version) + ? entry.version + : null, + scope: scope as ClaudeInstallScope, + projectPath: + scope !== "user" && typeof entry.projectPath === "string" ? entry.projectPath : null, + }; + if (appliesTo(install, cwd)) found.push(install); + } + } + return found; +}; + +/** A Workit install Claude Code loads for this user (any project). */ +export const isClaudeWorkitInstalled = ( + home: string, + env: NodeJS.ProcessEnv = process.env, + cwd: string | null = process.cwd(), +): boolean => claudeWorkitInstalls(home, env, cwd).length > 0; + export function installedHostApp( host: HostId, home: string, @@ -297,6 +406,39 @@ export function planHostInstall( }, ]; } + if (host === "claude-code") { + if (!upgrading && isClaudeWorkitInstalled(home, env, null)) return []; + const base = hostCommand("claude", [], { home, cwd, env }); + return [ + nodePrerequisite({ home, cwd, env }), + { + ...base, + args: [...base.args, "plugin", "marketplace", "list", "--json"], + purpose: "Check whether the Workit Claude Code marketplace is already registered", + }, + upgrading + ? { + ...base, + args: [...base.args, "plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME], + purpose: "Refresh the Workit Claude Code marketplace", + } + : { + ...base, + args: [...base.args, "plugin", "marketplace", "add", CLAUDE_MARKETPLACE_REPO], + purpose: "Register the Workit Claude Code marketplace", + skipWhenOutputIncludes: `"name": "${CLAUDE_MARKETPLACE_NAME}"`, + }, + { + ...base, + args: upgrading + ? [...base.args, "plugin", "update", CLAUDE_PLUGIN_ID] + : [...base.args, "plugin", "install", CLAUDE_PLUGIN_ID, "--scope", "user"], + purpose: upgrading + ? "Update the Workit Claude Code plugin" + : "Install the Workit Claude Code plugin", + }, + ]; + } if (host === "pi") { if (!upgrading && isPiWorkitInstalled(home, env)) return []; const base = hostCommand("pi", [], { home, cwd, env }); @@ -440,6 +582,7 @@ export function runHostCommand( const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home }; env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex"); env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"); + env.CLAUDE_CONFIG_DIR = context.env?.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"); env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config"); return env; })(), diff --git a/packages/workit-core/src/core/setup.ts b/packages/workit-core/src/core/setup.ts index 37996cb2..e01c2031 100644 --- a/packages/workit-core/src/core/setup.ts +++ b/packages/workit-core/src/core/setup.ts @@ -52,6 +52,8 @@ import { writeFileExclusive } from "./safe-write"; import { isCodexWorkitInstalled, isPiWorkitInstalled, + isClaudeWorkitInstalled, + claudeConfigDir, planHostInstall, runHostInstall, type HostCommandRunner, @@ -385,25 +387,27 @@ export function buildSetupPreview( mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir }); mutations.push({ type: "register-platform", platform, path: paths.cursorSettings }); mutations.push({ type: "register-platform", platform, path: paths.cursorMcp }); - } else if (platform === "codex" || platform === "pi") { + } else if (platform === "codex" || platform === "pi" || platform === "claude-code") { const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env }); if (commands.length > 0) { mutations.push({ type: "install-host", platform, path: - platform === "codex" - ? path.join( - env.CODEX_HOME ?? path.join(paths.home, ".codex"), - "plugins", - "cache", - "workflow-toolkit", - "workit", - ) - : path.join( - env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"), - "settings.json", - ), + platform === "claude-code" + ? claudeInstallRecord(paths.home, env) + : platform === "codex" + ? path.join( + env.CODEX_HOME ?? path.join(paths.home, ".codex"), + "plugins", + "cache", + "workflow-toolkit", + "workit", + ) + : path.join( + env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"), + "settings.json", + ), commands, }); } @@ -446,6 +450,10 @@ export function buildSetupPreview( // Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31). // --------------------------------------------------------------------------- +/** The Claude Code plugin registry a Workit install is recorded in. */ +const claudeInstallRecord = (home: string, env: NodeJS.ProcessEnv): string => + path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json"); + export type Platform = "opencode" | "cursor"; export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed"; @@ -1250,7 +1258,9 @@ export function applySetupPreview( res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"), "settings.json", ) - : ""; + : mutation.platform === "claude-code" + ? claudeInstallRecord(res.home, res.env) + : ""; if (mutation.path !== expectedPath) { entries.push({ platform: mutation.platform, @@ -1292,7 +1302,9 @@ export function applySetupPreview( ? isCodexWorkitInstalled(res.home, res.env) : mutation.platform === "pi" ? isPiWorkitInstalled(res.home, res.env) - : true; + : mutation.platform === "claude-code" + ? isClaudeWorkitInstalled(res.home, res.env, null) + : true; if (!verified) { entries.push({ platform: mutation.platform, diff --git a/packages/workit-core/src/core/support-matrix.ts b/packages/workit-core/src/core/support-matrix.ts index 2a1bbf28..7048bb98 100644 --- a/packages/workit-core/src/core/support-matrix.ts +++ b/packages/workit-core/src/core/support-matrix.ts @@ -16,5 +16,9 @@ export const SUPPORT_MATRIX = { // an installed CLI drifts ahead so a fresh install never silently outruns // the qualification pin. codex: { cli: "0.153.4", desktopPackage: "26.901.20858", bundledCodexCli: "0.153.0-alpha.5" }, + // Claude Code is a qualification host too: CI runs `claude plugin validate + // --strict` with exactly this CLI, and the hook output schema fixture + // (test/fixtures/claude-code-schemas) was transcribed from this binary. + claudeCode: { cli: "2.1.288" }, os: ["ubuntu-latest", "macos-latest", "windows-latest"], } as const; diff --git a/packages/workit-core/src/core/uninstall.ts b/packages/workit-core/src/core/uninstall.ts index 54da7365..0c3db989 100644 --- a/packages/workit-core/src/core/uninstall.ts +++ b/packages/workit-core/src/core/uninstall.ts @@ -10,12 +10,28 @@ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "n import os from "node:os"; import path from "node:path"; import { isWorkitPlugin } from "./registration"; +import { + claudeWorkitInstalls, + findHostExecutable, + runHostCommand, + type HostCommandRunner, +} from "./host-install"; -export type UninstallHost = "opencode" | "cursor" | "codex" | "pi"; +export type UninstallHost = "opencode" | "cursor" | "codex" | "pi" | "claude-code"; export type UninstallAction = | { kind: "edit-json-remove"; path: string; detail: string } - | { kind: "remove-dir"; path: string; detail: string }; + | { kind: "remove-dir"; path: string; detail: string } + /** A host-native uninstall (Claude Code owns its plugin cache and registry). */ + | { + kind: "host-command"; + path: string; + detail: string; + command: "claude"; + args: string[]; + /** The project a project/local-scope uninstall must run from; null for user scope. */ + cwd: string | null; + }; export type UninstallHostPlan = { host: UninstallHost; @@ -49,6 +65,10 @@ export type UninstallPaths = { cursorSettings?: string; cursorMcp?: string; cursorPluginDir?: string; + /** Injectable host-command runner for host-native uninstall actions. */ + runHostCommand?: HostCommandRunner; + /** Project directory for Claude Code project/local-scope installs (default: process cwd). */ + cwd?: string; }; type ResolvedUninstall = { @@ -321,7 +341,32 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan { : [], }; - return { hosts: [opencode, cursor, codex, pi] }; + // Claude Code: one native `claude plugin uninstall --scope ` + // per recorded Workit install that applies here (user scope, plus this + // project's project/local scope, run from that project). A --plugin-dir + // pin is never recorded. + const home = paths.home ?? paths.env?.HOME ?? os.homedir(); + const claudeInstalls = claudeWorkitInstalls( + home, + paths.env ?? process.env, + paths.cwd ?? process.cwd(), + ); + const claude: UninstallHostPlan = { + host: "claude-code", + installed: claudeInstalls.length > 0, + actions: claudeInstalls.map((install) => ({ + kind: "host-command" as const, + path: install.installPath, + detail: `claude plugin uninstall ${install.id} --scope ${install.scope}${ + install.projectPath ? ` (in ${install.projectPath})` : "" + }`, + command: "claude" as const, + args: ["plugin", "uninstall", install.id, "--scope", install.scope], + cwd: install.projectPath, + })), + }; + + return { hosts: [opencode, cursor, codex, pi, claude] }; } // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved @@ -390,6 +435,60 @@ const applyEditJsonRemove = ( return { status: "removed" }; }; +const CLAUDE_PLUGIN_ID = /^workit@[A-Za-z0-9._-]+$/; + +/** Runs a reviewed `claude plugin uninstall workit@`; any other + * argv is refused, so a tampered plan cannot run an arbitrary command. */ +const applyClaudeUninstall = ( + action: Extract, + paths: UninstallPaths, +): { status: UninstallResultStatus; detail?: string } => { + const [verb, sub, id, flag, scope, ...rest] = action.args; + const projectScoped = scope === "project" || scope === "local"; + if ( + action.command !== "claude" || + verb !== "plugin" || + sub !== "uninstall" || + !CLAUDE_PLUGIN_ID.test(id ?? "") || + flag !== "--scope" || + !(scope === "user" || projectScoped) || + rest.length > 0 || + // user scope runs anywhere; project/local only from their project. + (projectScoped ? typeof action.cwd !== "string" : action.cwd !== null) + ) + return { + status: "failed", + detail: `refusing unreviewed host command: ${action.args.join(" ")}`, + }; + const home = paths.home ?? paths.env?.HOME ?? os.homedir(); + const env = paths.env ?? process.env; + const recorded = claudeWorkitInstalls(home, env, action.cwd).some( + (install) => + install.id === id && + install.scope === scope && + (install.projectPath === null + ? action.cwd === null + : path.resolve(install.projectPath) === path.resolve(action.cwd ?? "")), + ); + if (!recorded) + return { status: "skipped", detail: `${id} (${scope} scope) is no longer installed` }; + const executable = findHostExecutable("claude", { home, env }); + if (!executable && !paths.runHostCommand) + return { status: "failed", detail: "claude executable was not found on PATH" }; + const step = { + command: executable ?? "claude", + args: action.args, + ...(action.cwd === null ? {} : { cwd: action.cwd }), + purpose: `Uninstall the Workit Claude Code plugin ${id} (${scope} scope)`, + }; + const result = paths.runHostCommand + ? paths.runHostCommand(step) + : runHostCommand(step, { home, env }); + return result.exitCode === 0 + ? { status: "removed", detail: action.detail } + : { status: "failed", detail: result.stderr || `exit ${result.exitCode}` }; +}; + /** Applies ONLY the reviewed plan actions with the given path options. Each * planned action yields exactly one result entry; malformed host JSON fails * its own action untouched while the remaining actions proceed (CA-13). */ @@ -400,7 +499,11 @@ export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}): for (const action of hostPlan.actions) { let status: UninstallResultStatus; let detail: string | undefined; - if (action.kind === "remove-dir") { + if (action.kind === "host-command") { + const outcome = applyClaudeUninstall(action, paths); + status = outcome.status; + detail = outcome.detail; + } else if (action.kind === "remove-dir") { // Resolve before comparing so ".."/symlink tricks can never widen the rm. const resolved = path.resolve(action.path); const allowed = diff --git a/packages/workit-core/src/hooks/handle.ts b/packages/workit-core/src/hooks/handle.ts index b47f1c3e..482860b5 100644 --- a/packages/workit-core/src/hooks/handle.ts +++ b/packages/workit-core/src/hooks/handle.ts @@ -12,6 +12,20 @@ const NONE: HookDecision = { kind: "none" }; /** Sessions already offered unfinished tasks in this process. */ const offered = new Set(); +/** The Workit plugin's worktree-isolated implementer is the one Claude Code + * subagent that writes. Claude namespaces plugin agents, so only the exact + * `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */ +const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer"; + +const subagentStartText = ( + host: HookInput["host"], + descriptor: HostDescriptor, + event: Extract, +): string => + host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER + ? `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch (\`git switch -c /\`, e.g. feature/); branch policy hooks still deny protected or non-compliant branches. Never push, open a PR, or merge unless the brief asks for it.` + : `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`; + export function handleHook(input: HookInput, deps: HookDeps): HookDecision { const { descriptor } = deps; const event = input.event; @@ -36,10 +50,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision { case "shell.pre": return usable(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE; case "subagent.start": - return { - kind: "context", - text: `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`, - }; + return { kind: "context", text: subagentStartText(input.host, descriptor, event) }; case "compact.pre": return { kind: "notice", diff --git a/release.config.cjs b/release.config.cjs index 16ff01a8..7b29eb83 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -32,6 +32,7 @@ module.exports = { ["@semantic-release/npm", { pkgRoot: "packages/workit-cursor", npmPublish: false }], ["@semantic-release/npm", { pkgRoot: "packages/workit-codex", npmPublish: false }], ["@semantic-release/npm", { pkgRoot: "packages/workit-pi", npmPublish: false }], + ["@semantic-release/npm", { pkgRoot: "packages/workit-claude-code", npmPublish: false }], // AR-02/RR-01: prepare-time rewrite AFTER version bumps (unchanged). [ "@semantic-release/exec", diff --git a/scripts/check-reachability.ts b/scripts/check-reachability.ts index 9f137eea..07241081 100644 --- a/scripts/check-reachability.ts +++ b/scripts/check-reachability.ts @@ -26,6 +26,8 @@ const ENTRIES = [ "packages/workit-cursor/hooks/session-start.ts", "packages/workit-codex/hooks/workit-hook.ts", "packages/workit-codex/scripts/launch-mcp.ts", + "packages/workit-claude-code/src/hook.ts", // Claude Code dist/workit-hook.js + "packages/workit-claude-code/src/run.ts", // Claude Code local-pin hook process "packages/workit-pi/extensions/workit.ts", "packages/workit-pi/src/worker.ts", "packages/workit-cli/src/main.ts", // workit-cli dist/index.js (wizards via dynamic import) diff --git a/scripts/test.ts b/scripts/test.ts index f8475dbe..c7540468 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -15,6 +15,7 @@ const PACKAGING = [ "test/workit-cli/doctor.test.ts", "test/workit-cli/packed-cli.test.ts", "test/workit-cli/platform-install.test.ts", + "test/workit-claude-code/packed-plugin.test.ts", "test/workit-codex/packed-launcher.test.ts", "test/workit-core/cursor-install-mcp.test.ts", "test/workit-core/doctor.test.ts", diff --git a/test/artifacts/package-contents.test.ts b/test/artifacts/package-contents.test.ts index 46416805..cdbe3628 100644 --- a/test/artifacts/package-contents.test.ts +++ b/test/artifacts/package-contents.test.ts @@ -349,7 +349,8 @@ test("shipped skill/template/vendor markdown uses workit_ tool identifiers with pack.packageName !== CURSOR && pack.packageName !== "@brainervirus/workit-mcp" && pack.packageName !== "@brainervirus/workit-codex" && - pack.packageName !== "@brainervirus/workit-pi" + pack.packageName !== "@brainervirus/workit-pi" && + pack.packageName !== "@brainervirus/workit-claude-code" ) expect(sawWorkitTool, `${pack.packageName} ships renamed workit_ tool references`).toBe(true); } diff --git a/test/artifacts/phase-0-candidate.test.ts b/test/artifacts/phase-0-candidate.test.ts index 3fd098c3..431a45a9 100644 --- a/test/artifacts/phase-0-candidate.test.ts +++ b/test/artifacts/phase-0-candidate.test.ts @@ -38,7 +38,8 @@ const CURSOR = "@brainervirus/workit-cursor"; const CODEX = "@brainervirus/workit-codex"; const PI = "@brainervirus/workit-pi"; const CLI = "@brainervirus/workit-cli"; -const V1_PACKAGES = [CORE, MCP, CLI, OPENCODE, CURSOR, CODEX, PI]; +const CLAUDE_CODE = "@brainervirus/workit-claude-code"; +const V1_PACKAGES = [CORE, MCP, CLI, OPENCODE, CURSOR, CODEX, PI, CLAUDE_CODE]; // The isolated npm-install gate fetches third-party runtime deps (ink/react/ // @inkjs/ui) from the public registry, so an offline/registry-outage CI run diff --git a/test/artifacts/release-candidate.test.ts b/test/artifacts/release-candidate.test.ts index 3924d286..b184976d 100644 --- a/test/artifacts/release-candidate.test.ts +++ b/test/artifacts/release-candidate.test.ts @@ -26,7 +26,8 @@ const CODEX = "@brainervirus/workit-codex"; const PI = "@brainervirus/workit-pi"; const CLI = "@brainervirus/workit-cli"; -const V1_PACKAGES = [CORE, MCP, CLI, OPENCODE, CURSOR, CODEX, PI]; +const CLAUDE_CODE = "@brainervirus/workit-claude-code"; +const V1_PACKAGES = [CORE, MCP, CLI, OPENCODE, CURSOR, CODEX, PI, CLAUDE_CODE]; const packedFiles = () => { const files: string[] = []; @@ -147,6 +148,7 @@ test("release analysis treats every v1 package path as product code", () => { "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ]); }); diff --git a/test/artifacts/release-orchestration.test.ts b/test/artifacts/release-orchestration.test.ts index d5428272..65c7b1b2 100644 --- a/test/artifacts/release-orchestration.test.ts +++ b/test/artifacts/release-orchestration.test.ts @@ -21,6 +21,7 @@ const ADAPTERS = [ "workit-codex", "workit-pi", "workit-cli", + "workit-claude-code", ] as const; test( @@ -168,7 +169,7 @@ test( } // bumpers only: exactly four @semantic-release/npm entries, none publishing. const npmEntries = cfg.plugins.filter(isNpm); - expect(npmEntries).toHaveLength(7); + expect(npmEntries).toHaveLength(8); for (const entry of npmEntries) expect(opts(entry).npmPublish).toBe(false); // selective publish lands after the bumpers and before the GitHub // release/tag plugin (AR-16). diff --git a/test/artifacts/reliability-report.test.ts b/test/artifacts/reliability-report.test.ts index a70d4f28..bc03bb0a 100644 --- a/test/artifacts/reliability-report.test.ts +++ b/test/artifacts/reliability-report.test.ts @@ -33,6 +33,7 @@ test("default report aggregates the deterministic candidate and an isolated doct CURSOR, CODEX, PI, + "@brainervirus/workit-claude-code", ]); for (const c of report.candidate) { expect(c.sha256).toMatch(/^[0-9a-f]{64}$/); @@ -40,15 +41,16 @@ test("default report aggregates the deterministic candidate and an isolated doct const packs = packReleaseCandidate(); expect(report.candidate.map((c) => c.sha256)).toEqual(packs.map((p) => p.sha256)); // The default env-isolated doctor (node+bun on PATH, no git) is deterministic: - // exactly the utility check fails (D11/D13); codex_pin passes (absent). + // exactly the utility check fails (D11/D13); codex_pin and claude_plugin + // pass (absent). // Counts include both provider identity checks (pass with no Git remote) // and the workspace_lock check (pass with no metadata lock). expect(report.doctor).toEqual({ ok: false, - passed: 20, + passed: 21, warned: 0, failed: 1, - total: 21, + total: 22, fixes: 1, }); expect(report.logs).toEqual({ files: 0, events: 0 }); @@ -78,10 +80,10 @@ test("report doctor counts are exact against a controlled isolated fixture", () // and the workspace_lock check (pass with no metadata lock). expect(report.doctor).toEqual({ ok: false, - passed: 20, + passed: 21, warned: 0, failed: 1, - total: 21, + total: 22, fixes: 1, }); } finally { diff --git a/test/fixtures/claude-code-schemas/hook-output.schema.json b/test/fixtures/claude-code-schemas/hook-output.schema.json new file mode 100644 index 00000000..0f135eff --- /dev/null +++ b/test/fixtures/claude-code-schemas/hook-output.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Claude Code 2.1.288 command-hook JSON output, transcribed from the zod schemas embedded in the binary (~/.local/share/claude/versions/2.1.288): the common fields plus the hookSpecificOutput variants for the events the Workit plugin registers. PreCompact, Stop and SubagentStop accept common fields only (SubagentStop may add additionalContext). Re-transcribe when the support-matrix Claude Code pin moves.", + "type": "object", + "additionalProperties": false, + "properties": { + "continue": { "type": "boolean" }, + "suppressOutput": { "type": "boolean" }, + "stopReason": { "type": "string" }, + "decision": { "enum": ["approve", "block"] }, + "reason": { "type": "string" }, + "systemMessage": { "type": "string" }, + "terminalSequence": { "type": "string" }, + "hookSpecificOutput": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "PreToolUse" }, + "permissionDecision": { "enum": ["allow", "deny", "ask", "defer"] }, + "permissionDecisionReason": { "type": "string" }, + "updatedInput": { "type": "object" }, + "additionalContext": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "UserPromptSubmit" }, + "additionalContext": { "type": "string" }, + "sessionTitle": { "type": "string" }, + "suppressOriginalPrompt": { "type": "boolean" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "SessionStart" }, + "additionalContext": { "type": "string" }, + "initialUserMessage": { "type": "string" }, + "sessionTitle": { "type": "string" }, + "watchPaths": { "type": "array", "items": { "type": "string" } }, + "reloadSkills": { "type": "boolean" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "SubagentStart" }, + "additionalContext": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "SubagentStop" }, + "additionalContext": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["hookEventName"], + "properties": { + "hookEventName": { "const": "PostToolUse" }, + "additionalContext": { "type": "string" }, + "classifierContext": { "type": "string" }, + "updatedMCPToolOutput": {} + } + } + ] + } + } +} diff --git a/test/shared/helpers/packages.ts b/test/shared/helpers/packages.ts index 2ae75bcd..4355e0b9 100644 --- a/test/shared/helpers/packages.ts +++ b/test/shared/helpers/packages.ts @@ -34,6 +34,7 @@ const WORKSPACE_PACKAGES = [ "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ] as const; let cached: PackedPackage[] | null = null; @@ -109,6 +110,7 @@ export function packWorkspacePackages(options: { force?: boolean } = {}): Packed "workit-codex", "workit-pi", "workit-cli", + "workit-claude-code", ]) { const buildScript = path.join(REPO_ROOT, "packages", pkg, "scripts", "build.ts"); const target = path.join(sandbox, "packages", pkg); diff --git a/test/workit-claude-code/hooks.test.ts b/test/workit-claude-code/hooks.test.ts new file mode 100644 index 00000000..5ca9b02a --- /dev/null +++ b/test/workit-claude-code/hooks.test.ts @@ -0,0 +1,211 @@ +// S14 hook-fixture suite: every Claude Code event is piped through the real +// launcher (`node bin/workit-hook.mjs`, as hooks/hooks.json registers it) in +// both runtimes: the local pin (monorepo sources with bun) and the installed +// layout (bundled dist/). Outputs must satisfy the hook output schema pinned +// from Claude Code 2.1.288 and never answer `allow`. +import { afterAll, expect, test } from "bun:test"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + fixture, + startTask, + tempRoot, + withProtectedMain, +} from "@/test/workit-core/hooks/hook-fixtures"; +import { installedPlugin, outputProblem, PLUGIN_DIR, runHook } from "./plugin-helpers"; + +const FIXTURES = path.resolve(import.meta.dir, "../fixtures/hooks/claude-code"); +const NAMES = readdirSync(FIXTURES).map((name) => name.replace(/\.json$/, "")); +const RUNTIMES = [ + ["local pin (source)", () => PLUGIN_DIR], + ["installed (dist)", installedPlugin], +] as const; + +const roots: string[] = []; +const root = () => { + const dir = tempRoot("workit-claude-hooks-"); + roots.push(dir); + return dir; +}; +afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); +}); + +type Specific = { + hookSpecificOutput?: { + hookEventName?: string; + additionalContext?: string; + permissionDecision?: string; + permissionDecisionReason?: string; + }; +}; + +for (const [label, plugin] of RUNTIMES) { + test(`[${label}] given the hook fixture suite, every event yields schema-valid output and never allow`, async () => { + await withProtectedMain(() => { + const cwd = root(); + startTask(cwd, { host: "claude_code", actor: "claude-session-1" }, "fixture task"); + for (const name of NAMES) { + const payload = fixture("claude-code", name, cwd); + const run = runHook(plugin(), payload); + expect(run.status, `${name}: ${run.stderr}`).toBe(0); + expect(outputProblem(String(payload.hook_event_name), run.json), name).toBeNull(); + expect(run.stdout, name).not.toContain('"allow"'); + } + }); + }, 60_000); + + test(`[${label}] given a protected main, a Bash \`git checkout -b main\` is denied with protected_ref`, async () => { + await withProtectedMain(() => { + const run = runHook(plugin(), fixture("claude-code", "pre-tool-use-bash", root())); + const output = (run.json as Specific).hookSpecificOutput; + expect(output?.hookEventName).toBe("PreToolUse"); + expect(output?.permissionDecision).toBe("deny"); + expect(output?.permissionDecisionReason).toContain("protected_ref"); + // A compliant branch passes silently: the user's own permission rules decide. + const allowed = runHook( + plugin(), + fixture("claude-code", "pre-tool-use-bash", root(), { + tool_input: { command: "git switch -c feature/ok" }, + }), + ); + expect(allowed.json).toEqual({}); + }); + }, 30_000); +} + +test("given SessionStart source compact, context is restored with the Claude addendum and the session env is exported", () => { + const cwd = root(); + const data = mkdtempSync(path.join(tmpdir(), "workit-claude-data-")); + roots.push(data); + const envFile = path.join(data, "session-env.sh"); + startTask(cwd, { host: "claude_code", actor: "claude-session-1" }, "restore after compaction"); + const run = runHook(PLUGIN_DIR, fixture("claude-code", "session-start-compact", cwd), { + CLAUDE_ENV_FILE: envFile, + }); + const context = (run.json as Specific).hookSpecificOutput?.additionalContext ?? ""; + expect(context).toContain(""); + expect(context).toContain(""); + expect(context).toContain("restore after compaction"); + expect(context).toContain("/workit:steer"); + expect(readFileSync(envFile, "utf8")).toBe( + "export WORKIT_HOST=claude_code\nexport WORKIT_SESSION_ID='claude-session-1'\n", + ); +}); + +test("given an unchanged task, UserPromptSubmit re-injects context only when it changed, never right after SessionStart", () => { + const cwd = root(); + const data = mkdtempSync(path.join(tmpdir(), "workit-claude-data-")); + roots.push(data); + const env = { CLAUDE_PLUGIN_DATA: data }; + const turn = () => + runHook(PLUGIN_DIR, fixture("claude-code", "user-prompt-submit", cwd), env).json as Specific; + const start = (source: string) => + runHook(PLUGIN_DIR, fixture("claude-code", "session-start-compact", cwd, { source }), env) + .json as Specific; + // No task bound: nothing to inject. + expect(turn()).toEqual({}); + startTask(cwd, { host: "claude_code", actor: "claude-session-1" }, "per-turn task"); + // The task appeared after the session started: the next turn carries it once. + expect(turn().hookSpecificOutput?.additionalContext).toContain("per-turn task"); + expect(turn()).toEqual({}); + // SessionStart (startup or compaction restore) injects the context itself, + // so the first turn after it does not resend it. + for (const source of ["startup", "compact"]) { + expect(start(source).hookSpecificOutput?.additionalContext).toContain("per-turn task"); + expect(turn(), source).toEqual({}); + } + // Caches of sessions untouched for a week are pruned on the next start. + const ctx = path.join(data, "ctx"); + const stale = path.join(ctx, "old-session.json"); + writeFileSync(stale, '{"digest":"x"}\n'); + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + utimesSync(stale, eightDaysAgo, eightDaysAgo); + start("startup"); + expect(existsSync(stale)).toBe(false); + expect(readdirSync(ctx)).toHaveLength(1); +}); + +test("a protected-branch deny is structured JSON on stdout with exit 0 and nothing on stderr", async () => { + await withProtectedMain(() => { + for (const plugin of [PLUGIN_DIR, installedPlugin()]) { + const run = runHook(plugin, fixture("claude-code", "pre-tool-use-bash", root())); + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.stdout.trim().split("\n")).toHaveLength(1); + expect(outputProblem("PreToolUse", run.json)).toBeNull(); + expect(run.json).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: expect.stringContaining("branch_policy_denied"), + }, + }); + } + }); +}, 30_000); + +test("SubagentStart gives the worktree implementer write guidance and keeps other agents read-only", () => { + const cwd = root(); + const context = (agent_type: string) => + ( + runHook(PLUGIN_DIR, fixture("claude-code", "subagent-start", cwd, { agent_type })) + .json as Specific + ).hookSpecificOutput?.additionalContext ?? ""; + for (const agent of ["workit:implementer"]) { + const text = context(agent); + expect(text, agent).toContain("working in its own git worktree"); + expect(text, agent).toContain("policy-compliant branch"); + expect(text, agent).not.toContain("read-only"); + } + // Only the Workit plugin's own implementer: a bare or another plugin's + // `implementer` cannot be told apart from an unrelated agent. + for (const agent of [ + "workit:reviewer", + "workit:verifier", + "Explore", + "implementer", + "other:implementer", + ]) + expect(context(agent), agent).toContain("read-only/agent-guided"); +}); + +test("a malformed payload or a missing bundle fails open with an empty decision and a diagnostic", () => { + const bad = runHook(PLUGIN_DIR, "{not json"); + expect(bad.status).toBe(0); + expect(bad.json).toEqual({}); + expect(bad.stderr).toContain("invalid JSON hook input"); + // An installed layout whose dist/ is missing (a broken package). + const broken = path.join(mkdtempSync(path.join(tmpdir(), "workit-claude-broken-")), "workit"); + roots.push(path.dirname(broken)); + cpSync(path.join(PLUGIN_DIR, "bin"), path.join(broken, "bin"), { recursive: true }); + const missing = runHook(broken, fixture("claude-code", "pre-tool-use-bash", root())); + expect(missing.status).toBe(0); + expect(missing.json).toEqual({}); + expect(missing.stderr).toContain("is missing"); + // A dist/ that cannot be imported (corrupt or truncated bundle). + mkdirSync(path.join(broken, "dist")); + writeFileSync(path.join(broken, "dist", "workit-hook.js"), "export const = ;\n"); + const corrupt = runHook(broken, fixture("claude-code", "pre-tool-use-bash", root())); + expect(corrupt.status).toBe(0); + expect(corrupt.json).toEqual({}); + expect(corrupt.stderr).toContain("Claude Code hook unavailable: cannot load"); + const noBun = runHook(PLUGIN_DIR, fixture("claude-code", "pre-tool-use-bash", root()), { + WORKIT_CLAUDE_RUNTIME: "source", + WORKIT_BUN: path.join(tmpdir(), "definitely-not-bun"), + }); + expect(noBun.status).toBe(0); + expect(noBun.json).toEqual({}); + expect(noBun.stderr).toContain("bun is required for the local pin"); +}); diff --git a/test/workit-claude-code/packed-plugin.test.ts b/test/workit-claude-code/packed-plugin.test.ts new file mode 100644 index 00000000..3213d3ba --- /dev/null +++ b/test/workit-claude-code/packed-plugin.test.ts @@ -0,0 +1,157 @@ +// S14 packaging gate: the packed npm tarball is the plugin Claude Code +// installs from the marketplace's npm source. It must carry the built +// payload, resolve dist/ (no monorepo next to it), and, when a Claude Code +// CLI is available (WORKIT_CLAUDE_BIN in CI, or `claude` on PATH), pass +// `claude plugin validate --strict` and install from a local marketplace in +// an isolated config dir. +import { afterAll, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { claudeWorkitInstalls } from "@/packages/workit-core/src/core/host-install"; +import { fixture, tempRoot, withProtectedMain } from "@/test/workit-core/hooks/hook-fixtures"; +import { + extractTarball, + listTarball, + packWorkspacePackages, + REPO_ROOT, +} from "@/test/shared/helpers/packages"; +import { runHook } from "./plugin-helpers"; + +const PACKAGE = "@brainervirus/workit-claude-code"; +const TREE_VERSION = JSON.parse( + readFileSync(path.join(REPO_ROOT, "packages", "workit-core", "package.json"), "utf8"), +).version as string; +const cleanup: string[] = []; +afterAll(() => { + for (const dir of cleanup) rmSync(dir, { recursive: true, force: true }); +}); + +const packed = () => packWorkspacePackages().find((pack) => pack.packageName === PACKAGE)!; + +const claudeBin = (): string | null => { + const explicit = process.env.WORKIT_CLAUDE_BIN; + if (explicit) return existsSync(explicit) ? explicit : null; + const probe = spawnSync("claude", ["--version"], { encoding: "utf8" }); + return probe.status === 0 ? "claude" : null; +}; + +/** The extracted package laid out as Claude's plugin cache holds it. */ +let extracted: string | null = null; +const pluginFromTarball = () => { + if (extracted) return extracted; + const { root, packageDir } = extractTarball(packed().tarball); + cleanup.push(root); + extracted = packageDir; + return packageDir; +}; + +test( + "the packed tarball carries the built plugin payload and no sources or evals", + () => { + const files = listTarball(packed().tarball); + for (const required of [ + ".claude-plugin/plugin.json", + "hooks/hooks.json", + "bin/workit-hook.mjs", + "bin/workit", + "agents/verifier.md", + "agents/reviewer.md", + "agents/implementer.md", + "dist/workit-hook.js", + "dist/workit.js", + "skills/review/SKILL.md", + "assets/templates/workit-contract.md", + "package.json", + "README.md", + ]) + expect(files, required).toContain(required); + expect(files.filter((file) => /^(src|scripts|evals)\//.test(file))).toEqual([]); + const pkg = JSON.parse(readFileSync(path.join(pluginFromTarball(), "package.json"), "utf8")); + // Release parity: no workspace protocol survives into the published manifest. + expect(JSON.stringify(pkg)).not.toContain("workspace:"); + const manifest = JSON.parse( + readFileSync(path.join(pluginFromTarball(), ".claude-plugin", "plugin.json"), "utf8"), + ); + // The release-time rewrite (rewrite-workspace-deps.ts, which the pack + // sandbox runs too) mirrors the tree's core version into plugin.json, the + // version Claude reads. Derived from the tree, so a release on main can't + // break this; the plugin package.json lockstep is the manifest sync's job + // (plugin.test.ts). + expect(manifest.version).toBe(TREE_VERSION); + }, + { timeout: 300_000 }, +); + +test( + "given the packed tarball, the hook launcher resolves dist/ and denies a protected-branch checkout", + async () => { + const plugin = pluginFromTarball(); + await withProtectedMain(() => { + const cwd = tempRoot("workit-claude-packed-"); + cleanup.push(cwd); + const run = runHook(plugin, fixture("claude-code", "pre-tool-use-bash", cwd)); + expect(run.status, run.stderr).toBe(0); + expect(JSON.stringify(run.json)).toContain('"permissionDecision":"deny"'); + }); + if (process.platform !== "win32") { + const cli = spawnSync(path.join(plugin, "bin", "workit"), ["--version"], { + encoding: "utf8", + env: { ...process.env, WORKIT_SHIM_TRACE: "1" }, + timeout: 60_000, + }); + expect(cli.status, cli.stderr).toBe(0); + expect(cli.stderr).toContain("workit-shim: dist"); + } + }, + { timeout: 300_000 }, +); + +const claude = claudeBin(); +test.skipIf(claude === null)( + "given the packed tarball, claude plugin validate --strict passes and a local marketplace installs it", + () => { + const plugin = pluginFromTarball(); + const home = mkdtempSync(path.join(os.tmpdir(), "workit-claude-home-")); + cleanup.push(home); + const env = { + ...process.env, + HOME: home, + CLAUDE_CONFIG_DIR: path.join(home, ".claude"), + USERPROFILE: home, + }; + const run = (args: string[]) => + spawnSync(claude!, args, { encoding: "utf8", env, cwd: home, timeout: 120_000 }); + const validated = run(["plugin", "validate", "--strict", plugin]); + expect(validated.status, validated.stdout + validated.stderr).toBe(0); + const root = run(["plugin", "validate", "--strict", REPO_ROOT]); + expect(root.status, root.stdout + root.stderr).toBe(0); + // A directory marketplace whose entry points at the extracted package + // stands in for the npm source (same payload, no registry). + const market = path.join(home, "market"); + mkdirSync(path.join(market, ".claude-plugin"), { recursive: true }); + writeFileSync( + path.join(market, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "workit", + description: "packed candidate", + owner: { name: "BrainerVirus" }, + plugins: [{ name: "workit", description: "packed candidate", source: "./workit" }], + }), + ); + spawnSync("cp", ["-R", plugin, path.join(market, "workit")]); + expect(run(["plugin", "marketplace", "add", market]).status).toBe(0); + const installed = run(["plugin", "install", "workit@workit"]); + expect(installed.status, installed.stdout + installed.stderr).toBe(0); + const installs = claudeWorkitInstalls(home, env); + expect(installs).toHaveLength(1); + // Claude records the version plugin.json declares. + expect(installs[0].version).toBe( + JSON.parse(readFileSync(path.join(plugin, ".claude-plugin", "plugin.json"), "utf8")).version, + ); + expect(installs[0].version).toBe(TREE_VERSION); + expect(existsSync(path.join(installs[0].installPath, "dist", "workit-hook.js"))).toBe(true); + }, + { timeout: 300_000 }, +); diff --git a/test/workit-claude-code/plugin-helpers.ts b/test/workit-claude-code/plugin-helpers.ts new file mode 100644 index 00000000..4e6b8b01 --- /dev/null +++ b/test/workit-claude-code/plugin-helpers.ts @@ -0,0 +1,72 @@ +// Shared helpers for the Claude Code plugin suites: run the real hook +// launcher (bin/workit-hook.mjs) as Claude does, in either runtime. +import { spawnSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import Ajv from "ajv"; + +export const PLUGIN_DIR = path.resolve(import.meta.dir, "../../packages/workit-claude-code"); +const BUILD = path.join(PLUGIN_DIR, "scripts", "build.ts"); + +let installed: string | null = null; + +/** + * An installed-layout copy of the plugin (no monorepo sibling, so the + * launcher takes dist/), built once per test process into a temp dir. + */ +export const installedPlugin = (): string => { + if (installed) return installed; + const dir = path.join(mkdtempSync(path.join(tmpdir(), "workit-claude-plugin-")), "workit"); + for (const part of [".claude-plugin", "hooks", "bin", "agents", "package.json"]) + cpSync(path.join(PLUGIN_DIR, part), path.join(dir, part), { recursive: true }); + const built = spawnSync(process.execPath, [BUILD, dir], { encoding: "utf8" }); + if (built.status !== 0) throw new Error(`plugin build failed: ${built.stderr || built.stdout}`); + installed = dir; + // Shared by every suite in this test process; removed when it exits. + process.once("exit", () => rmSync(path.dirname(dir), { recursive: true, force: true })); + return dir; +}; + +export type HookRun = { status: number | null; stdout: string; stderr: string; json: unknown }; + +/** Pipe one native payload through `node /bin/workit-hook.mjs`. */ +export const runHook = ( + pluginDir: string, + payload: unknown, + env: Record = {}, +): HookRun => { + const child = spawnSync("node", [path.join(pluginDir, "bin", "workit-hook.mjs")], { + input: typeof payload === "string" ? payload : JSON.stringify(payload), + encoding: "utf8", + env: { ...process.env, ...env }, + timeout: 30_000, + }); + let json: unknown = null; + try { + json = JSON.parse(child.stdout); + } catch { + json = null; + } + return { status: child.status, stdout: child.stdout, stderr: child.stderr, json }; +}; + +const ajv = new Ajv({ strict: true, allErrors: true }); +const validateOutput = ajv.compile( + JSON.parse( + readFileSync( + path.resolve(import.meta.dir, "../fixtures/claude-code-schemas/hook-output.schema.json"), + "utf8", + ), + ), +); + +/** Null when `output` is valid hook output for `event`, else the reason. */ +export const outputProblem = (event: string, output: unknown): string | null => { + if (!validateOutput(output)) return ajv.errorsText(validateOutput.errors); + const specific = (output as { hookSpecificOutput?: { hookEventName?: string } }) + .hookSpecificOutput; + if (specific && specific.hookEventName !== event) + return `hookSpecificOutput.hookEventName ${specific.hookEventName} answers ${event}`; + return null; +}; diff --git a/test/workit-claude-code/plugin.test.ts b/test/workit-claude-code/plugin.test.ts new file mode 100644 index 00000000..9c27df5c --- /dev/null +++ b/test/workit-claude-code/plugin.test.ts @@ -0,0 +1,178 @@ +// S14 plugin shape: manifest, marketplace entry, hooks registration, agents, +// generated skills, and the source/dist resolution of the local pin. +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { WORKIT_METHOD_SKILLS } from "@/packages/workit-core/src/core/skill-manifests"; +import { SUPPORT_MATRIX } from "@/packages/workit-core/src/core/support-matrix"; +import { + SYNC_MANIFEST_PATHS, + syncManifests, +} from "@/packages/workit-core/scripts/sync-release-manifests"; +import { installedPlugin, PLUGIN_DIR } from "./plugin-helpers"; + +const REPO = path.resolve(PLUGIN_DIR, "..", ".."); +const json = (file: string) => JSON.parse(readFileSync(file, "utf8")); +const CLI_VERSION = json(path.join(REPO, "packages", "workit-cli", "package.json")).version; + +const frontmatter = (file: string): Record => { + const text = readFileSync(file, "utf8"); + const match = /^---\n([\s\S]*?)\n---\n/.exec(text); + if (!match) throw new Error(`${file}: no frontmatter`); + return Object.fromEntries( + match[1].split("\n").map((line) => { + const index = line.indexOf(":"); + return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; + }), + ); +}; + +test("the plugin manifest is versioned with its package and kept in lockstep by the release sync", () => { + const manifest = json(path.join(PLUGIN_DIR, ".claude-plugin", "plugin.json")); + const pkg = json(path.join(PLUGIN_DIR, "package.json")); + // Claude reads the version from plugin.json; npm from package.json. + expect(manifest.version).toBe(pkg.version); + // Not compared with the current release: a branch cut before the latest + // release legitimately carries the previous version until the + // post-release manifest sync, which owns both files. + const synced = [ + "packages/workit-claude-code/package.json", + "packages/workit-claude-code/.claude-plugin/plugin.json", + ]; + for (const rel of synced) expect(SYNC_MANIFEST_PATHS, rel).toContain(rel); + const tree = mkdtempSync(path.join(tmpdir(), "workit-claude-sync-")); + try { + for (const rel of SYNC_MANIFEST_PATHS) { + mkdirSync(path.dirname(path.join(tree, rel)), { recursive: true }); + cpSync(path.join(REPO, rel), path.join(tree, rel)); + } + syncManifests(tree, "v99.0.0"); + for (const rel of synced) expect(json(path.join(tree, rel)).version, rel).toBe("99.0.0"); + } finally { + rmSync(tree, { recursive: true, force: true }); + } + expect(manifest.name).toBe("workit"); + expect(manifest.repository).toBe("https://github.com/BrainerVirus/workit"); + // Default component scan: no path overrides that could drift from the layout. + for (const key of ["hooks", "skills", "agents", "commands", "mcpServers"]) + expect(manifest[key], key).toBeUndefined(); + // Everything runtime is bundled, so an npm-sourced install needs no node_modules. + expect(pkg.dependencies).toBeUndefined(); +}); + +test("the root marketplace installs the published npm package and pins no version", () => { + const market = json(path.join(REPO, ".claude-plugin", "marketplace.json")); + const pkg = json(path.join(PLUGIN_DIR, "package.json")); + expect(market.name).toBe("workit"); + expect(market.plugins).toHaveLength(1); + const [entry] = market.plugins; + expect(entry.name).toBe("workit"); + expect(entry.source).toEqual({ source: "npm", package: pkg.name }); + // plugin.json carries the version; a second one makes validate/tag reject. + expect(entry.version).toBeUndefined(); +}); + +test("hooks.json registers the designed events through the exec-form launcher, never a shell", () => { + const hooks = json(path.join(PLUGIN_DIR, "hooks", "hooks.json")).hooks as Record< + string, + Array<{ + matcher?: string; + hooks: Array<{ type: string; command: string; args?: string[]; if?: string }>; + }> + >; + expect(Object.keys(hooks).toSorted()).toEqual( + // Only events whose hook changes Claude's behavior are registered: Stop, + // SubagentStop and PostToolUse are no-ops until the evidence model lands, + // and PreCompact cannot inject context (SessionStart compact restores it). + ["PreToolUse", "SessionStart", "SubagentStart", "UserPromptSubmit"].toSorted(), + ); + for (const [event, groups] of Object.entries(hooks)) + for (const group of groups) + for (const hook of group.hooks) { + expect(hook.type, event).toBe("command"); + expect(hook.command, event).toBe("node"); + expect(hook.args, event).toEqual(["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"]); + } + expect(hooks.SessionStart[0].matcher).toBe("startup|resume|clear|compact|fork"); + expect(hooks.PreToolUse.map((group) => [group.matcher, group.hooks[0].if])).toEqual([ + ["Bash", "Bash(git *)"], + ["PowerShell", "PowerShell(git *)"], + ]); +}); + +test("agents: verifier and reviewer are read-only, implementer runs in an isolated worktree", () => { + const agents = path.join(PLUGIN_DIR, "agents"); + expect(readdirSync(agents).toSorted()).toEqual(["implementer.md", "reviewer.md", "verifier.md"]); + for (const name of ["verifier", "reviewer"]) { + const meta = frontmatter(path.join(agents, `${name}.md`)); + expect(meta.name).toBe(name); + expect(meta.disallowedTools).toContain("Write"); + expect(meta.disallowedTools).toContain("Edit"); + expect(meta.tools).not.toContain("Write"); + } + const implementer = frontmatter(path.join(agents, "implementer.md")); + expect(implementer.isolation).toBe("worktree"); + expect(readFileSync(path.join(agents, "implementer.md"), "utf8")).toContain("goal, scope (files"); + // Plugin subagents may ignore these keys; the design forbids relying on them. + for (const name of ["verifier", "reviewer", "implementer"]) { + const meta = frontmatter(path.join(agents, `${name}.md`)); + for (const key of ["hooks", "mcpServers", "permissionMode"]) + expect(meta[key], `${name}.${key}`).toBeUndefined(); + } +}); + +test("skills are generated from workit-core, namespaced without the workit- prefix, and never committed", () => { + const skills = path.join(installedPlugin(), "skills"); + const expected = WORKIT_METHOD_SKILLS.map((name) => name.replace(/^workit-/, "")).toSorted(); + expect(readdirSync(skills).toSorted()).toEqual(expected); + for (const name of expected) { + const file = path.join(skills, name, "SKILL.md"); + expect(frontmatter(file).name, name).toBe(name); + const source = readFileSync( + path.join(REPO, "packages", "workit-core", "skills", `workit-${name}`, "SKILL.md"), + "utf8", + ); + const generated = readFileSync(file, "utf8"); + expect(generated, name).toContain("## In Claude Code"); + // The method body is the canonical one, untouched. + expect(generated, name).toContain(source.slice(source.indexOf("\n---\n") + 5).trim()); + } + const tracked = spawnSync("git", ["ls-files", "packages/workit-claude-code/skills"], { + cwd: REPO, + encoding: "utf8", + }); + expect(tracked.stdout.trim()).toBe(""); +}); + +test.skipIf(process.platform === "win32")( + "given the local pin, bin/workit --version resolves the monorepo source; the installed layout resolves dist/", + () => { + const trace = (dir: string) => + spawnSync(path.join(dir, "bin", "workit"), ["--version"], { + encoding: "utf8", + env: { ...process.env, WORKIT_SHIM_TRACE: "1" }, + timeout: 60_000, + }); + const pinned = trace(PLUGIN_DIR); + expect(pinned.status, pinned.stderr).toBe(0); + expect(pinned.stderr).toMatch(/workit-shim: source .*workit-cli[\\/]src[\\/]main\.ts/); + expect(pinned.stdout.trim()).toBe(CLI_VERSION); + const installed = trace(installedPlugin()); + expect(installed.status, installed.stderr).toBe(0); + expect(installed.stderr).toContain(`workit-shim: dist ${installedPlugin()}/dist/workit.js`); + expect(installed.stdout.trim()).toBe(CLI_VERSION); + }, + 90_000, +); + +test("CI and the eval workflow pin the support-matrix Claude Code CLI", () => { + for (const workflow of ["ci.yml", "claude-eval.yml"]) { + const text = readFileSync(path.join(REPO, ".github", "workflows", workflow), "utf8"); + expect(text, workflow).toContain(`CLAUDE_CODE_VERSION: "${SUPPORT_MATRIX.claudeCode.cli}"`); + } + const ci = readFileSync(path.join(REPO, ".github", "workflows", "ci.yml"), "utf8"); + expect(ci).toContain("plugin validate --strict packages/workit-claude-code"); + expect(ci).toContain("plugin validate --strict ."); +}); diff --git a/test/workit-cli/wizard-config.test.ts b/test/workit-cli/wizard-config.test.ts index 2d52c867..d5853bd9 100644 --- a/test/workit-cli/wizard-config.test.ts +++ b/test/workit-cli/wizard-config.test.ts @@ -1191,12 +1191,14 @@ test("platform options show all hosts and detection state without external-insta { label: "Cursor · detected", value: "cursor" }, { label: "Codex · detected", value: "codex" }, { label: "Pi · unavailable", value: "pi" }, + { label: "Claude Code · unavailable", value: "claude-code" }, ]); expect(platformOptions(emptyDetection())).toEqual([ { label: "OpenCode · unavailable", value: "opencode" }, { label: "Cursor · unavailable", value: "cursor" }, { label: "Codex · unavailable", value: "codex" }, { label: "Pi · unavailable", value: "pi" }, + { label: "Claude Code · unavailable", value: "claude-code" }, ]); expect(externalHostGuidance(detection)).toEqual([]); expect(externalHostGuidance(emptyDetection())).toEqual([]); diff --git a/test/workit-core/analyze-release-scope.test.ts b/test/workit-core/analyze-release-scope.test.ts index 18e72abb..457b693d 100644 --- a/test/workit-core/analyze-release-scope.test.ts +++ b/test/workit-core/analyze-release-scope.test.ts @@ -96,7 +96,10 @@ describe("analyzeReleaseScope", () => { r.tag("v0.8.11"); try { r.commit("fix(cli): flag parsing", { "packages/workit-cli/src/index.tsx": "export {};\n" }); - expect(analyzeReleaseScope(r.root)).toEqual({ level: "patch", productPkgs: ["workit-cli"] }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: "patch", + productPkgs: ["workit-cli", "workit-claude-code"], + }); } finally { r.cleanup(); } @@ -163,7 +166,10 @@ describe("analyzeReleaseScope", () => { try { r.commit("docs: readme", { "README.md": "# x\n" }); r.commit("fix(workit-cli): title (#42)", { "packages/workit-cli/src/main.ts": "m\n" }); - expect(analyzeReleaseScope(r.root)).toEqual({ level: "patch", productPkgs: ["workit-cli"] }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: "patch", + productPkgs: ["workit-cli", "workit-claude-code"], + }); } finally { r.cleanup(); } @@ -183,6 +189,7 @@ describe("analyzeReleaseScope", () => { "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ], }); } finally { @@ -203,7 +210,10 @@ describe("analyzeReleaseScope", () => { g(["checkout", "-q", "main"]); r.commit("docs: notes", { "docs/x.md": "x\n" }); g(["merge", "--no-ff", "-q", "-m", "Merge branch 'hotfix'", "hotfix"]); - expect(analyzeReleaseScope(r.root)).toEqual({ level: null, productPkgs: ["workit-cli"] }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: null, + productPkgs: ["workit-cli", "workit-claude-code"], + }); } finally { r.cleanup(); } @@ -216,7 +226,20 @@ describe("analyzeReleaseScope", () => { r.commit("docs(skills): pause parked leads", { "packages/workit-core/skills/workit-steer/SKILL.md": "# steer\n", }); - expect(analyzeReleaseScope(r.root)).toEqual({ level: "patch", productPkgs: ["workit-core"] }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: "patch", + // Core is inlined (and its skills copied) into every adapter. + productPkgs: [ + "workit-core", + "workit-mcp", + "workit-cli", + "workit-opencode", + "workit-cursor", + "workit-codex", + "workit-pi", + "workit-claude-code", + ], + }); } finally { r.cleanup(); } @@ -236,12 +259,46 @@ describe("analyzeReleaseScope", () => { } }); + test("a dev-tooling lockfile bump is no release; an inlined dependency bump releases its bundlers", () => { + const lock = (versions: Record) => + `{\n "lockfileVersion": 1,\n "packages": {\n${Object.entries(versions) + .map(([name, version]) => ` "${name}": ["${name}@${version}", "", {}, "sha512-x"],\n`) + .join("")} }\n}\n`; + const r = repo(); + try { + r.commit("chore: lock", { "bun.lock": lock({ zod: "4.6.5", oxlint: "1.86.0" }) }); + r.tag("v0.9.0"); + r.commit("chore(deps): bump oxlint", { + "bun.lock": lock({ zod: "4.6.5", oxlint: "1.87.0" }), + }); + expect(analyzeReleaseScope(r.root)).toEqual({ level: null, productPkgs: [] }); + r.commit("fix(deps): bump zod", { "bun.lock": lock({ zod: "4.7.0", oxlint: "1.87.0" }) }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: "patch", + productPkgs: [ + "workit-mcp", + "workit-cli", + "workit-opencode", + "workit-cursor", + "workit-codex", + "workit-pi", + "workit-claude-code", + ], + }); + } finally { + r.cleanup(); + } + }); + test("non-ASCII paths survive collection without C-quoting", () => { const r = repo(); r.tag("v0.8.11"); try { r.commit("fix(cli): café", { "packages/workit-cli/src/café.ts": "c\n" }); - expect(analyzeReleaseScope(r.root)).toEqual({ level: "patch", productPkgs: ["workit-cli"] }); + expect(analyzeReleaseScope(r.root)).toEqual({ + level: "patch", + productPkgs: ["workit-cli", "workit-claude-code"], + }); } finally { r.cleanup(); } diff --git a/test/workit-core/bundled-sources.test.ts b/test/workit-core/bundled-sources.test.ts new file mode 100644 index 00000000..9726bc2d --- /dev/null +++ b/test/workit-core/bundled-sources.test.ts @@ -0,0 +1,99 @@ +// BUNDLED_SOURCES must cover every workspace source a published dist/ +// inlines: each build entry is bundled with a metafile, and every input from +// another workspace package must fall under that package's payload paths, +// or a change there would ship without republishing the bundle. Inlined +// third-party packages must match the BUNDLED_DEPS inventory exactly. +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + BUNDLED_DEPS, + BUNDLED_SOURCES, + lockResolutions, + payloadPaths, + RELEASE_PACKAGES, +} from "@/packages/workit-core/scripts/analyze-release-scope"; + +/** bun.lock `packages` key for a bundled node_modules input path. */ +const lockKey = (rel: string): string => + rel + .split("node_modules/") + .slice(1) + .map((segment) => { + const parts = segment.split("/"); + return (parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1)).join("/"); + }) + .join("/"); + +const ROOT = path.resolve(import.meta.dir, "../.."); + +// The dist entries each package's scripts/build.ts bundles (core ships sources). +const BUILD_ENTRIES: Record, string[]> = { + "workit-mcp": ["src/index.ts"], + "workit-cli": ["src/main.ts"], + "workit-opencode": ["src/index.ts"], + "workit-cursor": ["mcp/run-server.ts", "hooks/session-start.ts", "hooks/workit-hook.ts"], + "workit-codex": ["hooks/workit-hook.ts", "scripts/launch-mcp.ts"], + "workit-pi": ["extensions/workit.ts", "src/worker.ts"], + "workit-claude-code": ["src/hook.ts", "../workit-cli/src/main.ts"], +}; + +test("every workspace source a dist/ inlines is in its package's payload paths", () => { + const out = mkdtempSync(path.join(tmpdir(), "workit-bundled-sources-")); + try { + const uncovered: string[] = []; + const inlined: Record> = {}; + for (const [pkg, entries] of Object.entries(BUILD_ENTRIES)) { + for (const [index, entry] of entries.entries()) { + const metafile = path.join(out, `${pkg}-${index}.json`); + const built = spawnSync( + process.execPath, + [ + "build", + path.join(ROOT, "packages", pkg, entry), + "--target", + "node", + "--outdir", + path.join(out, `${pkg}-${index}`), + `--metafile=${metafile}`, + ], + { cwd: ROOT, encoding: "utf8" }, + ); + expect(built.status, built.stderr).toBe(0); + const covered = payloadPaths(pkg as keyof typeof BUILD_ENTRIES); + for (const input of Object.keys(JSON.parse(readFileSync(metafile, "utf8")).inputs)) { + const rel = path.relative(ROOT, path.resolve(ROOT, input)).split(path.sep).join("/"); + // Inlined third-party code: record its bun.lock key + // (node_modules chain, e.g. `@opencode-ai/plugin/zod`). + if (rel.includes("node_modules/")) { + inlined[pkg] ??= new Set(); + inlined[pkg].add(lockKey(rel)); + continue; + } + if (!rel.startsWith("packages/")) continue; + if (!covered.some((prefix) => rel.startsWith(prefix))) uncovered.push(`${pkg}: ${rel}`); + } + } + } + expect(uncovered).toEqual([]); + // The checked-in inventory must equal what the bundles inline, so a + // version change of any inlined package republishes exactly them. + const actual = Object.fromEntries( + Object.keys(BUILD_ENTRIES).map((pkg) => [pkg, [...(inlined[pkg] ?? [])].toSorted()]), + ); + expect(actual).toEqual(BUNDLED_DEPS); + // Every inventory key resolves in the current lockfile. + const lock = lockResolutions(readFileSync(path.join(ROOT, "bun.lock"), "utf8")); + for (const keys of Object.values(BUNDLED_DEPS)) + for (const key of keys ?? []) expect(lock.has(key), key).toBe(true); + } finally { + rmSync(out, { recursive: true, force: true }); + } +}, 120_000); + +test("every adapter bundles core, so a core change republishes all of them", () => { + for (const pkg of RELEASE_PACKAGES.filter((name) => name !== "workit-core")) + expect(BUNDLED_SOURCES[pkg], pkg).toContain("packages/workit-core/"); +}); diff --git a/test/workit-core/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts new file mode 100644 index 00000000..7925a3d7 --- /dev/null +++ b/test/workit-core/claude-code-host.test.ts @@ -0,0 +1,394 @@ +// S14 host wiring for Claude Code: wizard detection, the native install plan +// (git-hosted marketplace → npm package), setup apply verification, and the +// doctor's stale/skew check over Claude's plugin registry. +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { detectHosts, preselectedPlatforms } from "@/packages/workit-core/src/core/detect-hosts"; +import { runDoctor } from "@/packages/workit-core/src/core/doctor"; +import { + claudeWorkitInstalls, + isClaudeWorkitInstalled, + planHostInstall, + runHostInstall, +} from "@/packages/workit-core/src/core/host-install"; +import { applySetupPreview, buildSetupPreview } from "@/packages/workit-core/src/core/setup"; +import { + applyUninstall, + planUninstall, + type UninstallAction, +} from "@/packages/workit-core/src/core/uninstall"; + +const temp = (prefix: string) => mkdtempSync(path.join(os.tmpdir(), prefix)); +const executable = (file: string) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, "#!/usr/bin/env node\n", { mode: 0o755 }); +}; +const REPO = path.resolve(import.meta.dir, "../.."); +const CORE_VERSION = JSON.parse( + readFileSync(path.join(REPO, "packages/workit-core/package.json"), "utf8"), +).version as string; + +/** Record a Claude Code plugin install the way `claude plugin install` does (2.1.288). */ +const recordInstall = ( + configDir: string, + options: { + id?: string; + version?: string; + name?: string; + repository?: string; + scope?: "user" | "project" | "local"; + projectPath?: string; + } = {}, +) => { + const id = options.id ?? "workit@workit"; + const version = options.version ?? "2.1.5"; + const installPath = path.join( + configDir, + "plugins", + "cache", + ...id.split("@").toReversed(), + version, + ); + mkdirSync(path.join(installPath, ".claude-plugin"), { recursive: true }); + writeFileSync( + path.join(installPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: options.name ?? "workit", + version, + repository: options.repository ?? "https://github.com/BrainerVirus/workit", + }), + ); + writeFileSync( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + [id]: [ + { + scope: options.scope ?? "user", + ...(options.projectPath ? { projectPath: options.projectPath } : {}), + installPath, + version, + }, + ], + }, + }), + ); + return installPath; +}; + +test("the wizard detects `claude` on PATH and a recorded Workit plugin install", () => { + const home = temp("workit-claude-detect-"); + const bin = temp("workit-claude-detect-bin-"); + try { + let found = detectHosts({ home, env: { HOME: home, PATH: bin } }); + expect(found["claude-code"]).toEqual({ detected: false, configured: false }); + // A config directory alone is not an installation. + mkdirSync(path.join(home, ".claude", "plugins"), { recursive: true }); + expect(detectHosts({ home, env: { HOME: home, PATH: bin } })["claude-code"].detected).toBe( + false, + ); + executable(path.join(bin, "claude")); + found = detectHosts({ home, env: { HOME: home, PATH: bin } }); + expect(found["claude-code"]).toEqual({ detected: true, configured: false }); + expect(preselectedPlatforms(found)).toContain("claude-code"); + recordInstall(path.join(home, ".claude")); + expect(detectHosts({ home, env: { HOME: home, PATH: bin } })["claude-code"]).toEqual({ + detected: true, + configured: true, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(bin, { recursive: true, force: true }); + } +}); + +test("only a canonical Workit manifest counts as installed, from any marketplace or CLAUDE_CONFIG_DIR", () => { + const home = temp("workit-claude-installs-"); + try { + const config = path.join(home, "custom-claude"); + const env = { HOME: home, CLAUDE_CONFIG_DIR: config }; + recordInstall(config, { name: "workit", repository: "https://github.com/someone/else" }); + expect(isClaudeWorkitInstalled(home, env)).toBe(false); + recordInstall(config, { id: "workit@my-fork", version: "9.9.9" }); + expect(claudeWorkitInstalls(home, env)).toEqual([ + expect.objectContaining({ id: "workit@my-fork", version: "9.9.9" }), + ]); + // The default config dir is not consulted when CLAUDE_CONFIG_DIR is set. + expect(isClaudeWorkitInstalled(home, { HOME: home })).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("Claude Code setup registers the git-hosted marketplace once, then installs workit@workit", () => { + const home = temp("workit-claude-plan-"); + const bin = temp("workit-claude-plan-bin-"); + try { + executable(path.join(bin, "node")); + executable(path.join(bin, "claude")); + const env = { HOME: home, PATH: bin }; + const commands = planHostInstall("claude-code", { home, cwd: home, env }); + expect(commands.slice(1).map((command) => command.args)).toEqual([ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "marketplace", "add", "BrainerVirus/workit"], + ["plugin", "install", "workit@workit", "--scope", "user"], + ]); + const ran: string[] = []; + const listing = JSON.stringify([{ name: "workit", source: "github" }], null, 2); + const result = runHostInstall(commands, (command) => { + ran.push(command.purpose); + return { + exitCode: 0, + stdout: command.args.includes("list") ? listing : "", + stderr: "", + }; + }); + expect(result.ok).toBe(true); + // Already-registered marketplace: the add is skipped, the install still runs. + expect(ran).toEqual([ + "Check the Node.js 24+ package runtime requirement", + "Check whether the Workit Claude Code marketplace is already registered", + "Install the Workit Claude Code plugin", + ]); + expect( + planHostInstall("claude-code", { home, cwd: home, env, mode: "upgrade" }) + .slice(1) + .map((command) => command.args), + ).toEqual([ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "marketplace", "update", "workit"], + ["plugin", "update", "workit@workit"], + ]); + // An existing install is never re-installed by setup. + recordInstall(path.join(home, ".claude")); + expect(planHostInstall("claude-code", { home, cwd: home, env })).toEqual([]); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(bin, { recursive: true, force: true }); + } +}); + +test("setup apply reports Claude Code installed only when the plugin registry shows Workit", () => { + const home = temp("workit-claude-setup-"); + const bin = temp("workit-claude-setup-bin-"); + const configDir = temp("workit-claude-setup-config-"); + try { + executable(path.join(bin, "node")); + executable(path.join(bin, "claude")); + const env = { HOME: home, PATH: bin, WORKFLOW_TOOLKIT_CONFIG_DIR: configDir }; + const options = { home, cwd: home, env, dir: configDir, configDir }; + const preview = buildSetupPreview( + { + platforms: ["claude-code"], + locale: "en", + branchPreset: "github-flow", + branchAllowed: "", + branchProtected: "", + baseUrl: "", + vcsProvider: "skip", + workspaces: [], + applyProject: false, + }, + options, + ); + const host = preview.mutations.find((mutation) => mutation.type === "install-host"); + expect(host?.path).toBe(path.join(home, ".claude", "plugins", "installed_plugins.json")); + const applied = applySetupPreview(preview, { + ...options, + runHostCommand: (command) => { + if (command.args.includes("install")) recordInstall(path.join(home, ".claude")); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + const claude = applied.entries.filter((entry) => entry.platform === "claude-code"); + expect(claude.map((entry) => entry.status)).not.toContain("Failed"); + expect(claude.some((entry) => entry.detail?.includes("plugin install workit@workit"))).toBe( + true, + ); + // A host command that "succeeds" without registering Workit is a failure. + rmSync(path.join(home, ".claude"), { recursive: true, force: true }); + const unverified = applySetupPreview(preview, { + ...options, + runHostCommand: () => ({ exitCode: 0, stdout: "", stderr: "" }), + }); + expect( + unverified.entries.some( + (entry) => entry.platform === "claude-code" && entry.status === "Failed", + ), + ).toBe(true); + } finally { + for (const dir of [home, bin, configDir]) rmSync(dir, { recursive: true, force: true }); + } +}); + +// Each call runs the whole doctor (runtime, identity and lock probes too), +// which takes several seconds per call on the Windows runner. +test("doctor warns only when a newer Claude Code plugin version is published with the native update command", () => { + const home = temp("workit-claude-doctor-"); + try { + const check = (env: NodeJS.ProcessEnv) => + // An isolated home as cwd keeps the other checks off the repository. + runDoctor({ home, cwd: home, env: { HOME: home, ...env } }).checks.find( + (entry) => entry.id === "claude_plugin", + )!; + expect(check({}).status).toBe("pass"); + recordInstall(path.join(home, ".claude"), { version: CORE_VERSION }); + const current = check({ WORKIT_DOCTOR_STALE_REGISTRY_VERSION: CORE_VERSION }); + expect(current.status).toBe("pass"); + recordInstall(path.join(home, ".claude"), { version: "0.0.1" }); + const stale = check({ WORKIT_DOCTOR_STALE_REGISTRY_VERSION: CORE_VERSION }); + expect(stale.status).toBe("warn"); + expect(stale.detail).toContain( + `stale_install: workit@workit 0.0.1 is behind published ${CORE_VERSION}`, + ); + // An older plugin with nothing newer published (or an unreachable registry) + // is not a finding, whatever this CLI's version. + expect(check({ WORKIT_DOCTOR_STALE_REGISTRY_VERSION: "0.0.1" }).status).toBe("pass"); + expect(stale.fix).toBe( + "claude plugin marketplace update workit && claude plugin update workit@workit", + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}, 120_000); + +test("uninstall previews a native `claude plugin uninstall` per Workit install and runs only that argv", () => { + const home = temp("workit-claude-uninstall-"); + try { + const env = { HOME: home }; + expect(planUninstall({ home, env }).hosts.find((h) => h.host === "claude-code")).toEqual({ + host: "claude-code", + installed: false, + actions: [], + }); + recordInstall(path.join(home, ".claude"), { id: "workit@workit" }); + const plan = planUninstall({ home, env }); + const claude = plan.hosts.find((h) => h.host === "claude-code")!; + expect(claude.installed).toBe(true); + expect(claude.actions).toEqual([ + expect.objectContaining({ + kind: "host-command", + command: "claude", + args: ["plugin", "uninstall", "workit@workit", "--scope", "user"], + cwd: null, + detail: "claude plugin uninstall workit@workit --scope user", + }), + ]); + const ran: string[][] = []; + const reviewed = { hosts: [claude] }; + const result = applyUninstall(reviewed, { + home, + env, + runHostCommand: (step) => { + ran.push(step.args); + rmSync(path.join(home, ".claude", "plugins", "installed_plugins.json")); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + expect(ran).toEqual([["plugin", "uninstall", "workit@workit", "--scope", "user"]]); + expect(result.entries).toEqual([ + expect.objectContaining({ host: "claude-code", status: "removed" }), + ]); + // Already gone: skipped without running anything. + expect( + applyUninstall(reviewed, { + home, + env, + runHostCommand: () => { + throw new Error("must not run"); + }, + }).entries[0].status, + ).toBe("skipped"); + // A tampered plan never runs an arbitrary command. + recordInstall(path.join(home, ".claude")); + const tampered = { + hosts: [ + { + ...claude, + actions: [{ ...claude.actions[0], args: ["plugin", "uninstall", "other@x", "--prune"] }], + }, + ], + } as typeof reviewed; + const refused = applyUninstall(tampered, { + home, + env, + runHostCommand: () => { + throw new Error("must not run"); + }, + }); + expect(refused.ok).toBe(false); + expect(refused.entries[0].detail).toContain("refusing unreviewed host command"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("project and local installs count only inside their project, and uninstall from it with --scope", () => { + const home = temp("workit-claude-scope-"); + try { + const project = path.join(home, "repo"); + const other = path.join(home, "elsewhere"); + mkdirSync(path.join(project, "src"), { recursive: true }); + mkdirSync(other, { recursive: true }); + const env = { HOME: home }; + recordInstall(path.join(home, ".claude"), { scope: "project", projectPath: project }); + // Outside the project (and with no project at all) nothing applies. + expect(isClaudeWorkitInstalled(home, env, other)).toBe(false); + expect(isClaudeWorkitInstalled(home, env, null)).toBe(false); + expect(claudeWorkitInstalls(home, env, path.join(project, "src"))).toEqual([ + expect.objectContaining({ scope: "project", projectPath: project }), + ]); + // Setup installs user scope, so a project-only install does not satisfy it. + expect(planUninstall({ home, env, cwd: other }).hosts.at(-1)?.actions).toEqual([]); + const plan = planUninstall({ home, env, cwd: project }); + const claude = plan.hosts.find((h) => h.host === "claude-code")!; + expect(claude.actions).toEqual([ + expect.objectContaining({ + args: ["plugin", "uninstall", "workit@workit", "--scope", "project"], + cwd: project, + }), + ]); + const steps: Array<{ args: string[]; cwd?: string }> = []; + const applied = applyUninstall( + { hosts: [claude] }, + { + home, + env, + runHostCommand: (step) => { + steps.push({ args: step.args, cwd: step.cwd }); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + ); + expect(applied.ok).toBe(true); + expect(steps).toEqual([ + { args: ["plugin", "uninstall", "workit@workit", "--scope", "project"], cwd: project }, + ]); + const reviewedAction = claude.actions[0] as Extract; + // A project-scope action without its project, or a user-scope one with a + // cwd, is not the reviewed shape. + for (const action of [ + { ...reviewedAction, cwd: null }, + { ...reviewedAction, args: ["plugin", "uninstall", "workit@workit", "--scope", "user"] }, + { + ...claude.actions[0], + args: ["plugin", "uninstall", "workit@workit", "--scope", "managed"], + }, + ]) { + const refused = applyUninstall( + { hosts: [{ ...claude, actions: [action] }] }, + { + home, + env, + runHostCommand: () => ({ exitCode: 0, stdout: "", stderr: "" }), + }, + ); + expect(refused.entries[0].status, JSON.stringify(action.args)).toBe("failed"); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/test/workit-core/hooks/bundle.test.ts b/test/workit-core/hooks/bundle.test.ts index 579a32f3..4585d0d9 100644 --- a/test/workit-core/hooks/bundle.test.ts +++ b/test/workit-core/hooks/bundle.test.ts @@ -9,6 +9,7 @@ const HOOK_ENTRIES = [ "packages/workit-codex/hooks/workit-hook.ts", "packages/workit-cursor/hooks/workit-hook.ts", "packages/workit-core/src/hooks/run.ts", + "packages/workit-claude-code/src/hook.ts", ]; // Minified bytes. The design target is 300 KB; today's floor is the task // engine plus zod that compact task context needs (~585 KB), so this pins the diff --git a/test/workit-core/hooks/claude-code.test.ts b/test/workit-core/hooks/claude-code.test.ts index 5b4b3c81..1b99f15b 100644 --- a/test/workit-core/hooks/claude-code.test.ts +++ b/test/workit-core/hooks/claude-code.test.ts @@ -165,3 +165,35 @@ test("Claude renders context, per-turn context, and silent events in its native rmSync(root, { recursive: true, force: true }); } }); + +test("Claude SubagentStart keys its text on agent_type: only the worktree implementer may write", () => { + const root = tempRoot(); + try { + const text = (agent_type: string) => + JSON.stringify( + dispatchHook( + claudeCodeAdapter, + fixture("claude-code", "subagent-start", root, { agent_type }), + {}, + ).json, + ); + expect(text("workit:implementer")).toContain("working in its own git worktree"); + expect(text("workit:implementer")).not.toContain("read-only"); + expect(text("workit:reviewer")).toContain("read-only/agent-guided"); + expect(text("general-purpose")).toContain("read-only/agent-guided"); + expect(text("implementer")).toContain("read-only/agent-guided"); + expect(text("acme:implementer")).toContain("read-only/agent-guided"); + // Other hosts keep their text whatever the agent type is called. + const codex = JSON.stringify( + dispatchHook( + codexAdapter, + fixture("codex", "subagent-start", root, { agent_type: "implementer" }), + {}, + ).json, + ); + expect(codex).toContain("read-only/agent-guided"); + expect(codex).not.toContain("worktree"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/workit-core/install-scripts.test.ts b/test/workit-core/install-scripts.test.ts index a80d3be2..a17b2905 100644 --- a/test/workit-core/install-scripts.test.ts +++ b/test/workit-core/install-scripts.test.ts @@ -246,6 +246,7 @@ function copyCoreSources(stub: string) { "task-contract.ts", "store-lock.ts", "runtime-identity.ts", + "host-install.ts", ]) { const src = name === "doctor-check.ts" diff --git a/test/workit-core/publish-changed-packages.test.ts b/test/workit-core/publish-changed-packages.test.ts index cb4582a6..457d9e1e 100644 --- a/test/workit-core/publish-changed-packages.test.ts +++ b/test/workit-core/publish-changed-packages.test.ts @@ -41,14 +41,12 @@ function repo({ tagged = true }: { tagged?: boolean } = {}) { }; } -const ADAPTER_PACKAGES = RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-core"); - describe("changedPackages", () => { test("lists only packages with payload diffs", () => { const r = repo(); try { - r.change("packages/workit-cli/src/i.ts", "c\n"); - expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-cli"]); + r.change("packages/workit-opencode/src/i.ts", "c\n"); + expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-opencode"]); } finally { r.cleanup(); } @@ -56,9 +54,9 @@ describe("changedPackages", () => { test("uncommitted working-tree edits are never counted (B1)", () => { const r = repo(); try { - r.change("packages/workit-cli/src/i.ts", "c\n"); + r.change("packages/workit-opencode/src/i.ts", "c\n"); writeFileSync(path.join(r.root, "packages/workit-opencode/src/i.ts"), "dirty\n"); - expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-cli"]); + expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-opencode"]); } finally { r.cleanup(); } @@ -90,7 +88,7 @@ describe("publishChanged", () => { test("publishes changed, skips unchanged, exact skip line", () => { const r = repo(); try { - r.change("packages/workit-core/src/i.ts", "c\n"); + r.change("packages/workit-opencode/src/i.ts", "c\n"); const calls: string[] = []; const result = publishChanged({ root: r.root, @@ -98,26 +96,80 @@ describe("publishChanged", () => { calls.push(`${args.join(" ")} @ ${opts.cwd}`); }, }); - expect(result.published).toEqual(["workit-core"]); - expect(result.skipped).toEqual(ADAPTER_PACKAGES); + const others = RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-opencode"); + expect(result.published).toEqual(["workit-opencode"]); + expect(result.skipped).toEqual(others); expect(calls[0]).toBe( - `publish --access public @ ${path.join(r.root, "packages/workit-core")}`, + `publish --access public @ ${path.join(r.root, "packages/workit-opencode")}`, ); const log = spyOn(console, "log"); publishChanged({ root: r.root, run: () => {} }); - expect(log.mock.calls.map((c) => c[0])).toEqual([ - `published workit-core @ ${path.join(r.root, "packages", "workit-core")}`, - ...ADAPTER_PACKAGES.map((pkg) => `skip ${pkg} (no payload change since v0.8.10)`), - ]); + expect(log.mock.calls.map((c) => c[0])).toEqual( + RELEASE_PACKAGES.map((pkg) => + pkg === "workit-opencode" + ? `published workit-opencode @ ${path.join(r.root, "packages", "workit-opencode")}` + : `skip ${pkg} (no payload change since v0.8.10)`, + ), + ); log.mockRestore(); } finally { r.cleanup(); } }); - test("publish failure logs shipped state, names the package, and stops", () => { + test("bundling packages republish when the core, MCP or CLI sources they inline change", () => { const r = repo(); try { r.change("packages/workit-core/src/i.ts", "c\n"); + // Every adapter inlines core into its dist/ (BUNDLED_SOURCES). + expect(changedPackages(r.root, "v0.8.10")).toEqual([...RELEASE_PACKAGES]); + const r2 = repo(); + try { + r2.change("packages/workit-cli/src/i.ts", "c\n"); + expect(changedPackages(r2.root, "v0.8.10")).toEqual(["workit-cli", "workit-claude-code"]); + r2.change("packages/workit-mcp/src/i.ts", "c\n"); + expect(changedPackages(r2.root, "v0.8.10")).toEqual([ + "workit-mcp", + "workit-cli", + "workit-cursor", + "workit-codex", + "workit-claude-code", + ]); + } finally { + r2.cleanup(); + } + } finally { + r.cleanup(); + } + }); + test("a dev-tooling lockfile bump releases nothing; an inlined dep bump republishes its bundlers", () => { + const r = repo(); + const lock = (versions: Record) => + `{\n "lockfileVersion": 1,\n "packages": {\n${Object.entries(versions) + .map(([name, version]) => ` "${name}": ["${name}@${version}", "", {}, "sha512-x"],\n`) + .join("")} }\n}\n`; + try { + r.change("bun.lock", lock({ zod: "4.6.5", oxlint: "1.86.0", ink: "7.1.1" })); + const r0 = r.root; + execFileSync("git", ["tag", "-f", "v0.8.10"], { cwd: r0 }); + r.change("bun.lock", lock({ zod: "4.6.5", oxlint: "1.87.0", ink: "7.1.1" })); + expect(changedPackages(r0, "v0.8.10")).toEqual([]); + r.change("bun.lock", lock({ zod: "4.7.0", oxlint: "1.87.0", ink: "7.1.1" })); + // Every adapter inlines zod (through core); core ships sources. + expect(changedPackages(r0, "v0.8.10")).toEqual( + RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-core"), + ); + execFileSync("git", ["tag", "-f", "v0.8.10"], { cwd: r0 }); + r.change("bun.lock", lock({ zod: "4.7.0", oxlint: "1.87.0", ink: "7.2.0" })); + // ink is inlined only by the bundled CLI. + expect(changedPackages(r0, "v0.8.10")).toEqual(["workit-cli", "workit-claude-code"]); + } finally { + r.cleanup(); + } + }); + test("a publish failure does not stop the others; failures throw at the end with a summary", () => { + const r = repo(); + try { + r.change("packages/workit-mcp/src/i.ts", "c\n"); r.change("packages/workit-opencode/src/i.ts", "c\n"); const log = spyOn(console, "log"); const ran: string[] = []; @@ -126,24 +178,21 @@ describe("publishChanged", () => { publishChanged({ root: r.root, run: (_cmd, _args, opts) => { - ran.push(opts.cwd); + ran.push(path.basename(opts.cwd)); if (opts.cwd.endsWith("workit-opencode")) throw new Error("boom"); }, }); } catch (e) { err = e; } - expect((err as Error).message).toBe("boom"); - expect(ran).toEqual([ - path.join(r.root, "packages", "workit-core"), - path.join(r.root, "packages", "workit-opencode"), - ]); - expect(log.mock.calls.map((c) => c[0])).toEqual([ - `published workit-core @ ${path.join(r.root, "packages", "workit-core")}`, - `skip workit-mcp (no payload change since v0.8.10)`, - `skip workit-cli (no payload change since v0.8.10)`, - `publish failed workit-opencode: boom`, - ]); + expect(ran).toEqual(["workit-mcp", "workit-opencode", "workit-cursor", "workit-codex"]); + const message = (err as Error).message; + expect(message).toContain("publish failed for 1 package(s): workit-opencode"); + expect(message).toContain("published: workit-mcp, workit-cursor, workit-codex"); + expect(message).toContain("npm token"); + const lines = log.mock.calls.map((c) => String(c[0])); + expect(lines).toContain("publish failed workit-opencode: boom"); + expect(lines.at(-1)).toBe(message); log.mockRestore(); } finally { r.cleanup(); @@ -179,7 +228,7 @@ describe("publishChanged", () => { // Real CI ordering: product changes land, then semantic-release creates // the NEW release tag on HEAD before publish plugins run — diffing // against latestTag() at that point is always empty. - r.change("packages/workit-core/src/i.ts", "c\n"); + r.change("packages/workit-opencode/src/i.ts", "c\n"); execFileSync("git", ["tag", "v0.9.0"], { cwd: r.root }); const calls: string[] = []; const result = publishChanged({ @@ -189,11 +238,11 @@ describe("publishChanged", () => { calls.push(`${args.join(" ")} @ ${opts.cwd}`); }, }); - expect(result.published).toEqual(["workit-core"]); - expect(result.skipped).toEqual(ADAPTER_PACKAGES); + expect(result.published).toEqual(["workit-opencode"]); + expect(result.skipped).toEqual(RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-opencode")); expect(result.tag).toBe("v0.8.10"); expect(calls[0]).toBe( - `publish --access public @ ${path.join(r.root, "packages/workit-core")}`, + `publish --access public @ ${path.join(r.root, "packages/workit-opencode")}`, ); } finally { r.cleanup(); @@ -202,7 +251,7 @@ describe("publishChanged", () => { test("dryRun records without invoking npm", () => { const r = repo(); try { - r.change("packages/workit-cli/src/i.ts", "c\n"); + r.change("packages/workit-opencode/src/i.ts", "c\n"); let ran = 0; const result = publishChanged({ root: r.root, @@ -211,7 +260,7 @@ describe("publishChanged", () => { ran++; }, }); - expect(result.published).toEqual(["workit-cli"]); + expect(result.published).toEqual(["workit-opencode"]); expect(ran).toBe(0); } finally { r.cleanup(); diff --git a/test/workit-core/rewrite-workspace-deps.test.ts b/test/workit-core/rewrite-workspace-deps.test.ts index 804776ce..1f8306ff 100644 --- a/test/workit-core/rewrite-workspace-deps.test.ts +++ b/test/workit-core/rewrite-workspace-deps.test.ts @@ -31,6 +31,11 @@ test("rewrite-workspace-deps.ts: workspace:* → ^ in all platform path.join(repoRoot, "packages/workit-codex/.codex-plugin/plugin.json"), path.join(sandbox, "packages/workit-codex/.codex-plugin/plugin.json"), ); + mkdirSync(path.join(sandbox, "packages/workit-claude-code/.claude-plugin"), { recursive: true }); + cpSync( + path.join(repoRoot, "packages/workit-claude-code/.claude-plugin/plugin.json"), + path.join(sandbox, "packages/workit-claude-code/.claude-plugin/plugin.json"), + ); const script = path.join(repoRoot, "packages/workit-core/scripts/rewrite-workspace-deps.ts"); const run = spawnSync("bun", [script, sandbox], { encoding: "utf8" }); expect(run.status, run.stderr).toBe(0); @@ -54,6 +59,13 @@ test("rewrite-workspace-deps.ts: workspace:* → ^ in all platform ); expect(data.version).toBe(version); } + const claude = JSON.parse( + readFileSync( + path.join(sandbox, "packages/workit-claude-code/.claude-plugin/plugin.json"), + "utf8", + ), + ); + expect(claude.version).toBe(version); const plugin = JSON.parse( readFileSync(path.join(sandbox, "packages/workit-cursor/.cursor-plugin/plugin.json"), "utf8"), ); @@ -96,6 +108,13 @@ test("rewrite-workspace-deps.ts: every prepared adapter dependency equals the pr path.join(sandbox, "packages/workit-codex/.codex-plugin/plugin.json"), `${JSON.stringify({ version: "0.4.0" }, null, 2)}\n`, ); + mkdirSync(path.join(sandbox, "packages/workit-claude-code/.claude-plugin"), { + recursive: true, + }); + writeFileSync( + path.join(sandbox, "packages/workit-claude-code/.claude-plugin/plugin.json"), + `${JSON.stringify({ version: "0.4.0" }, null, 2)}\n`, + ); const script = path.join(repoRoot, "packages/workit-core/scripts/rewrite-workspace-deps.ts"); const run = spawnSync("bun", [script, sandbox], { encoding: "utf8" }); expect(run.status, run.stderr).toBe(0); @@ -152,6 +171,13 @@ test("rewrite-workspace-deps.ts: pins every internal @brainervirus dependency in path.join(repoRoot, "packages/workit-codex/.codex-plugin/plugin.json"), path.join(sandbox, "packages/workit-codex/.codex-plugin/plugin.json"), ); + mkdirSync(path.join(sandbox, "packages/workit-claude-code/.claude-plugin"), { + recursive: true, + }); + cpSync( + path.join(repoRoot, "packages/workit-claude-code/.claude-plugin/plugin.json"), + path.join(sandbox, "packages/workit-claude-code/.claude-plugin/plugin.json"), + ); const script = path.join(repoRoot, "packages/workit-core/scripts/rewrite-workspace-deps.ts"); const run = spawnSync("bun", [script, sandbox], { encoding: "utf8" }); expect(run.status, run.stderr).toBe(0); diff --git a/test/workit-core/sync-release-manifests.test.ts b/test/workit-core/sync-release-manifests.test.ts index ed3a8e9a..f609866e 100644 --- a/test/workit-core/sync-release-manifests.test.ts +++ b/test/workit-core/sync-release-manifests.test.ts @@ -13,8 +13,10 @@ const manifestPaths = [ "packages/workit-cursor/package.json", "packages/workit-codex/package.json", "packages/workit-pi/package.json", + "packages/workit-claude-code/package.json", "packages/workit-cursor/.cursor-plugin/plugin.json", "packages/workit-codex/.codex-plugin/plugin.json", + "packages/workit-claude-code/.claude-plugin/plugin.json", ]; const fixtureRoot = (versions: Record, name = "workflow-toolkit") => { @@ -76,8 +78,10 @@ describe("syncManifests", () => { "packages/workit-cursor/package.json", "packages/workit-codex/package.json", "packages/workit-pi/package.json", + "packages/workit-claude-code/package.json", "packages/workit-cursor/.cursor-plugin/plugin.json", "packages/workit-codex/.codex-plugin/plugin.json", + "packages/workit-claude-code/.claude-plugin/plugin.json", ]); expect(JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version).toBe( "0.8.9", diff --git a/test/workit-core/uninstall.test.ts b/test/workit-core/uninstall.test.ts index f1eaa7d0..291e0362 100644 --- a/test/workit-core/uninstall.test.ts +++ b/test/workit-core/uninstall.test.ts @@ -78,7 +78,13 @@ test("plan reports both hosts installed with exact action paths", () => { const f = tracked(); f.seedInstalled(); const plan = planUninstall(f); - expect(plan.hosts.map((h) => h.host)).toEqual(["opencode", "cursor", "codex", "pi"]); + expect(plan.hosts.map((h) => h.host)).toEqual([ + "opencode", + "cursor", + "codex", + "pi", + "claude-code", + ]); const oc = plan.hosts.find((h) => h.host === "opencode")!; expect(oc.installed).toBe(true); expect(oc.actions).toEqual([