From d78f9278c329a6476de1d7fb8aca6bb93ba34143 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:08:47 -0300 Subject: [PATCH 01/21] feat(claude-code): add the Claude Code plugin package packages/workit-claude-code ships the Workit plugin for Claude Code: - hooks/hooks.json registers SessionStart (startup|resume|clear|compact), UserPromptSubmit, PreToolUse (Bash/PowerShell `git *`), PostToolUse (Bash `workit *`), SubagentStart/Stop, PreCompact and Stop, all through one exec-form launcher (`node bin/workit-hook.mjs`, no shell). - The launcher runs src/run.ts from source with bun when the plugin sits in the monorepo (local pin) and imports dist/workit-hook.js otherwise; any launcher failure fails open with a diagnostic. - src/hook.ts maps events through core/hooks' claude-code adapter, adds a Claude addendum (workit- skills are /workit:), exports WORKIT_HOST/WORKIT_SESSION_ID via $CLAUDE_ENV_FILE on SessionStart, and re-injects per-turn context only when it changed (cache in $CLAUDE_PLUGIN_DATA). - bin/workit puts the CLI on the Bash tool's PATH (source with bun in a checkout, bundled dist/workit.js when installed). TODO(#163): bundle workit-cli/src/main.ts once the router lands; both the shim and the build already prefer it when present. - agents: read-only verifier and reviewer, implementer with isolation: worktree. - scripts/build.ts bundles the hook and CLI, copies templates, and generates the fourteen skills from workit-core/skills, renamed to plugin-namespaced names (never committed). Hook-fixture tests pipe every captured Claude payload through the real launcher in both runtimes and validate the output against the hook output schema transcribed from Claude Code 2.1.288. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + .oxfmtrc.json | 2 + bun.lock | 24 ++- knip.json | 8 +- package.json | 5 +- .../.claude-plugin/plugin.json | 19 +++ packages/workit-claude-code/README.md | 46 ++++++ .../workit-claude-code/agents/implementer.md | 26 ++++ .../workit-claude-code/agents/reviewer.md | 23 +++ .../workit-claude-code/agents/verifier.md | 26 ++++ packages/workit-claude-code/bin/workit | 26 ++++ .../workit-claude-code/bin/workit-hook.mjs | 43 ++++++ packages/workit-claude-code/hooks/hooks.json | 118 +++++++++++++++ packages/workit-claude-code/package.json | 48 ++++++ packages/workit-claude-code/scripts/build.ts | 111 ++++++++++++++ packages/workit-claude-code/src/hook.ts | 117 +++++++++++++++ packages/workit-claude-code/src/run.ts | 5 + scripts/check-reachability.ts | 2 + .../hook-output.schema.json | 84 +++++++++++ test/workit-claude-code/hooks.test.ts | 138 ++++++++++++++++++ test/workit-claude-code/plugin-helpers.ts | 72 +++++++++ test/workit-core/hooks/bundle.test.ts | 1 + 22 files changed, 938 insertions(+), 10 deletions(-) create mode 100644 packages/workit-claude-code/.claude-plugin/plugin.json create mode 100644 packages/workit-claude-code/README.md create mode 100644 packages/workit-claude-code/agents/implementer.md create mode 100644 packages/workit-claude-code/agents/reviewer.md create mode 100644 packages/workit-claude-code/agents/verifier.md create mode 100755 packages/workit-claude-code/bin/workit create mode 100755 packages/workit-claude-code/bin/workit-hook.mjs create mode 100644 packages/workit-claude-code/hooks/hooks.json create mode 100644 packages/workit-claude-code/package.json create mode 100644 packages/workit-claude-code/scripts/build.ts create mode 100644 packages/workit-claude-code/src/hook.ts create mode 100644 packages/workit-claude-code/src/run.ts create mode 100644 test/fixtures/claude-code-schemas/hook-output.schema.json create mode 100644 test/workit-claude-code/hooks.test.ts create mode 100644 test/workit-claude-code/plugin-helpers.ts 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/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 378d111c..965309c6 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 72a1ca1d..f558de40 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "workit", "version": "2.1.5", "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..85c82929 --- /dev/null +++ b/packages/workit-claude-code/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "workit", + "displayName": "Workit", + "version": "2.1.5", + "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..0d826a48 --- /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, UserPromptSubmit, PreToolUse (Bash/PowerShell `git *`), PostToolUse, SubagentStart/Stop, PreCompact, Stop | +| `bin/workit-hook.mjs` | Hook launcher: source with Bun in a checkout, `dist/workit-hook.js` when installed | +| `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..36bacb3e --- /dev/null +++ b/packages/workit-claude-code/bin/workit @@ -0,0 +1,26 @@ +#!/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. +# TODO(S9a, PR #163): once packages/workit-cli/src/main.ts lands on main, drop +# the index.tsx fallback below. +set -e +here=$(dirname "$0") +root=$(cd "$here/.." && pwd) +cli="$root/../workit-cli/src" +entry="" +if [ "${WORKIT_CLAUDE_RUNTIME:-}" != "dist" ] && [ -f "$root/../workit-core/src/core.ts" ]; then + if [ -f "$cli/main.ts" ]; then entry="$cli/main.ts"; elif [ -f "$cli/index.tsx" ]; then entry="$cli/index.tsx"; fi +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..3a857f29 --- /dev/null +++ b/packages/workit-claude-code/bin/workit-hook.mjs @@ -0,0 +1,43 @@ +#!/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. A launcher failure fails open +// (an empty decision plus a stderr diagnostic): 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)) { + const { runClaudeHook } = await import(pathToFileURL(dist).href); + 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/hooks/hooks.json b/packages/workit-claude-code/hooks/hooks.json new file mode 100644 index 00000000..ad7f1d6d --- /dev/null +++ b/packages/workit-claude-code/hooks/hooks.json @@ -0,0 +1,118 @@ +{ + "description": "Workit host hooks: task context on session start and per turn, branch policy on git shell commands, subagent and stop observation.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "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 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(workit *)", + "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 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "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..f7b7f363 --- /dev/null +++ b/packages/workit-claude-code/package.json @@ -0,0 +1,48 @@ +{ + "name": "@brainervirus/workit-claude-code", + "version": "2.1.5", + "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..959f00e3 --- /dev/null +++ b/packages/workit-claude-code/scripts/build.ts @@ -0,0 +1,111 @@ +#!/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); + // TODO(S9a, PR #163): bundle workit-cli/src/main.ts unconditionally once it + // is on main; until then the current index.tsx entry is the CLI. + const cliSrc = path.join(packagesDir, "workit-cli", "src"); + const cliEntry = existsSync(path.join(cliSrc, "main.ts")) + ? path.join(cliSrc, "main.ts") + : path.join(cliSrc, "index.tsx"); + bundle(cliEntry, 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..4b7dbfea --- /dev/null +++ b/packages/workit-claude-code/src/hook.ts @@ -0,0 +1,117 @@ +// 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, rmSync, 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. + } +}; + +/** + * 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 = createHash("sha256").update(context).digest("hex"); + 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. + } + try { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ digest })}\n`); + } catch { + // Unwritable plugin data dir: context is simply sent every turn. + } + return false; +}; + +/** 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); + // A fresh or compacted conversation no longer holds the old context. + if (cache) rmSync(cache, { force: true }); + } 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/scripts/check-reachability.ts b/scripts/check-reachability.ts index 1905163d..838eb4e6 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/index.tsx", 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/workit-claude-code/hooks.test.ts b/test/workit-claude-code/hooks.test.ts new file mode 100644 index 00000000..ffbb197b --- /dev/null +++ b/test/workit-claude-code/hooks.test.ts @@ -0,0 +1,138 @@ +// 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, mkdtempSync, readFileSync, readdirSync, rmSync } 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 after it changed or the session restarted", () => { + 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; + // No task bound: nothing to inject. + expect(turn()).toEqual({}); + startTask(cwd, { host: "claude_code", actor: "claude-session-1" }, "per-turn task"); + expect(turn().hookSpecificOutput?.additionalContext).toContain("per-turn task"); + expect(turn()).toEqual({}); + // A new session start (or compaction) clears the per-session cache. + runHook( + PLUGIN_DIR, + fixture("claude-code", "session-start-compact", cwd, { source: "startup" }), + env, + ); + expect(turn().hookSpecificOutput?.additionalContext).toContain("per-turn task"); +}); + +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"); + 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/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-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 From 2caa7f2f9aceaf7c771325d7132f4f58de372551 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:09:03 -0300 Subject: [PATCH 02/21] feat(claude-code): publish the plugin through a git-hosted marketplace The repository root becomes a Claude Code marketplace (.claude-plugin/marketplace.json, name `workit`) whose single entry installs the published @brainervirus/workit-claude-code npm package, so `claude plugin marketplace add BrainerVirus/workit` + `claude plugin install workit@workit` always gets the latest release. The entry pins no version: plugin.json carries it. Release wiring follows the other adapters: a semantic-release npm bumper, RELEASE_PACKAGES (path-gated analysis and selective publish), the workspace-dep rewrite (now also devDependencies, which this fully bundled package uses), and manifest sync for package.json and .claude-plugin/plugin.json, including the release workflow's sync list. CI's PR gate installs the Claude Code CLI pinned in the support matrix (2.1.288) and runs `claude plugin validate --strict` on the plugin and the root marketplace; the packaging tier validates and installs the packed tarball through a local marketplace in an isolated config dir. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 21 +++ .github/workflows/ci.yml | 18 ++ .github/workflows/release.yml | 2 +- .../scripts/analyze-release-scope.ts | 1 + .../scripts/rewrite-workspace-deps.ts | 16 +- .../scripts/sync-release-manifests.ts | 4 + .../workit-core/src/core/support-matrix.ts | 4 + release.config.cjs | 1 + scripts/test.ts | 1 + test/artifacts/package-contents.test.ts | 3 +- test/artifacts/phase-0-candidate.test.ts | 3 +- test/artifacts/release-candidate.test.ts | 4 +- test/artifacts/release-orchestration.test.ts | 3 +- test/artifacts/reliability-report.test.ts | 12 +- test/shared/helpers/packages.ts | 2 + test/workit-claude-code/packed-plugin.test.ts | 147 ++++++++++++++++ test/workit-claude-code/plugin.test.ts | 165 ++++++++++++++++++ .../workit-core/analyze-release-scope.test.ts | 1 + .../rewrite-workspace-deps.test.ts | 26 +++ .../sync-release-manifests.test.ts | 4 + 20 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 test/workit-claude-code/packed-plugin.test.ts create mode 100644 test/workit-claude-code/plugin.test.ts 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/release.yml b/.github/workflows/release.yml index 20e302e6..7c9d37ee 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/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index 031dcd12..cdec92a9 100644 --- a/packages/workit-core/scripts/analyze-release-scope.ts +++ b/packages/workit-core/scripts/analyze-release-scope.ts @@ -15,6 +15,7 @@ export const RELEASE_PACKAGES = [ "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ] as const; /** The release pipeline's own version-sync commit: never a release trigger. */ 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..3cadc19b 100644 --- a/packages/workit-core/scripts/sync-release-manifests.ts +++ b/packages/workit-core/scripts/sync-release-manifests.ts @@ -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/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/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/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/shared/helpers/packages.ts b/test/shared/helpers/packages.ts index 867541f7..adfd4e22 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; @@ -82,6 +83,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/packed-plugin.test.ts b/test/workit-claude-code/packed-plugin.test.ts new file mode 100644 index 00000000..56c08443 --- /dev/null +++ b/test/workit-claude-code/packed-plugin.test.ts @@ -0,0 +1,147 @@ +// 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 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"), + ); + expect(manifest.version).toBe(pkg.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"), ["--help"], { + 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); + expect(installs[0].version).toBe( + JSON.parse(readFileSync(path.join(plugin, "package.json"), "utf8")).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.test.ts b/test/workit-claude-code/plugin.test.ts new file mode 100644 index 00000000..87df94a4 --- /dev/null +++ b/test/workit-claude-code/plugin.test.ts @@ -0,0 +1,165 @@ +// 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 { readFileSync, readdirSync } from "node:fs"; +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 { installedPlugin, PLUGIN_DIR } from "./plugin-helpers"; + +const REPO = path.resolve(PLUGIN_DIR, "..", ".."); +const json = (file: string) => JSON.parse(readFileSync(file, "utf8")); + +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 tracks the released version and the canonical repository", () => { + const manifest = json(path.join(PLUGIN_DIR, ".claude-plugin", "plugin.json")); + const pkg = json(path.join(PLUGIN_DIR, "package.json")); + const core = json(path.join(REPO, "packages", "workit-core", "package.json")); + expect(manifest.name).toBe("workit"); + expect(manifest.version).toBe(core.version); + expect(pkg.version).toBe(core.version); + 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( + [ + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "SubagentStart", + "SubagentStop", + "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"); + expect(hooks.PreToolUse.map((group) => [group.matcher, group.hooks[0].if])).toEqual([ + ["Bash", "Bash(git *)"], + ["PowerShell", "PowerShell(git *)"], + ]); + expect(hooks.PostToolUse.map((group) => [group.matcher, group.hooks[0].if])).toEqual([ + ["Bash", "Bash(workit *)"], + ]); +}); + +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 resolves the monorepo source; the installed layout resolves dist/", + () => { + const trace = (dir: string) => + spawnSync(path.join(dir, "bin", "workit"), ["--help"], { + 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|index\.tsx)/, + ); + expect(pinned.stdout).toContain("workit"); + 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).toContain("workit"); + }, + 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-core/analyze-release-scope.test.ts b/test/workit-core/analyze-release-scope.test.ts index 18e72abb..1dd40fe7 100644 --- a/test/workit-core/analyze-release-scope.test.ts +++ b/test/workit-core/analyze-release-scope.test.ts @@ -183,6 +183,7 @@ describe("analyzeReleaseScope", () => { "workit-cursor", "workit-codex", "workit-pi", + "workit-claude-code", ], }); } finally { 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", From c8366d181d90187348921571c62c48f7df9dfc73 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:09:03 -0300 Subject: [PATCH 03/21] feat(claude-code): detect, install and doctor Claude Code from the CLI - Host detection: `claude` on PATH (or a version-manager/user bin dir) marks Claude Code detected; a Workit entry in Claude's plugin registry (/plugins/installed_plugins.json) whose install dir carries the canonical manifest marks it configured. - The init wizard lists Claude Code; setup apply runs the native plan (node check, marketplace list, marketplace add BrainerVirus/workit when missing, plugin install workit@workit --scope user) and verifies the registry afterwards. Upgrade mode refreshes the marketplace and runs `claude plugin update workit@workit`. - `workit doctor` adds `claude_plugin`: a warning when an installed plugin is behind the published package (stale_install) or differs from the running workit CLI, with the native update command as the fix. Co-Authored-By: Claude Opus 5.5 --- packages/workit-cli/src/steps.tsx | 1 + packages/workit-core/src/core/detect-hosts.ts | 12 +- packages/workit-core/src/core/doctor.ts | 57 +++++ packages/workit-core/src/core/host-install.ts | 109 ++++++++- packages/workit-core/src/core/setup.ts | 42 ++-- test/workit-cli/wizard-config.test.ts | 2 + test/workit-core/claude-code-host.test.ts | 230 ++++++++++++++++++ test/workit-core/install-scripts.test.ts | 1 + 8 files changed, 435 insertions(+), 19 deletions(-) create mode 100644 test/workit-core/claude-code-host.test.ts 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/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 + * and skew from the `workit` CLI running this doctor. Both are warnings: the + * plugin keeps working, and 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 } => { + const installs = claudeWorkitInstalls(res.home, res.env); + if (installs.length === 0) + return { + id: "claude_plugin", + status: "pass", + detail: "no Workit Claude Code plugin install recorded — skipping", + }; + const fix = (id: string) => + `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && claude plugin update ${id}`; + const cli = readJson(path.join(packageRoot(), "package.json"))?.version; + 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)"}`; + if (latest && install.version && versionBehind(install.version, latest)) { + problems.push(`stale_install: ${label} is behind published ${latest}`); + repair ??= fix(install.id); + } + if (typeof cli === "string" && install.version && install.version !== cli) { + problems.push(`${label} differs from this workit CLI ${cli}`); + repair ??= fix(install.id); + } + } + 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..e4686252 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,73 @@ 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 ClaudeWorkitInstall = { id: string; version: string | null; installPath: string }; + +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. + * A `--plugin-dir` local pin is per-session and never appears here. + */ +export const claudeWorkitInstalls = ( + home: string, + env: NodeJS.ProcessEnv = process.env, +): 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 }>) { + if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath)) + continue; + found.push({ + id, + installPath: entry.installPath, + version: + typeof entry.version === "string" && /^\d+\.\d+\.\d+/.test(entry.version) + ? entry.version + : null, + }); + } + } + return found; +}; + +export const isClaudeWorkitInstalled = ( + home: string, + env: NodeJS.ProcessEnv = process.env, +): boolean => claudeWorkitInstalls(home, env).length > 0; + export function installedHostApp( host: HostId, home: string, @@ -297,6 +370,39 @@ export function planHostInstall( }, ]; } + if (host === "claude-code") { + if (!upgrading && isClaudeWorkitInstalled(home, env)) 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 +546,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..f8c2034a 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) + : true; if (!verified) { entries.push({ platform: mutation.platform, 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/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts new file mode 100644 index 00000000..b4bbe9c1 --- /dev/null +++ b/test/workit-core/claude-code-host.test.ts @@ -0,0 +1,230 @@ +// 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"; + +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 } = {}, +) => { + 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: "user", 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 }); + } +}); + +test("doctor warns on a stale or skewed Claude Code plugin install with the native update command", () => { + const home = temp("workit-claude-doctor-"); + try { + const check = (env: NodeJS.ProcessEnv) => + runDoctor({ 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}`, + ); + expect(stale.detail).toContain(`differs from this workit CLI ${CORE_VERSION}`); + expect(stale.fix).toBe( + "claude plugin marketplace update workit && claude plugin update workit@workit", + ); + } finally { + rmSync(home, { 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" From 08113cdb048df58b5bb891895c147c612063dd28 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:09:03 -0300 Subject: [PATCH 04/21] test(claude-code): add an opt-in `claude plugin eval` suite Three cases against a scaffolded git repo on a protected main: session-context (SessionStart injects the workit contract), branch-policy-deny (a Bash `git checkout -b main` is denied by the hook), and skill-triggers (an independent-review request loads the review skill). claude-eval.yml runs them nightly, on the `eval` label, or by dispatch with the pinned CLI and ANTHROPIC_API_KEY, capped at $2; it is never a PR gate (credential, cost, nondeterminism). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/claude-eval.yml | 84 +++++++++++++++++++ .../evals/branch-policy-deny/case.yaml | 24 ++++++ .../evals/scaffold/git-repo.sh | 13 +++ .../evals/session-context/case.yaml | 24 ++++++ .../evals/skill-triggers/case.yaml | 23 +++++ 5 files changed, 168 insertions(+) create mode 100644 .github/workflows/claude-eval.yml create mode 100644 packages/workit-claude-code/evals/branch-policy-deny/case.yaml create mode 100755 packages/workit-claude-code/evals/scaffold/git-repo.sh create mode 100644 packages/workit-claude-code/evals/session-context/case.yaml create mode 100644 packages/workit-claude-code/evals/skill-triggers/case.yaml 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/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. From 964b4a14d19668ed6acbe2ea93693fa883dc5463 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:09:03 -0300 Subject: [PATCH 05/21] docs(claude-code): document both Claude Code install modes README: latest published (git-hosted marketplace with the npm source, native update) and the local pin (`claude --plugin-dir` or CLAUDE_CODE_PLUGIN_DIRS, sources run with bun, only skills generated), plus what the plugin ships and its host surface limits. AGENTS.md notes the Claude Code adapter contract. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 12 +++++++-- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 24d1ae16..0bf2b80b 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 da0a81d5..55f5f5f4 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,61 @@ 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 the installed plugin is behind the +published package or differs from the `workit` CLI you run. + +**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) 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; `PreToolUse` on `Bash`/`PowerShell` `git *` commands denies + protected or non-compliant branch operations (it never answers `allow`, so + your permission prompts stay in charge); `PreCompact` warns that context + may be stale; subagent, post-tool and stop hooks observe only; +- 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 @@ -328,6 +384,20 @@ 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"`. `SubagentStart` can only +add context, so subagents are observed as read-only/agent-guided. Implementer +worktrees are created by Claude with its own branch names; the implementer +agent switches to a policy-compliant branch before committing. + +
+
Pi @@ -511,7 +581,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 @@ -525,7 +595,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 ``` From 4c87c4fb0a18729a696fd8992b1c73b86522888a Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:17:36 -0300 Subject: [PATCH 06/21] test(claude-code): give the full-doctor claude_plugin test a CI-sized timeout Each runDoctor call runs every check (runtime, identity and lock probes), which exceeded bun's 5 s default on the Ubuntu runner. Co-Authored-By: Claude Opus 5.5 --- test/workit-core/claude-code-host.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/workit-core/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts index b4bbe9c1..19446754 100644 --- a/test/workit-core/claude-code-host.test.ts +++ b/test/workit-core/claude-code-host.test.ts @@ -203,6 +203,8 @@ test("setup apply reports Claude Code installed only when the plugin registry sh } }); +// Each call runs the whole doctor (runtime, identity and lock probes too), +// which takes seconds on a CI runner. test("doctor warns on a stale or skewed Claude Code plugin install with the native update command", () => { const home = temp("workit-claude-doctor-"); try { @@ -227,4 +229,4 @@ test("doctor warns on a stale or skewed Claude Code plugin install with the nati } finally { rmSync(home, { recursive: true, force: true }); } -}); +}, 30_000); From 4a57a6b9dd9ec2558898a1db6600c4a76534825f Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:55:34 -0300 Subject: [PATCH 07/21] fix(claude-code): register only effective hooks; harden the launcher and per-turn cache - hooks.json: SessionStart also matches fork; Stop, SubagentStop, PostToolUse and PreCompact are no longer registered (no-ops today, and SessionStart compact restores context), halving hook processes per turn. - PreToolUse denies stay structured JSON (permissionDecision deny, exit 0, empty stderr), now asserted for both runtimes. Claude Code 2.1.288 folds a JSON deny into a blocking error internally, so its UI prints "PreToolUse:Bash hook error: " for it too. - The launcher fails open on an unloadable dist/ ({} + one stderr line). - SessionStart seeds the per-session turn cache with the context it just injected, so the next prompt does not resend it; caches untouched for 7 days are pruned. - bin/workit and the build use workit-cli/src/main.ts (S9a landed); bin/workit --version now resolves the source on a local pin. Co-Authored-By: Claude Opus 5.5 --- packages/workit-claude-code/README.md | 4 +- packages/workit-claude-code/bin/workit | 8 +- .../workit-claude-code/bin/workit-hook.mjs | 18 ++-- packages/workit-claude-code/hooks/hooks.json | 54 +----------- packages/workit-claude-code/scripts/build.ts | 8 +- packages/workit-claude-code/src/hook.ts | 68 +++++++++++++-- test/workit-claude-code/hooks.test.ts | 83 +++++++++++++++++-- test/workit-claude-code/packed-plugin.test.ts | 2 +- test/workit-claude-code/plugin.test.ts | 32 +++---- 9 files changed, 167 insertions(+), 110 deletions(-) diff --git a/packages/workit-claude-code/README.md b/packages/workit-claude-code/README.md index 0d826a48..bf1902fa 100644 --- a/packages/workit-claude-code/README.md +++ b/packages/workit-claude-code/README.md @@ -36,8 +36,8 @@ export CLAUDE_CODE_PLUGIN_DIRS="$HOME/path/to/workit/packages/workit-claude-code | Path | Purpose | | --- | --- | | `.claude-plugin/plugin.json` | Manifest (version synced at release) | -| `hooks/hooks.json` | SessionStart, UserPromptSubmit, PreToolUse (Bash/PowerShell `git *`), PostToolUse, SubagentStart/Stop, PreCompact, Stop | -| `bin/workit-hook.mjs` | Hook launcher: source with Bun in a checkout, `dist/workit-hook.js` when installed | +| `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) | diff --git a/packages/workit-claude-code/bin/workit b/packages/workit-claude-code/bin/workit index 36bacb3e..78f7f61f 100755 --- a/packages/workit-claude-code/bin/workit +++ b/packages/workit-claude-code/bin/workit @@ -4,15 +4,13 @@ # 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. -# TODO(S9a, PR #163): once packages/workit-cli/src/main.ts lands on main, drop -# the index.tsx fallback below. set -e here=$(dirname "$0") root=$(cd "$here/.." && pwd) -cli="$root/../workit-cli/src" +main="$root/../workit-cli/src/main.ts" entry="" -if [ "${WORKIT_CLAUDE_RUNTIME:-}" != "dist" ] && [ -f "$root/../workit-core/src/core.ts" ]; then - if [ -f "$cli/main.ts" ]; then entry="$cli/main.ts"; elif [ -f "$cli/index.tsx" ]; then entry="$cli/index.tsx"; fi +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 diff --git a/packages/workit-claude-code/bin/workit-hook.mjs b/packages/workit-claude-code/bin/workit-hook.mjs index 3a857f29..0ac11bf7 100755 --- a/packages/workit-claude-code/bin/workit-hook.mjs +++ b/packages/workit-claude-code/bin/workit-hook.mjs @@ -5,9 +5,12 @@ // 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. A launcher failure fails open -// (an empty decision plus a stderr diagnostic): a broken hook must never -// brick the host. +// 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"; @@ -36,8 +39,13 @@ if (fromSource) { if (run.error) failOpen(`bun is required for the local pin (${run.error.message})`); else process.exitCode = run.status ?? 0; } else if (existsSync(dist)) { - const { runClaudeHook } = await import(pathToFileURL(dist).href); - process.exitCode = await runClaudeHook(process.stdin, process.stdout); + 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/hooks/hooks.json b/packages/workit-claude-code/hooks/hooks.json index ad7f1d6d..16296ea0 100644 --- a/packages/workit-claude-code/hooks/hooks.json +++ b/packages/workit-claude-code/hooks/hooks.json @@ -1,9 +1,9 @@ { - "description": "Workit host hooks: task context on session start and per turn, branch policy on git shell commands, subagent and stop observation.", + "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", + "matcher": "startup|resume|clear|compact|fork", "hooks": [ { "type": "command", @@ -52,20 +52,6 @@ ] } ], - "PostToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "if": "Bash(workit *)", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], - "timeout": 5 - } - ] - } - ], "SubagentStart": [ { "hooks": [ @@ -77,42 +63,6 @@ } ] } - ], - "SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], - "timeout": 5 - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], - "timeout": 5 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/bin/workit-hook.mjs"], - "timeout": 5 - } - ] - } ] } } diff --git a/packages/workit-claude-code/scripts/build.ts b/packages/workit-claude-code/scripts/build.ts index 959f00e3..f02386c4 100644 --- a/packages/workit-claude-code/scripts/build.ts +++ b/packages/workit-claude-code/scripts/build.ts @@ -92,13 +92,7 @@ if (!skillsOnly) { 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); - // TODO(S9a, PR #163): bundle workit-cli/src/main.ts unconditionally once it - // is on main; until then the current index.tsx entry is the CLI. - const cliSrc = path.join(packagesDir, "workit-cli", "src"); - const cliEntry = existsSync(path.join(cliSrc, "main.ts")) - ? path.join(cliSrc, "main.ts") - : path.join(cliSrc, "index.tsx"); - bundle(cliEntry, path.join(dist, "workit.js")); + 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"); diff --git a/packages/workit-claude-code/src/hook.ts b/packages/workit-claude-code/src/hook.ts index 4b7dbfea..c89acca4 100644 --- a/packages/workit-claude-code/src/hook.ts +++ b/packages/workit-claude-code/src/hook.ts @@ -8,7 +8,15 @@ // 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, rmSync, writeFileSync } from "node:fs"; +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"; @@ -54,6 +62,17 @@ const exportSessionEnv = (env: NodeJS.ProcessEnv, sessionId: string): void => { } }; +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 @@ -61,20 +80,54 @@ const exportSessionEnv = (env: NodeJS.ProcessEnv, sessionId: string): void => { */ const unchangedTurnContext = (file: string | null, context: string): boolean => { if (!file) return false; - const digest = createHash("sha256").update(context).digest("hex"); + 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 { - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, `${JSON.stringify({ digest })}\n`); + 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 { - // Unwritable plugin data dir: context is simply sent every turn. + // No cache dir yet. } - return false; +}; + +/** + * 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. */ @@ -104,8 +157,7 @@ export async function runClaudeHook( const cache = contextCache(env, sessionId); if (payload.hook_event_name === "SessionStart") { exportSessionEnv(env, sessionId); - // A fresh or compacted conversation no longer holds the old context. - if (cache) rmSync(cache, { force: true }); + 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; diff --git a/test/workit-claude-code/hooks.test.ts b/test/workit-claude-code/hooks.test.ts index ffbb197b..32fd1887 100644 --- a/test/workit-claude-code/hooks.test.ts +++ b/test/workit-claude-code/hooks.test.ts @@ -4,7 +4,17 @@ // 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, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -94,25 +104,73 @@ test("given SessionStart source compact, context is restored with the Claude add ); }); -test("given an unchanged task, UserPromptSubmit re-injects context only after it changed or the session restarted", () => { +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({}); - // A new session start (or compaction) clears the per-session cache. - runHook( - PLUGIN_DIR, - fixture("claude-code", "session-start-compact", cwd, { source: "startup" }), - env, - ); - expect(turn().hookSpecificOutput?.additionalContext).toContain("per-turn task"); + // 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", "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"); + } + for (const agent of ["workit:reviewer", "workit:verifier", "Explore"]) + 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", () => { @@ -128,6 +186,13 @@ test("a malformed payload or a missing bundle fails open with an empty decision 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"), diff --git a/test/workit-claude-code/packed-plugin.test.ts b/test/workit-claude-code/packed-plugin.test.ts index 56c08443..630dcdeb 100644 --- a/test/workit-claude-code/packed-plugin.test.ts +++ b/test/workit-claude-code/packed-plugin.test.ts @@ -88,7 +88,7 @@ test( expect(JSON.stringify(run.json)).toContain('"permissionDecision":"deny"'); }); if (process.platform !== "win32") { - const cli = spawnSync(path.join(plugin, "bin", "workit"), ["--help"], { + const cli = spawnSync(path.join(plugin, "bin", "workit"), ["--version"], { encoding: "utf8", env: { ...process.env, WORKIT_SHIM_TRACE: "1" }, timeout: 60_000, diff --git a/test/workit-claude-code/plugin.test.ts b/test/workit-claude-code/plugin.test.ts index 87df94a4..bd2006c6 100644 --- a/test/workit-claude-code/plugin.test.ts +++ b/test/workit-claude-code/plugin.test.ts @@ -10,6 +10,7 @@ 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"); @@ -59,16 +60,10 @@ test("hooks.json registers the designed events through the exec-form launcher, n }> >; expect(Object.keys(hooks).toSorted()).toEqual( - [ - "PostToolUse", - "PreCompact", - "PreToolUse", - "SessionStart", - "Stop", - "SubagentStart", - "SubagentStop", - "UserPromptSubmit", - ].toSorted(), + // 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) @@ -77,14 +72,11 @@ test("hooks.json registers the designed events through the exec-form launcher, n 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"); + 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 *)"], ]); - expect(hooks.PostToolUse.map((group) => [group.matcher, group.hooks[0].if])).toEqual([ - ["Bash", "Bash(workit *)"], - ]); }); test("agents: verifier and reviewer are read-only, implementer runs in an isolated worktree", () => { @@ -132,24 +124,22 @@ test("skills are generated from workit-core, namespaced without the workit- pref }); test.skipIf(process.platform === "win32")( - "given the local pin, bin/workit resolves the monorepo source; the installed layout resolves dist/", + "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"), ["--help"], { + 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|index\.tsx)/, - ); - expect(pinned.stdout).toContain("workit"); + 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).toContain("workit"); + expect(installed.stdout.trim()).toBe(CLI_VERSION); }, 90_000, ); From 23b6f742622e2a5924f373487fa3ff4cb2a6f7e3 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:55:34 -0300 Subject: [PATCH 08/21] fix(core): give the Claude Code worktree implementer write guidance on SubagentStart SubagentStart told every Claude Code subagent it was read-only, including the plugin's worktree-isolated implementer. On claude_code the text is now keyed on agent_type: (workit:)implementer is told it may edit and commit in its own worktree after switching to a policy-compliant branch; every other agent type and every other host keeps the read-only text. Co-Authored-By: Claude Opus 5.5 --- packages/workit-core/src/hooks/handle.ts | 18 ++++++++++--- test/workit-core/hooks/claude-code.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/workit-core/src/hooks/handle.ts b/packages/workit-core/src/hooks/handle.ts index b47f1c3e..32ee91ec 100644 --- a/packages/workit-core/src/hooks/handle.ts +++ b/packages/workit-core/src/hooks/handle.ts @@ -12,6 +12,19 @@ const NONE: HookDecision = { kind: "none" }; /** Sessions already offered unfinished tasks in this process. */ const offered = new Set(); +/** Claude Code's worktree-isolated implementer (`implementer`, or the + * plugin-namespaced `workit:implementer`) is the one subagent that writes. */ +const CLAUDE_WORKTREE_IMPLEMENTER = /^(?:[\w-]+:)?implementer$/; + +const subagentStartText = ( + host: HookInput["host"], + descriptor: HostDescriptor, + event: Extract, +): string => + host === "claude_code" && CLAUDE_WORKTREE_IMPLEMENTER.test(event.agentType) + ? `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 +49,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/test/workit-core/hooks/claude-code.test.ts b/test/workit-core/hooks/claude-code.test.ts index 5b4b3c81..3e5c7a10 100644 --- a/test/workit-core/hooks/claude-code.test.ts +++ b/test/workit-core/hooks/claude-code.test.ts @@ -165,3 +165,33 @@ 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"); + // 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 }); + } +}); From 91bcc0c879b5ec439d46feb94c38bcf2f98187ed Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:55:34 -0300 Subject: [PATCH 09/21] fix(release): republish bundling packages and attempt every publish - BUNDLED_SOURCES: workit-claude-code bundles core and the CLI sources, workit-pi bundles core, and neither declares them as runtime deps, so a change under packages/workit-core/ (or packages/workit-cli/src/ for claude-code) now marks them changed in release analysis and selective publish. - publish-changed-packages attempts every changed package even after a failure (e.g. the first publish of a new npm name the token cannot create), then throws one summary listing failed and published packages. - doctor's claude_plugin warns only when a newer plugin version is published; an older plugin than the CLI is normal when its payload did not change. Co-Authored-By: Claude Opus 5.5 --- .../scripts/analyze-release-scope.ts | 26 +++++- .../scripts/publish-changed-packages.ts | 35 ++++---- packages/workit-core/src/core/doctor.ts | 13 ++- .../workit-core/analyze-release-scope.test.ts | 25 ++++-- test/workit-core/claude-code-host.test.ts | 77 ++++++++++++++++- .../publish-changed-packages.test.ts | 86 ++++++++++++------- 6 files changed, 194 insertions(+), 68 deletions(-) diff --git a/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index cdec92a9..d0f26409 100644 --- a/packages/workit-core/scripts/analyze-release-scope.ts +++ b/packages/workit-core/scripts/analyze-release-scope.ts @@ -18,6 +18,25 @@ export const RELEASE_PACKAGES = [ "workit-claude-code", ] as const; +type ReleasePackage = (typeof RELEASE_PACKAGES)[number]; + +/** + * Sources a package's published dist/ bundles from OUTSIDE its own directory. + * These packages ship no runtime dependency on what they bundle, so a change + * there must republish them or installs keep the old bundled code. (The + * other adapters also bundle core but declare it as a runtime dependency.) + */ +export const BUNDLED_SOURCES: Partial> = { + "workit-claude-code": ["packages/workit-core/", "packages/workit-cli/src/"], + "workit-pi": ["packages/workit-core/"], +}; + +/** 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/; @@ -91,10 +110,9 @@ export function analyzeReleaseScope(root = process.cwd()): { 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 (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/publish-changed-packages.ts b/packages/workit-core/scripts/publish-changed-packages.ts index aec95a43..14428636 100644 --- a/packages/workit-core/scripts/publish-changed-packages.ts +++ b/packages/workit-core/scripts/publish-changed-packages.ts @@ -4,7 +4,7 @@ // 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, payloadPaths, RELEASE_PACKAGES } from "./analyze-release-scope"; const git = (root: string, args: string[]): string => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); @@ -13,7 +13,8 @@ export function changedPackages(root: string, fromTag: string): string[] { // Committed state only: ..HEAD, never the working tree — unreviewed // local edits must not decide what ships. return RELEASE_PACKAGES.filter((pkg) => { - const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", `packages/${pkg}`]); + // 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 +39,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 +58,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/src/core/doctor.ts b/packages/workit-core/src/core/doctor.ts index c18bf55c..b390485a 100644 --- a/packages/workit-core/src/core/doctor.ts +++ b/packages/workit-core/src/core/doctor.ts @@ -1746,9 +1746,11 @@ const versionBehind = (version: string, latest: string): boolean => /** * 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 - * and skew from the `workit` CLI running this doctor. Both are warnings: the - * plugin keeps working, and the fix is one native update. A `--plugin-dir` + * 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 } => { @@ -1761,7 +1763,6 @@ const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: bool }; const fix = (id: string) => `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && claude plugin update ${id}`; - const cli = readJson(path.join(packageRoot(), "package.json"))?.version; const latest = registryLatestVersion(res, CLAUDE_PLUGIN_PACKAGE); const problems: string[] = []; let repair: string | undefined; @@ -1771,10 +1772,6 @@ const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: bool problems.push(`stale_install: ${label} is behind published ${latest}`); repair ??= fix(install.id); } - if (typeof cli === "string" && install.version && install.version !== cli) { - problems.push(`${label} differs from this workit CLI ${cli}`); - repair ??= fix(install.id); - } } const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION; if (problems.length === 0) diff --git a/test/workit-core/analyze-release-scope.test.ts b/test/workit-core/analyze-release-scope.test.ts index 1dd40fe7..8ef7d8a0 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(); } @@ -204,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(); } @@ -217,7 +226,10 @@ 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", + productPkgs: ["workit-core", "workit-pi", "workit-claude-code"], + }); } finally { r.cleanup(); } @@ -242,7 +254,10 @@ describe("analyzeReleaseScope", () => { 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/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts index 19446754..acdf7e8b 100644 --- a/test/workit-core/claude-code-host.test.ts +++ b/test/workit-core/claude-code-host.test.ts @@ -14,6 +14,7 @@ import { runHostInstall, } from "@/packages/workit-core/src/core/host-install"; import { applySetupPreview, buildSetupPreview } from "@/packages/workit-core/src/core/setup"; +import { applyUninstall, planUninstall } from "@/packages/workit-core/src/core/uninstall"; const temp = (prefix: string) => mkdtempSync(path.join(os.tmpdir(), prefix)); const executable = (file: string) => { @@ -205,7 +206,7 @@ test("setup apply reports Claude Code installed only when the plugin registry sh // Each call runs the whole doctor (runtime, identity and lock probes too), // which takes seconds on a CI runner. -test("doctor warns on a stale or skewed Claude Code plugin install with the native update command", () => { +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) => @@ -222,7 +223,9 @@ test("doctor warns on a stale or skewed Claude Code plugin install with the nati expect(stale.detail).toContain( `stale_install: workit@workit 0.0.1 is behind published ${CORE_VERSION}`, ); - expect(stale.detail).toContain(`differs from this workit CLI ${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", ); @@ -230,3 +233,73 @@ test("doctor warns on a stale or skewed Claude Code plugin install with the nati rmSync(home, { recursive: true, force: true }); } }, 30_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"], + detail: "claude plugin uninstall workit@workit", + }), + ]); + 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"]]); + 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 }); + } +}); diff --git a/test/workit-core/publish-changed-packages.test.ts b/test/workit-core/publish-changed-packages.test.ts index cb4582a6..8feb887e 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-mcp/src/i.ts", "c\n"); + expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-mcp"]); } 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-mcp/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-mcp"]); } 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,27 +96,52 @@ 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 or CLI sources they bundle change", () => { const r = repo(); try { r.change("packages/workit-core/src/i.ts", "c\n"); + expect(changedPackages(r.root, "v0.8.10")).toEqual([ + "workit-core", + "workit-pi", + "workit-claude-code", + ]); + 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"]); + } finally { + r2.cleanup(); + } + } 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"); + r.change("packages/workit-claude-code/src/i.ts", "c\n"); const log = spyOn(console, "log"); const ran: string[] = []; let err: unknown; @@ -126,24 +149,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-claude-code"]); + const message = (err as Error).message; + expect(message).toContain("publish failed for 1 package(s): workit-opencode"); + expect(message).toContain("published: workit-mcp, workit-claude-code"); + 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 +199,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-mcp/src/i.ts", "c\n"); execFileSync("git", ["tag", "v0.9.0"], { cwd: r.root }); const calls: string[] = []; const result = publishChanged({ @@ -189,11 +209,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-mcp"]); + expect(result.skipped).toEqual(RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-mcp")); 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-mcp")}`, ); } finally { r.cleanup(); @@ -202,7 +222,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-mcp/src/i.ts", "c\n"); let ran = 0; const result = publishChanged({ root: r.root, @@ -211,7 +231,7 @@ describe("publishChanged", () => { ran++; }, }); - expect(result.published).toEqual(["workit-cli"]); + expect(result.published).toEqual(["workit-mcp"]); expect(ran).toBe(0); } finally { r.cleanup(); From 163734f7da800b47e9e90ea2db8dd4832f537e86 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:55:34 -0300 Subject: [PATCH 10/21] feat(claude-code): uninstall Claude Code through the native plugin command workit uninstall plans one reviewed `claude plugin uninstall workit@` per recorded Workit install and runs exactly that argv on apply (anything else is refused; an already-removed install is skipped). The wizard lists Claude Code. Co-Authored-By: Claude Opus 5.5 --- packages/workit-cli/src/index.tsx | 1 + packages/workit-core/src/core/uninstall.ts | 78 ++++++++++++++++++++-- test/workit-core/uninstall.test.ts | 8 ++- 3 files changed, 82 insertions(+), 5 deletions(-) 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-core/src/core/uninstall.ts b/packages/workit-core/src/core/uninstall.ts index 54da7365..3c2ed45e 100644 --- a/packages/workit-core/src/core/uninstall.ts +++ b/packages/workit-core/src/core/uninstall.ts @@ -10,12 +10,20 @@ 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[] }; export type UninstallHostPlan = { host: UninstallHost; @@ -49,6 +57,8 @@ export type UninstallPaths = { cursorSettings?: string; cursorMcp?: string; cursorPluginDir?: string; + /** Injectable host-command runner for host-native uninstall actions. */ + runHostCommand?: HostCommandRunner; }; type ResolvedUninstall = { @@ -321,7 +331,23 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan { : [], }; - return { hosts: [opencode, cursor, codex, pi] }; + // Claude Code: one native `claude plugin uninstall ` per recorded + // Workit install (any marketplace). A --plugin-dir pin is never recorded. + const home = paths.home ?? paths.env?.HOME ?? os.homedir(); + const claudeInstalls = claudeWorkitInstalls(home, paths.env ?? process.env); + 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}`, + command: "claude" as const, + args: ["plugin", "uninstall", install.id], + })), + }; + + return { hosts: [opencode, cursor, codex, pi, claude] }; } // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved @@ -390,6 +416,46 @@ 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, ...rest] = action.args; + if ( + action.command !== "claude" || + verb !== "plugin" || + sub !== "uninstall" || + !CLAUDE_PLUGIN_ID.test(id ?? "") || + rest.length > 0 + ) + 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; + if (!claudeWorkitInstalls(home, env).some((install) => install.id === id)) + return { status: "skipped", detail: `${id} 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, + purpose: `Uninstall the Workit Claude Code plugin ${id}`, + }; + 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 +466,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/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([ From 5bfdadf101980c32215eb6e3548c227b52b6ca49 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 19:55:34 -0300 Subject: [PATCH 11/21] docs(claude-code): document registered hooks, fail-open and uninstall Co-Authored-By: Claude Opus 5.5 --- README.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 55f5f5f4..be5e68aa 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,9 @@ claude plugin install workit@workit claude plugin marketplace update workit && claude plugin update workit@workit ``` -`workit doctor` warns (`claude_plugin`) when the installed plugin is behind the -published package or differs from the `workit` CLI you run. +`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@` for each Workit install. **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, @@ -209,13 +210,20 @@ Bun on `PATH`; an installed plugin needs only Node.js 24+. The plugin ships: -- hooks: `SessionStart` (startup/resume/clear/compact) injects the Workit +- 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; `PreToolUse` on `Bash`/`PowerShell` `git *` commands denies - protected or non-compliant branch operations (it never answers `allow`, so - your permission prompts stay in charge); `PreCompact` warns that context - may be stale; subagent, post-tool and stop hooks observe only; + 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` @@ -391,10 +399,11 @@ 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"`. `SubagentStart` can only -add context, so subagents are observed as read-only/agent-guided. Implementer -worktrees are created by Claude with its own branch names; the implementer -agent switches to a policy-compliant branch before committing. +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.
From ceb987a114ea6e2ff815bfecf3617cc6f535a721 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 20:34:36 -0300 Subject: [PATCH 12/21] fix(release): republish every adapter whose dist inlines core, MCP or CLI sources Every adapter build is a no-external bun build, so a runtime dependency on core does not reach the shipped bundle. Bundle metafiles show: mcp, cli, opencode and pi inline core; cursor and codex inline core and the MCP sources; claude-code inlines core and the CLI (src/ and package.json). BUNDLED_SOURCES now lists all of them, and a metafile test fails when a build entry inlines a workspace source missing from its package's payload paths. Co-Authored-By: Claude Opus 5.5 --- .../scripts/analyze-release-scope.ts | 24 +++++-- .../workit-core/analyze-release-scope.test.ts | 12 +++- test/workit-core/bundled-sources.test.ts | 67 +++++++++++++++++++ .../publish-changed-packages.test.ts | 42 ++++++------ 4 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 test/workit-core/bundled-sources.test.ts diff --git a/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index d0f26409..3f828df3 100644 --- a/packages/workit-core/scripts/analyze-release-scope.ts +++ b/packages/workit-core/scripts/analyze-release-scope.ts @@ -21,14 +21,26 @@ export const RELEASE_PACKAGES = [ type ReleasePackage = (typeof RELEASE_PACKAGES)[number]; /** - * Sources a package's published dist/ bundles from OUTSIDE its own directory. - * These packages ship no runtime dependency on what they bundle, so a change - * there must republish them or installs keep the old bundled code. (The - * other adapters also bundle core but declare it as a runtime dependency.) + * 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/"; +const MCP_SRC = "packages/workit-mcp/src/"; +// The bundled CLI also inlines its package.json (`workit --version`). +const CLI = ["packages/workit-cli/src/", "packages/workit-cli/package.json"]; export const BUNDLED_SOURCES: Partial> = { - "workit-claude-code": ["packages/workit-core/", "packages/workit-cli/src/"], - "workit-pi": ["packages/workit-core/"], + "workit-mcp": [CORE], + "workit-cli": [CORE], + "workit-opencode": [CORE], + "workit-cursor": [CORE, MCP_SRC], + "workit-codex": [CORE, MCP_SRC], + "workit-pi": [CORE], + "workit-claude-code": [CORE, ...CLI], }; /** Every repository path whose change alters `pkg`'s published payload. */ diff --git a/test/workit-core/analyze-release-scope.test.ts b/test/workit-core/analyze-release-scope.test.ts index 8ef7d8a0..6c346632 100644 --- a/test/workit-core/analyze-release-scope.test.ts +++ b/test/workit-core/analyze-release-scope.test.ts @@ -228,7 +228,17 @@ describe("analyzeReleaseScope", () => { }); expect(analyzeReleaseScope(r.root)).toEqual({ level: "patch", - productPkgs: ["workit-core", "workit-pi", "workit-claude-code"], + // 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(); diff --git a/test/workit-core/bundled-sources.test.ts b/test/workit-core/bundled-sources.test.ts new file mode 100644 index 00000000..4bd63d1f --- /dev/null +++ b/test/workit-core/bundled-sources.test.ts @@ -0,0 +1,67 @@ +// 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. +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_SOURCES, + payloadPaths, + RELEASE_PACKAGES, +} from "@/packages/workit-core/scripts/analyze-release-scope"; + +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[] = []; + 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("/"); + if (!rel.startsWith("packages/") || rel.includes("node_modules/")) continue; + if (!covered.some((prefix) => rel.startsWith(prefix))) uncovered.push(`${pkg}: ${rel}`); + } + } + } + expect(uncovered).toEqual([]); + } 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/publish-changed-packages.test.ts b/test/workit-core/publish-changed-packages.test.ts index 8feb887e..6855fbef 100644 --- a/test/workit-core/publish-changed-packages.test.ts +++ b/test/workit-core/publish-changed-packages.test.ts @@ -45,8 +45,8 @@ describe("changedPackages", () => { test("lists only packages with payload diffs", () => { const r = repo(); try { - r.change("packages/workit-mcp/src/i.ts", "c\n"); - expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-mcp"]); + r.change("packages/workit-opencode/src/i.ts", "c\n"); + expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-opencode"]); } finally { r.cleanup(); } @@ -54,9 +54,9 @@ describe("changedPackages", () => { test("uncommitted working-tree edits are never counted (B1)", () => { const r = repo(); try { - r.change("packages/workit-mcp/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-mcp"]); + expect(changedPackages(r.root, "v0.8.10")).toEqual(["workit-opencode"]); } finally { r.cleanup(); } @@ -116,19 +116,24 @@ describe("publishChanged", () => { r.cleanup(); } }); - test("bundling packages republish when the core or CLI sources they bundle change", () => { + 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"); - expect(changedPackages(r.root, "v0.8.10")).toEqual([ - "workit-core", - "workit-pi", - "workit-claude-code", - ]); + // 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(); } @@ -141,7 +146,6 @@ describe("publishChanged", () => { try { r.change("packages/workit-mcp/src/i.ts", "c\n"); r.change("packages/workit-opencode/src/i.ts", "c\n"); - r.change("packages/workit-claude-code/src/i.ts", "c\n"); const log = spyOn(console, "log"); const ran: string[] = []; let err: unknown; @@ -156,10 +160,10 @@ describe("publishChanged", () => { } catch (e) { err = e; } - expect(ran).toEqual(["workit-mcp", "workit-opencode", "workit-claude-code"]); + 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-claude-code"); + 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"); @@ -199,7 +203,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-mcp/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({ @@ -209,11 +213,11 @@ describe("publishChanged", () => { calls.push(`${args.join(" ")} @ ${opts.cwd}`); }, }); - expect(result.published).toEqual(["workit-mcp"]); - expect(result.skipped).toEqual(RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-mcp")); + 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-mcp")}`, + `publish --access public @ ${path.join(r.root, "packages/workit-opencode")}`, ); } finally { r.cleanup(); @@ -222,7 +226,7 @@ describe("publishChanged", () => { test("dryRun records without invoking npm", () => { const r = repo(); try { - r.change("packages/workit-mcp/src/i.ts", "c\n"); + r.change("packages/workit-opencode/src/i.ts", "c\n"); let ran = 0; const result = publishChanged({ root: r.root, @@ -231,7 +235,7 @@ describe("publishChanged", () => { ran++; }, }); - expect(result.published).toEqual(["workit-mcp"]); + expect(result.published).toEqual(["workit-opencode"]); expect(ran).toBe(0); } finally { r.cleanup(); From 6e17704fbb35d6d17c14f8f295d6e19ecc4ea9f6 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 22:38:12 -0300 Subject: [PATCH 13/21] chore(claude-code): align the plugin version with the 2.2.0 release Co-Authored-By: Claude Opus 5.5 --- packages/workit-claude-code/.claude-plugin/plugin.json | 2 +- packages/workit-claude-code/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workit-claude-code/.claude-plugin/plugin.json b/packages/workit-claude-code/.claude-plugin/plugin.json index 85c82929..6bd8af68 100644 --- a/packages/workit-claude-code/.claude-plugin/plugin.json +++ b/packages/workit-claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "workit", "displayName": "Workit", - "version": "2.1.5", + "version": "2.2.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" diff --git a/packages/workit-claude-code/package.json b/packages/workit-claude-code/package.json index f7b7f363..9348cc0a 100644 --- a/packages/workit-claude-code/package.json +++ b/packages/workit-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@brainervirus/workit-claude-code", - "version": "2.1.5", + "version": "2.2.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": [ From 31bbdd4293ad5263a47acdb1ab88df24ab7f4814 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 22:38:12 -0300 Subject: [PATCH 14/21] fix(release): count the lockfile and inlined manifests as bundled payload Bundles inline third-party code (zod, the MCP SDK, ink/react) at the versions bun.lock resolves, and the CLI/MCP manifests that declare them. BUNDLED_SOURCES now adds bun.lock to every adapter, the MCP package.json to cursor and codex, and the CLI package.json to claude-code (core's package.json is under its directory prefix). The metafile guard fails when a bundle inlines node_modules code without bun.lock in its payload paths. Co-Authored-By: Claude Opus 5.5 --- .../scripts/analyze-release-scope.ts | 27 +++++++++++-------- test/workit-core/bundled-sources.test.ts | 10 +++++-- .../publish-changed-packages.test.ts | 11 ++++++++ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index 3f828df3..ee04ccd6 100644 --- a/packages/workit-core/scripts/analyze-release-scope.ts +++ b/packages/workit-core/scripts/analyze-release-scope.ts @@ -29,18 +29,23 @@ type ReleasePackage = (typeof RELEASE_PACKAGES)[number]; * against the build entries' metafiles by * test/workit-core/bundled-sources.test.ts. */ -const CORE = "packages/workit-core/"; -const MCP_SRC = "packages/workit-mcp/src/"; -// The bundled CLI also inlines its package.json (`workit --version`). +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"]; +// Third-party code (zod, the MCP SDK, ink/react) is inlined at the version +// the lockfile resolves, so a lockfile change can change every bundle. +const LOCK = "bun.lock"; export const BUNDLED_SOURCES: Partial> = { - "workit-mcp": [CORE], - "workit-cli": [CORE], - "workit-opencode": [CORE], - "workit-cursor": [CORE, MCP_SRC], - "workit-codex": [CORE, MCP_SRC], - "workit-pi": [CORE], - "workit-claude-code": [CORE, ...CLI], + "workit-mcp": [CORE, LOCK], + "workit-cli": [CORE, LOCK], + "workit-opencode": [CORE, LOCK], + "workit-cursor": [CORE, ...MCP, LOCK], + "workit-codex": [CORE, ...MCP, LOCK], + "workit-pi": [CORE, LOCK], + "workit-claude-code": [CORE, ...CLI, LOCK], }; /** Every repository path whose change alters `pkg`'s published payload. */ @@ -116,7 +121,7 @@ export function analyzeReleaseScope(root = process.cwd()): { 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; const lvl = subjectLevel(message); diff --git a/test/workit-core/bundled-sources.test.ts b/test/workit-core/bundled-sources.test.ts index 4bd63d1f..87b647cf 100644 --- a/test/workit-core/bundled-sources.test.ts +++ b/test/workit-core/bundled-sources.test.ts @@ -1,7 +1,8 @@ // 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. +// or a change there would ship without republishing the bundle. Inlined +// third-party modules require bun.lock in the payload paths. import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; @@ -50,7 +51,12 @@ test("every workspace source a dist/ inlines is in its package's payload paths", 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("/"); - if (!rel.startsWith("packages/") || rel.includes("node_modules/")) continue; + // Inlined third-party code is pinned by the lockfile. + if (rel.includes("node_modules/")) { + if (!covered.includes("bun.lock")) uncovered.push(`${pkg}: ${rel} (needs bun.lock)`); + continue; + } + if (!rel.startsWith("packages/")) continue; if (!covered.some((prefix) => rel.startsWith(prefix))) uncovered.push(`${pkg}: ${rel}`); } } diff --git a/test/workit-core/publish-changed-packages.test.ts b/test/workit-core/publish-changed-packages.test.ts index 6855fbef..b41c49e5 100644 --- a/test/workit-core/publish-changed-packages.test.ts +++ b/test/workit-core/publish-changed-packages.test.ts @@ -141,6 +141,17 @@ describe("publishChanged", () => { r.cleanup(); } }); + test("a lockfile change republishes every package that inlines third-party code", () => { + const r = repo(); + try { + r.change("bun.lock", "{}\n"); + expect(changedPackages(r.root, "v0.8.10")).toEqual( + RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-core"), + ); + } finally { + r.cleanup(); + } + }); test("a publish failure does not stop the others; failures throw at the end with a summary", () => { const r = repo(); try { From df0a743dfa85cca3d1fc5a6a46afc0ab70fbc70d Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 22:38:12 -0300 Subject: [PATCH 15/21] fix(core): grant worktree write guidance only to workit:implementer Claude namespaces plugin agents, so only the exact workit:implementer is the Workit plugin's worktree agent; a bare or another plugin's implementer keeps the read-only text. Co-Authored-By: Claude Opus 5.5 --- packages/workit-core/src/hooks/handle.ts | 9 +++++---- test/workit-claude-code/hooks.test.ts | 12 ++++++++++-- test/workit-core/hooks/claude-code.test.ts | 2 ++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/workit-core/src/hooks/handle.ts b/packages/workit-core/src/hooks/handle.ts index 32ee91ec..482860b5 100644 --- a/packages/workit-core/src/hooks/handle.ts +++ b/packages/workit-core/src/hooks/handle.ts @@ -12,16 +12,17 @@ const NONE: HookDecision = { kind: "none" }; /** Sessions already offered unfinished tasks in this process. */ const offered = new Set(); -/** Claude Code's worktree-isolated implementer (`implementer`, or the - * plugin-namespaced `workit:implementer`) is the one subagent that writes. */ -const CLAUDE_WORKTREE_IMPLEMENTER = /^(?:[\w-]+:)?implementer$/; +/** 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" && CLAUDE_WORKTREE_IMPLEMENTER.test(event.agentType) + 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.`; diff --git a/test/workit-claude-code/hooks.test.ts b/test/workit-claude-code/hooks.test.ts index 32fd1887..5ca9b02a 100644 --- a/test/workit-claude-code/hooks.test.ts +++ b/test/workit-claude-code/hooks.test.ts @@ -163,13 +163,21 @@ test("SubagentStart gives the worktree implementer write guidance and keeps othe runHook(PLUGIN_DIR, fixture("claude-code", "subagent-start", cwd, { agent_type })) .json as Specific ).hookSpecificOutput?.additionalContext ?? ""; - for (const agent of ["workit:implementer", "implementer"]) { + 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"); } - for (const agent of ["workit:reviewer", "workit:verifier", "Explore"]) + // 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"); }); diff --git a/test/workit-core/hooks/claude-code.test.ts b/test/workit-core/hooks/claude-code.test.ts index 3e5c7a10..1b99f15b 100644 --- a/test/workit-core/hooks/claude-code.test.ts +++ b/test/workit-core/hooks/claude-code.test.ts @@ -181,6 +181,8 @@ test("Claude SubagentStart keys its text on agent_type: only the worktree implem 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( From 8673c67deb565a3e31a76edbd61a87915abc487f Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 22:38:12 -0300 Subject: [PATCH 16/21] fix(claude-code): scope-aware Claude Code detection, doctor and uninstall The plugin registry records user, project and local installs (project and local with their projectPath). Only installs Claude loads where workit runs now count: user scope always, project/local only inside their project; setup's already-installed check and post-install verification look at user scope only. Doctor's update fix carries --scope and the project. Uninstall plans `claude plugin uninstall --scope `, running project/local ones from their project, and refuses any other argv shape or a scope/cwd mismatch. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- packages/workit-core/src/core/doctor.ts | 19 ++-- packages/workit-core/src/core/host-install.ts | 50 +++++++-- packages/workit-core/src/core/setup.ts | 2 +- packages/workit-core/src/core/uninstall.ts | 55 ++++++++-- test/workit-core/claude-code-host.test.ts | 100 ++++++++++++++++-- 6 files changed, 198 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 63b92a13..e47c9557 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,9 @@ 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@` for each Workit install. +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, diff --git a/packages/workit-core/src/core/doctor.ts b/packages/workit-core/src/core/doctor.ts index b390485a..361f412c 100644 --- a/packages/workit-core/src/core/doctor.ts +++ b/packages/workit-core/src/core/doctor.ts @@ -33,7 +33,11 @@ import { isWorkitPlugin, } from "./registration"; import { readWorkspacesResult, resolveWorkspaceFrom } from "./workspaces"; -import { CLAUDE_MARKETPLACE_NAME, claudeWorkitInstalls } from "./host-install"; +import { + CLAUDE_MARKETPLACE_NAME, + claudeWorkitInstalls, + type ClaudeWorkitInstall, +} from "./host-install"; import { validateCursorSkills, WORKIT_METHOD_SKILLS } from "./skill-manifests"; import { classifyHostGeneration, @@ -1754,23 +1758,26 @@ const versionBehind = (version: string, latest: string): boolean => * local pin is per-session, never recorded, and never checked here. */ const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => { - const installs = claudeWorkitInstalls(res.home, res.env); + // 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 = (id: string) => - `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && claude plugin update ${id}`; + 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)"}`; + 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.id); + repair ??= fix(install); } } const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION; diff --git a/packages/workit-core/src/core/host-install.ts b/packages/workit-core/src/core/host-install.ts index e4686252..fe80b627 100644 --- a/packages/workit-core/src/core/host-install.ts +++ b/packages/workit-core/src/core/host-install.ts @@ -234,7 +234,26 @@ export const isCodexWorkitInstalled = ( export const claudeConfigDir = (home: string, env: NodeJS.ProcessEnv = process.env): string => env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"); -export type ClaudeWorkitInstall = { id: string; version: string | null; installPath: string }; +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 { @@ -257,11 +276,14 @@ const isCanonicalClaudePlugin = (root: string): boolean => { * (`/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. - * A `--plugin-dir` local pin is per-session and never appears here. + * 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; @@ -276,26 +298,40 @@ export const claudeWorkitInstalls = ( 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 }>) { + for (const entry of entries as Array<{ + installPath?: unknown; + version?: unknown; + scope?: unknown; + projectPath?: unknown; + }>) { if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath)) continue; - found.push({ + // 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, -): boolean => claudeWorkitInstalls(home, env).length > 0; + cwd: string | null = process.cwd(), +): boolean => claudeWorkitInstalls(home, env, cwd).length > 0; export function installedHostApp( host: HostId, @@ -371,7 +407,7 @@ export function planHostInstall( ]; } if (host === "claude-code") { - if (!upgrading && isClaudeWorkitInstalled(home, env)) return []; + if (!upgrading && isClaudeWorkitInstalled(home, env, null)) return []; const base = hostCommand("claude", [], { home, cwd, env }); return [ nodePrerequisite({ home, cwd, env }), diff --git a/packages/workit-core/src/core/setup.ts b/packages/workit-core/src/core/setup.ts index f8c2034a..e01c2031 100644 --- a/packages/workit-core/src/core/setup.ts +++ b/packages/workit-core/src/core/setup.ts @@ -1303,7 +1303,7 @@ export function applySetupPreview( : mutation.platform === "pi" ? isPiWorkitInstalled(res.home, res.env) : mutation.platform === "claude-code" - ? isClaudeWorkitInstalled(res.home, res.env) + ? isClaudeWorkitInstalled(res.home, res.env, null) : true; if (!verified) { entries.push({ diff --git a/packages/workit-core/src/core/uninstall.ts b/packages/workit-core/src/core/uninstall.ts index 3c2ed45e..0c3db989 100644 --- a/packages/workit-core/src/core/uninstall.ts +++ b/packages/workit-core/src/core/uninstall.ts @@ -23,7 +23,15 @@ export type UninstallAction = | { kind: "edit-json-remove"; 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[] }; + | { + 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; @@ -59,6 +67,8 @@ export type UninstallPaths = { 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 = { @@ -331,19 +341,28 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan { : [], }; - // Claude Code: one native `claude plugin uninstall ` per recorded - // Workit install (any marketplace). A --plugin-dir pin is never recorded. + // 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); + 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}`, + detail: `claude plugin uninstall ${install.id} --scope ${install.scope}${ + install.projectPath ? ` (in ${install.projectPath})` : "" + }`, command: "claude" as const, - args: ["plugin", "uninstall", install.id], + args: ["plugin", "uninstall", install.id, "--scope", install.scope], + cwd: install.projectPath, })), }; @@ -424,13 +443,18 @@ const applyClaudeUninstall = ( action: Extract, paths: UninstallPaths, ): { status: UninstallResultStatus; detail?: string } => { - const [verb, sub, id, ...rest] = action.args; + 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 ?? "") || - rest.length > 0 + 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", @@ -438,15 +462,24 @@ const applyClaudeUninstall = ( }; const home = paths.home ?? paths.env?.HOME ?? os.homedir(); const env = paths.env ?? process.env; - if (!claudeWorkitInstalls(home, env).some((install) => install.id === id)) - return { status: "skipped", detail: `${id} is no longer installed` }; + 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, - purpose: `Uninstall the Workit Claude Code plugin ${id}`, + ...(action.cwd === null ? {} : { cwd: action.cwd }), + purpose: `Uninstall the Workit Claude Code plugin ${id} (${scope} scope)`, }; const result = paths.runHostCommand ? paths.runHostCommand(step) diff --git a/test/workit-core/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts index acdf7e8b..5e1fb147 100644 --- a/test/workit-core/claude-code-host.test.ts +++ b/test/workit-core/claude-code-host.test.ts @@ -14,7 +14,11 @@ import { runHostInstall, } from "@/packages/workit-core/src/core/host-install"; import { applySetupPreview, buildSetupPreview } from "@/packages/workit-core/src/core/setup"; -import { applyUninstall, planUninstall } from "@/packages/workit-core/src/core/uninstall"; +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) => { @@ -29,7 +33,14 @@ const CORE_VERSION = JSON.parse( /** 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 } = {}, + 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"; @@ -53,7 +64,16 @@ const recordInstall = ( path.join(configDir, "plugins", "installed_plugins.json"), JSON.stringify({ version: 2, - plugins: { [id]: [{ scope: "user", installPath, version }] }, + plugins: { + [id]: [ + { + scope: options.scope ?? "user", + ...(options.projectPath ? { projectPath: options.projectPath } : {}), + installPath, + version, + }, + ], + }, }), ); return installPath; @@ -251,8 +271,9 @@ test("uninstall previews a native `claude plugin uninstall` per Workit install a expect.objectContaining({ kind: "host-command", command: "claude", - args: ["plugin", "uninstall", "workit@workit"], - detail: "claude plugin uninstall workit@workit", + args: ["plugin", "uninstall", "workit@workit", "--scope", "user"], + cwd: null, + detail: "claude plugin uninstall workit@workit --scope user", }), ]); const ran: string[][] = []; @@ -266,7 +287,7 @@ test("uninstall previews a native `claude plugin uninstall` per Workit install a return { exitCode: 0, stdout: "", stderr: "" }; }, }); - expect(ran).toEqual([["plugin", "uninstall", "workit@workit"]]); + expect(ran).toEqual([["plugin", "uninstall", "workit@workit", "--scope", "user"]]); expect(result.entries).toEqual([ expect.objectContaining({ host: "claude-code", status: "removed" }), ]); @@ -303,3 +324,70 @@ test("uninstall previews a native `claude plugin uninstall` per Workit install a 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 }); + } +}); From 94216070d392f437cfeb2a862ab24331046b9923 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 22:49:46 -0300 Subject: [PATCH 17/21] test(claude-code): isolate and time-box the full-doctor claude_plugin test for Windows Four full runDoctor calls exceeded 30 s on the Windows runner; run them from the isolated home and allow 120 s. Co-Authored-By: Claude Opus 5.5 --- test/workit-core/claude-code-host.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/workit-core/claude-code-host.test.ts b/test/workit-core/claude-code-host.test.ts index 5e1fb147..7925a3d7 100644 --- a/test/workit-core/claude-code-host.test.ts +++ b/test/workit-core/claude-code-host.test.ts @@ -225,12 +225,13 @@ test("setup apply reports Claude Code installed only when the plugin registry sh }); // Each call runs the whole doctor (runtime, identity and lock probes too), -// which takes seconds on a CI runner. +// 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) => - runDoctor({ home, env: { HOME: home, ...env } }).checks.find( + // 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"); @@ -252,7 +253,7 @@ test("doctor warns only when a newer Claude Code plugin version is published wit } finally { rmSync(home, { recursive: true, force: true }); } -}, 30_000); +}, 120_000); test("uninstall previews a native `claude plugin uninstall` per Workit install and runs only that argv", () => { const home = temp("workit-claude-uninstall-"); From ca12d64e5c41eb16ea227ed01e92774bfa99de9a Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 23:12:48 -0300 Subject: [PATCH 18/21] fix(release): republish on inlined dependency versions, not any lockfile change Dev-tooling-only bun.lock bumps (e.g. oxlint) must not release. Instead of treating the whole lockfile as payload, scripts/bundled-deps.json lists the bun.lock keys of the third-party packages each dist/ inlines (from the bundle metafiles; nested copies such as @opencode-ai/plugin/zod included). Release analysis (per commit, against its first parent) and selective publish (previous tag vs HEAD) mark a package changed only when one of its inlined packages resolves to a different version. The bundled-sources guard now requires the inventory to equal what the bundles inline. Co-Authored-By: Claude Opus 5.5 --- .../scripts/analyze-release-scope.ts | 83 +++++++++-- .../workit-core/scripts/bundled-deps.json | 133 ++++++++++++++++++ .../scripts/publish-changed-packages.ts | 11 +- .../workit-core/analyze-release-scope.test.ts | 31 ++++ test/workit-core/bundled-sources.test.ts | 32 ++++- .../publish-changed-packages.test.ts | 20 ++- 6 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 packages/workit-core/scripts/bundled-deps.json diff --git a/packages/workit-core/scripts/analyze-release-scope.ts b/packages/workit-core/scripts/analyze-release-scope.ts index ee04ccd6..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", @@ -35,17 +36,62 @@ 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"]; -// Third-party code (zod, the MCP SDK, ink/react) is inlined at the version -// the lockfile resolves, so a lockfile change can change every bundle. -const LOCK = "bun.lock"; export const BUNDLED_SOURCES: Partial> = { - "workit-mcp": [CORE, LOCK], - "workit-cli": [CORE, LOCK], - "workit-opencode": [CORE, LOCK], - "workit-cursor": [CORE, ...MCP, LOCK], - "workit-codex": [CORE, ...MCP, LOCK], - "workit-pi": [CORE, LOCK], - "workit-claude-code": [CORE, ...CLI, LOCK], + "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. */ @@ -93,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") @@ -117,18 +167,23 @@ 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) => 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 pkg of RELEASE_PACKAGES) - if (touched.some((f) => payloadPaths(pkg).some((prefix) => f.startsWith(prefix)))) + 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] }; 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 14428636..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, payloadPaths, 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,7 +17,11 @@ 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) => { + 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 !== ""; diff --git a/test/workit-core/analyze-release-scope.test.ts b/test/workit-core/analyze-release-scope.test.ts index 6c346632..457b693d 100644 --- a/test/workit-core/analyze-release-scope.test.ts +++ b/test/workit-core/analyze-release-scope.test.ts @@ -259,6 +259,37 @@ 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"); diff --git a/test/workit-core/bundled-sources.test.ts b/test/workit-core/bundled-sources.test.ts index 87b647cf..9726bc2d 100644 --- a/test/workit-core/bundled-sources.test.ts +++ b/test/workit-core/bundled-sources.test.ts @@ -2,18 +2,31 @@ // 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 modules require bun.lock in the payload paths. +// 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). @@ -31,6 +44,7 @@ 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`); @@ -51,9 +65,11 @@ test("every workspace source a dist/ inlines is in its package's payload paths", 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 is pinned by the lockfile. + // Inlined third-party code: record its bun.lock key + // (node_modules chain, e.g. `@opencode-ai/plugin/zod`). if (rel.includes("node_modules/")) { - if (!covered.includes("bun.lock")) uncovered.push(`${pkg}: ${rel} (needs bun.lock)`); + inlined[pkg] ??= new Set(); + inlined[pkg].add(lockKey(rel)); continue; } if (!rel.startsWith("packages/")) continue; @@ -62,6 +78,16 @@ test("every workspace source a dist/ inlines is in its package's payload paths", } } 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 }); } diff --git a/test/workit-core/publish-changed-packages.test.ts b/test/workit-core/publish-changed-packages.test.ts index b41c49e5..457d9e1e 100644 --- a/test/workit-core/publish-changed-packages.test.ts +++ b/test/workit-core/publish-changed-packages.test.ts @@ -141,13 +141,27 @@ describe("publishChanged", () => { r.cleanup(); } }); - test("a lockfile change republishes every package that inlines third-party code", () => { + 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", "{}\n"); - expect(changedPackages(r.root, "v0.8.10")).toEqual( + 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(); } From d6c4e73c269168dd0198dd2a17239d76566ae36c Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 23:24:45 -0300 Subject: [PATCH 19/21] chore(claude-code): align the plugin version with the 2.2.1 release Co-Authored-By: Claude Opus 5.5 --- packages/workit-claude-code/.claude-plugin/plugin.json | 2 +- packages/workit-claude-code/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workit-claude-code/.claude-plugin/plugin.json b/packages/workit-claude-code/.claude-plugin/plugin.json index 6bd8af68..55445d4f 100644 --- a/packages/workit-claude-code/.claude-plugin/plugin.json +++ b/packages/workit-claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "workit", "displayName": "Workit", - "version": "2.2.0", + "version": "2.2.1", "description": "Workflow rails for agentic coding: task context on every session, branch policy on shell commands, and the workit method skills", "author": { "name": "BrainerVirus" diff --git a/packages/workit-claude-code/package.json b/packages/workit-claude-code/package.json index 9348cc0a..223dff42 100644 --- a/packages/workit-claude-code/package.json +++ b/packages/workit-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@brainervirus/workit-claude-code", - "version": "2.2.0", + "version": "2.2.1", "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": [ From c6756364d3a1b5eb49d1b14287356bb93bdc89e4 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 23:33:57 -0300 Subject: [PATCH 20/21] test(claude-code): check plugin version lockstep via the manifest sync, not the release Comparing the plugin version with the current core version broke whenever main released before this branch merged, because CI tests the merge with main. The test now asserts plugin.json equals the plugin package.json, that SYNC_MANIFEST_PATHS lists both files, and that syncManifests rewrites both in a copied tree. Co-Authored-By: Claude Opus 5.5 --- .../scripts/sync-release-manifests.ts | 2 +- test/workit-claude-code/plugin.test.ts | 33 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/workit-core/scripts/sync-release-manifests.ts b/packages/workit-core/scripts/sync-release-manifests.ts index 3cadc19b..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", diff --git a/test/workit-claude-code/plugin.test.ts b/test/workit-claude-code/plugin.test.ts index bd2006c6..9c27df5c 100644 --- a/test/workit-claude-code/plugin.test.ts +++ b/test/workit-claude-code/plugin.test.ts @@ -2,10 +2,15 @@ // generated skills, and the source/dist resolution of the local pin. import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { readFileSync, readdirSync } from "node:fs"; +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, "..", ".."); @@ -24,13 +29,31 @@ const frontmatter = (file: string): Record => { ); }; -test("the plugin manifest tracks the released version and the canonical repository", () => { +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")); - const core = json(path.join(REPO, "packages", "workit-core", "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.version).toBe(core.version); - expect(pkg.version).toBe(core.version); 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"]) From 99d5cda2f9ce1e22f1d8909ba691e2e5436a5d31 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sun, 4 Oct 2026 02:41:45 -0300 Subject: [PATCH 21/21] test(claude-code): derive packed plugin versions from the tree The packaging tests compared the packed plugin.json with the packed package.json, but the release rewrite mirrors the tree's core version into plugin.json only, so every release on main broke them. They now expect the tree's core version (what the rewrite writes) for the packed manifest and for Claude's recorded install. Plugin manifests aligned to 2.4.0. Co-Authored-By: Claude Opus 5.5 --- .../workit-claude-code/.claude-plugin/plugin.json | 2 +- packages/workit-claude-code/package.json | 2 +- test/workit-claude-code/packed-plugin.test.ts | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/workit-claude-code/.claude-plugin/plugin.json b/packages/workit-claude-code/.claude-plugin/plugin.json index 55445d4f..186317e2 100644 --- a/packages/workit-claude-code/.claude-plugin/plugin.json +++ b/packages/workit-claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "workit", "displayName": "Workit", - "version": "2.2.1", + "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" diff --git a/packages/workit-claude-code/package.json b/packages/workit-claude-code/package.json index 223dff42..5dc9af64 100644 --- a/packages/workit-claude-code/package.json +++ b/packages/workit-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@brainervirus/workit-claude-code", - "version": "2.2.1", + "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": [ diff --git a/test/workit-claude-code/packed-plugin.test.ts b/test/workit-claude-code/packed-plugin.test.ts index 630dcdeb..3213d3ba 100644 --- a/test/workit-claude-code/packed-plugin.test.ts +++ b/test/workit-claude-code/packed-plugin.test.ts @@ -20,6 +20,9 @@ import { 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 }); @@ -71,7 +74,12 @@ test( const manifest = JSON.parse( readFileSync(path.join(pluginFromTarball(), ".claude-plugin", "plugin.json"), "utf8"), ); - expect(manifest.version).toBe(pkg.version); + // 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 }, ); @@ -138,9 +146,11 @@ test.skipIf(claude === null)( 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, "package.json"), "utf8")).version, + 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 },