@brainervirus/workit-core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -6,6 +6,7 @@
6
6
  // them; merge-backs and the release's own manifest sync never do.
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { resolve } from "node:path";
9
+ import BUNDLED_DEPS_JSON from "./bundled-deps.json" with { type: "json" };
9
10
 
10
11
  export const RELEASE_PACKAGES = [
11
12
  "workit-core",
@@ -15,8 +16,90 @@ export const RELEASE_PACKAGES = [
15
16
  "workit-cursor",
16
17
  "workit-codex",
17
18
  "workit-pi",
19
+ "workit-claude-code",
18
20
  ] as const;
19
21
 
22
+ type ReleasePackage = (typeof RELEASE_PACKAGES)[number];
23
+
24
+ /**
25
+ * Sources a package's published dist/ inlines from OUTSIDE its own directory.
26
+ * Every adapter build is a no-external `bun build`, so whatever it imports
27
+ * from another workspace package is copied into its bundle: a runtime
28
+ * dependency on that package does not reach the shipped code. A change in
29
+ * these sources must therefore republish the bundling package too. Verified
30
+ * against the build entries' metafiles by
31
+ * test/workit-core/bundled-sources.test.ts.
32
+ */
33
+ const CORE = "packages/workit-core/"; // sources, skills, templates and package.json
34
+ // Cursor and Codex inline the MCP transport and its declared dependencies.
35
+ const MCP = ["packages/workit-mcp/src/", "packages/workit-mcp/package.json"];
36
+ // The bundled CLI inlines its sources, package.json (`workit --version`) and
37
+ // its third-party dependencies.
38
+ const CLI = ["packages/workit-cli/src/", "packages/workit-cli/package.json"];
39
+ export const BUNDLED_SOURCES: Partial<Record<ReleasePackage, readonly string[]>> = {
40
+ "workit-mcp": [CORE],
41
+ "workit-cli": [CORE],
42
+ "workit-opencode": [CORE],
43
+ "workit-cursor": [CORE, ...MCP],
44
+ "workit-codex": [CORE, ...MCP],
45
+ "workit-pi": [CORE],
46
+ "workit-claude-code": [CORE, ...CLI],
47
+ };
48
+
49
+ /**
50
+ * Third-party packages each dist/ inlines, as bun.lock `packages` keys
51
+ * (`zod`, or `@opencode-ai/plugin/zod` for a nested copy). A lockfile change
52
+ * republishes a package only when one of these resolves to a different
53
+ * version, so dev-tooling bumps never release. Kept in sync with the bundle
54
+ * metafiles by test/workit-core/bundled-sources.test.ts.
55
+ */
56
+ export const BUNDLED_DEPS = BUNDLED_DEPS_JSON as Partial<Record<ReleasePackage, string[]>>;
57
+
58
+ const LOCKFILE = "bun.lock";
59
+
60
+ /** `packages` key → resolved `name@version` from a bun.lock text (JSONC). */
61
+ export const lockResolutions = (text: string): Map<string, string> => {
62
+ // bun.lock is JSON with trailing commas.
63
+ const parsed = JSON.parse(text.replace(/,(\s*[}\]])/g, "$1")) as {
64
+ packages?: Record<string, unknown>;
65
+ };
66
+ const resolved = new Map<string, string>();
67
+ for (const [key, entry] of Object.entries(parsed.packages ?? {}))
68
+ if (Array.isArray(entry) && typeof entry[0] === "string") resolved.set(key, entry[0]);
69
+ return resolved;
70
+ };
71
+
72
+ const lockAt = (root: string, rev: string): Map<string, string> | null => {
73
+ try {
74
+ return lockResolutions(
75
+ execFileSync("git", ["show", `${rev}:${LOCKFILE}`], {
76
+ cwd: root,
77
+ encoding: "utf8",
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ maxBuffer: 64 * 1024 * 1024,
80
+ }),
81
+ );
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
86
+
87
+ /** Packages whose inlined third-party resolutions differ between two revisions. */
88
+ export const lockChangedPackages = (root: string, from: string, to: string): ReleasePackage[] => {
89
+ const before = lockAt(root, from);
90
+ const after = lockAt(root, to);
91
+ if (before === null && after === null) return [];
92
+ return RELEASE_PACKAGES.filter((pkg) =>
93
+ (BUNDLED_DEPS[pkg] ?? []).some((key) => before?.get(key) !== after?.get(key)),
94
+ );
95
+ };
96
+
97
+ /** Every repository path whose change alters `pkg`'s published payload. */
98
+ export const payloadPaths = (pkg: ReleasePackage): string[] => [
99
+ `packages/${pkg}/`,
100
+ ...(BUNDLED_SOURCES[pkg] ?? []),
101
+ ];
102
+
20
103
  /** The release pipeline's own version-sync commit: never a release trigger. */
21
104
  const RELEASE_SYNC = /^chore\(release\): sync manifests\b/;
22
105
 
@@ -56,11 +139,15 @@ const subjectLevel = (commit: string): Level | null => {
56
139
  // combined diff drops files identical to either parent — e.g. hotfix-branch
57
140
  // back-merges), and -z returns raw NUL-delimited paths so spaces/non-ASCII
58
141
  // are never C-quoted. NUL is fine in captured output, never in argv.
59
- const commitsSince = (root: string, from: string): { message: string; files: string[] }[] => {
142
+ const commitsSince = (
143
+ root: string,
144
+ from: string,
145
+ ): { hash: string; message: string; files: string[] }[] => {
60
146
  const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`])
61
147
  .split("\n")
62
148
  .filter(Boolean);
63
149
  return hashes.map((h) => ({
150
+ hash: h,
64
151
  message: g(root, ["show", "-s", "--format=%B", h]),
65
152
  files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h])
66
153
  .split("\0")
@@ -80,20 +167,24 @@ export function analyzeReleaseScope(root = process.cwd()): {
80
167
  const levels: Level[] = [];
81
168
  const pkgs = new Set<string>();
82
169
  let payloadOnly = false;
83
- for (const { message, files } of commits) {
170
+ for (const { hash, message, files } of commits) {
84
171
  const subject = (message.split("\n")[0] ?? "").trim();
85
172
  if (RELEASE_SYNC.test(subject)) continue;
86
173
  const touched = files.filter((f) =>
87
- RELEASE_PACKAGES.some((p) => f.startsWith(`packages/${p}/`)),
174
+ RELEASE_PACKAGES.some((p) => payloadPaths(p).some((prefix) => f.startsWith(prefix))),
88
175
  );
89
- if (touched.length === 0) continue;
176
+ // A lockfile edit counts only for packages whose inlined deps moved.
177
+ const relocked = files.includes(LOCKFILE) ? lockChangedPackages(root, `${hash}^`, hash) : [];
178
+ if (touched.length === 0 && relocked.length === 0) continue;
90
179
  const lvl = subjectLevel(message);
91
180
  if (lvl) levels.push(lvl);
92
181
  else if (!subject.startsWith("Merge ")) payloadOnly = true;
93
- for (const f of touched) {
94
- const pkg = RELEASE_PACKAGES.find((p) => f.startsWith(`packages/${p}/`));
95
- if (pkg) pkgs.add(pkg);
96
- }
182
+ for (const pkg of RELEASE_PACKAGES)
183
+ if (
184
+ relocked.includes(pkg) ||
185
+ touched.some((f) => payloadPaths(pkg).some((prefix) => f.startsWith(prefix)))
186
+ )
187
+ pkgs.add(pkg);
97
188
  }
98
189
  if (levels.length === 0) return { level: payloadOnly ? "patch" : null, productPkgs: [...pkgs] };
99
190
  const level = levels.reduce<Level>(
@@ -0,0 +1,133 @@
1
+ {
2
+ "workit-mcp": [
3
+ "@modelcontextprotocol/sdk",
4
+ "@openclaw/fs-safe",
5
+ "ajv",
6
+ "ajv-formats",
7
+ "fast-deep-equal",
8
+ "fast-uri",
9
+ "json-schema-traverse",
10
+ "zod",
11
+ "zod-to-json-schema"
12
+ ],
13
+ "workit-cli": [
14
+ "@alcalzone/ansi-tokenize",
15
+ "@inkjs/ui",
16
+ "@openclaw/fs-safe",
17
+ "ansi-escapes",
18
+ "ansi-regex",
19
+ "ansi-styles",
20
+ "auto-bind",
21
+ "chalk",
22
+ "cli-boxes",
23
+ "cli-cursor",
24
+ "cli-truncate",
25
+ "code-excerpt",
26
+ "convert-to-spaces",
27
+ "deepmerge",
28
+ "environment",
29
+ "es-toolkit",
30
+ "escape-string-regexp",
31
+ "figures",
32
+ "get-east-asian-width",
33
+ "indent-string",
34
+ "ink",
35
+ "is-fullwidth-code-point",
36
+ "is-in-ci",
37
+ "is-unicode-supported",
38
+ "patch-console",
39
+ "react",
40
+ "react-devtools-core",
41
+ "react-reconciler",
42
+ "restore-cursor",
43
+ "restore-cursor/onetime",
44
+ "restore-cursor/onetime/mimic-fn",
45
+ "scheduler",
46
+ "signal-exit",
47
+ "slice-ansi",
48
+ "stack-utils",
49
+ "string-width",
50
+ "strip-ansi",
51
+ "terminal-size",
52
+ "widest-line",
53
+ "wrap-ansi",
54
+ "ws",
55
+ "yoga-layout",
56
+ "zod"
57
+ ],
58
+ "workit-opencode": [
59
+ "@openclaw/fs-safe",
60
+ "@opencode-ai/plugin",
61
+ "@opencode-ai/plugin/zod",
62
+ "@opencode/plugin",
63
+ "zod"
64
+ ],
65
+ "workit-cursor": [
66
+ "@modelcontextprotocol/sdk",
67
+ "@openclaw/fs-safe",
68
+ "ajv",
69
+ "ajv-formats",
70
+ "fast-deep-equal",
71
+ "fast-uri",
72
+ "json-schema-traverse",
73
+ "zod",
74
+ "zod-to-json-schema"
75
+ ],
76
+ "workit-codex": [
77
+ "@modelcontextprotocol/sdk",
78
+ "@openclaw/fs-safe",
79
+ "ajv",
80
+ "ajv-formats",
81
+ "fast-deep-equal",
82
+ "fast-uri",
83
+ "json-schema-traverse",
84
+ "zod",
85
+ "zod-to-json-schema"
86
+ ],
87
+ "workit-pi": ["@openclaw/fs-safe", "zod"],
88
+ "workit-claude-code": [
89
+ "@alcalzone/ansi-tokenize",
90
+ "@inkjs/ui",
91
+ "@openclaw/fs-safe",
92
+ "ansi-escapes",
93
+ "ansi-regex",
94
+ "ansi-styles",
95
+ "auto-bind",
96
+ "chalk",
97
+ "cli-boxes",
98
+ "cli-cursor",
99
+ "cli-truncate",
100
+ "code-excerpt",
101
+ "convert-to-spaces",
102
+ "deepmerge",
103
+ "environment",
104
+ "es-toolkit",
105
+ "escape-string-regexp",
106
+ "figures",
107
+ "get-east-asian-width",
108
+ "indent-string",
109
+ "ink",
110
+ "is-fullwidth-code-point",
111
+ "is-in-ci",
112
+ "is-unicode-supported",
113
+ "patch-console",
114
+ "react",
115
+ "react-devtools-core",
116
+ "react-reconciler",
117
+ "restore-cursor",
118
+ "restore-cursor/onetime",
119
+ "restore-cursor/onetime/mimic-fn",
120
+ "scheduler",
121
+ "signal-exit",
122
+ "slice-ansi",
123
+ "stack-utils",
124
+ "string-width",
125
+ "strip-ansi",
126
+ "terminal-size",
127
+ "widest-line",
128
+ "wrap-ansi",
129
+ "ws",
130
+ "yoga-layout",
131
+ "zod"
132
+ ]
133
+ }
@@ -4,7 +4,12 @@
4
4
  // package so release logs answer "what shipped?" without leaving the terminal.
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { resolve } from "node:path";
7
- import { latestTag, RELEASE_PACKAGES } from "./analyze-release-scope";
7
+ import {
8
+ latestTag,
9
+ lockChangedPackages,
10
+ payloadPaths,
11
+ RELEASE_PACKAGES,
12
+ } from "./analyze-release-scope";
8
13
 
9
14
  const git = (root: string, args: string[]): string =>
10
15
  execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
@@ -12,8 +17,13 @@ const git = (root: string, args: string[]): string =>
12
17
  export function changedPackages(root: string, fromTag: string): string[] {
13
18
  // Committed state only: <tag>..HEAD, never the working tree — unreviewed
14
19
  // local edits must not decide what ships.
20
+ // Plus packages whose inlined third-party deps resolve differently in the
21
+ // lockfile (BUNDLED_DEPS); a dev-tooling-only lockfile bump changes none.
22
+ const relocked = new Set<string>(lockChangedPackages(root, fromTag, "HEAD"));
15
23
  return RELEASE_PACKAGES.filter((pkg) => {
16
- const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", `packages/${pkg}`]);
24
+ if (relocked.has(pkg)) return true;
25
+ // Own directory plus any sources bundled into its dist/ (BUNDLED_SOURCES).
26
+ const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", ...payloadPaths(pkg)]);
17
27
  return out !== "";
18
28
  });
19
29
  }
@@ -38,20 +48,15 @@ export function publishChanged(opts: {
38
48
  execFileSync(cmd, args, { cwd: o.cwd, encoding: "utf8", stdio: "inherit" }));
39
49
  const tag =
40
50
  opts.fromTag !== undefined ? (opts.fromTag === "" ? null : opts.fromTag) : latestTag(root);
41
- if (tag === null) {
42
- // First-ever release: everything ships.
43
- const published: string[] = [];
44
- for (const pkg of RELEASE_PACKAGES) {
45
- const cwd = resolve(root, "packages", pkg);
46
- if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
47
- published.push(pkg);
48
- console.log(`published ${pkg} @ ${cwd}`);
49
- }
50
- return { published, skipped: [], tag: null };
51
- }
52
- const changed = new Set(changedPackages(root, tag));
51
+ // First-ever release: everything ships.
52
+ const changed =
53
+ tag === null ? new Set<string>(RELEASE_PACKAGES) : new Set(changedPackages(root, tag));
53
54
  const published: string[] = [];
54
55
  const skipped: string[] = [];
56
+ const failed: Array<{ pkg: string; error: string }> = [];
57
+ // Every changed package is attempted even after a failure: one package that
58
+ // cannot publish (e.g. a first publish of a new npm name the token does not
59
+ // cover) must not leave the others unreleased. Failures throw at the end.
55
60
  for (const pkg of RELEASE_PACKAGES) {
56
61
  if (!changed.has(pkg)) {
57
62
  skipped.push(pkg);
@@ -62,12 +67,19 @@ export function publishChanged(opts: {
62
67
  try {
63
68
  if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
64
69
  } catch (e) {
65
- console.log(`publish failed ${pkg}: ${e instanceof Error ? e.message : String(e)}`);
66
- throw e;
70
+ const error = e instanceof Error ? e.message : String(e);
71
+ failed.push({ pkg, error });
72
+ console.log(`publish failed ${pkg}: ${error}`);
73
+ continue;
67
74
  }
68
75
  published.push(pkg);
69
76
  console.log(`published ${pkg} @ ${cwd}`);
70
77
  }
78
+ if (failed.length > 0) {
79
+ 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.`;
80
+ console.log(summary);
81
+ throw new Error(summary);
82
+ }
71
83
  return { published, skipped, tag };
72
84
  }
73
85
 
@@ -25,14 +25,19 @@ for (const pkg of [
25
25
  "workit-cursor",
26
26
  "workit-codex",
27
27
  "workit-pi",
28
+ "workit-claude-code",
28
29
  ]) {
29
30
  const file = resolve(root, `packages/${pkg}/package.json`);
30
31
  const data = JSON.parse(readFileSync(file, "utf8"));
31
- const deps = data.dependencies;
32
- if (!deps) continue;
33
- for (const name of Object.keys(deps)) {
34
- if (name.startsWith("@brainervirus/")) deps[name] = `^${core.version}`;
35
- }
32
+ // devDependencies too: the Claude Code plugin bundles core and the CLI into
33
+ // dist/, so they are build-time only, but a published manifest must still
34
+ // never carry workspace:*.
35
+ const groups = [data.dependencies, data.devDependencies].filter(Boolean);
36
+ if (groups.length === 0) continue;
37
+ for (const deps of groups)
38
+ for (const name of Object.keys(deps)) {
39
+ if (name.startsWith("@brainervirus/")) deps[name] = `^${core.version}`;
40
+ }
36
41
  writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
37
42
  }
38
43
  // The root marketplace index carries no release version; only the package
@@ -40,6 +45,7 @@ for (const pkg of [
40
45
  for (const file of [
41
46
  resolve(root, "packages/workit-cursor/.cursor-plugin/plugin.json"),
42
47
  resolve(root, "packages/workit-codex/.codex-plugin/plugin.json"),
48
+ resolve(root, "packages/workit-claude-code/.claude-plugin/plugin.json"),
43
49
  ]) {
44
50
  const data = JSON.parse(readFileSync(file, "utf8"));
45
51
  data.version = core.version;
@@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process";
9
9
  import { readFileSync, writeFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
 
12
- const SYNC_MANIFEST_PATHS = [
12
+ export const SYNC_MANIFEST_PATHS = [
13
13
  "package.json",
14
14
  "packages/workit-core/package.json",
15
15
  "packages/workit-mcp/package.json",
@@ -18,9 +18,13 @@ const SYNC_MANIFEST_PATHS = [
18
18
  "packages/workit-cursor/package.json",
19
19
  "packages/workit-codex/package.json",
20
20
  "packages/workit-pi/package.json",
21
+ "packages/workit-claude-code/package.json",
21
22
  // Kept in lockstep with packages/workit-core/package.json by contract test.
22
23
  "packages/workit-cursor/.cursor-plugin/plugin.json",
23
24
  "packages/workit-codex/.codex-plugin/plugin.json",
25
+ // Claude Code reads the plugin version from here (the marketplace entry
26
+ // carries none), so it must track the published npm version.
27
+ "packages/workit-claude-code/.claude-plugin/plugin.json",
24
28
  ];
25
29
 
26
30
  export type ManifestSyncResult = { version: string; changed: string[] };
@@ -0,0 +1,135 @@
1
+ // Autonomy grants (D4, D15; design §0 #12, §4.2): the per-workspace ceiling on
2
+ // what an agent may deliver without asking. S11 introduces the reader and the
3
+ // one seam every delivery verb consults, `requireGrant(kind)`; S16 adds
4
+ // `workit grant`, the D4 defaults and the legacy `autoApprove` mapping.
5
+ //
6
+ // Today (S11):
7
+ // - Grants are read only from the matched workspace entry in the user's
8
+ // `~/.config/workit/workspaces.json` (`"autonomy": {"push": true, "pr": true,
9
+ // "merge": false | true | "verified", "release": false}`), never from a repo
10
+ // file, and nothing here writes them (D15: never raised headless).
11
+ // - An explicit value is honored: `false` denies with `grant_required`, `true`
12
+ // allows, and `merge: "verified"` allows only with an accepted independent
13
+ // verdict (S13). `merge: true` is the only way to merge without one.
14
+ // - An absent grant is allowed on the host's own authority (`source:
15
+ // "host_authority"`): the host permission prompt is the ceiling, and a merge
16
+ // still needs an accepted verdict. S16 replaces that default with the D4
17
+ // ceiling `{push: true, pr: true, merge: false, release: false}`.
18
+ // - Grants require an account (design §2.0 Identity): a workspace with any
19
+ // grant but no `vcs.account` cannot push, open or merge through a forge,
20
+ // so a personal grant never rides a work credential.
21
+ import { resolveRuntimeWorkspaceVcs } from "./core/workspaces";
22
+
23
+ export type GrantKind = "push" | "pr" | "merge" | "release";
24
+ const GRANT_KINDS: readonly GrantKind[] = ["push", "pr", "merge", "release"];
25
+
26
+ /** One grant as configured: `"verified"` means allowed with an accepted verdict. */
27
+ export type GrantValue = boolean | "verified";
28
+
29
+ export type Autonomy = {
30
+ workspace: string | null;
31
+ /** Explicitly configured grants; a kind that is absent falls back to host authority. */
32
+ grants: Partial<Record<GrantKind, GrantValue>>;
33
+ source: "autonomy" | "host_authority";
34
+ accountConfigured: boolean;
35
+ };
36
+
37
+ export type GrantDecision =
38
+ | {
39
+ allowed: true;
40
+ kind: GrantKind;
41
+ /** Where the permission came from. */
42
+ source: "autonomy" | "host_authority";
43
+ /** A merge must also have an accepted independent verdict (S13). */
44
+ requireVerdict: boolean;
45
+ workspace: string | null;
46
+ }
47
+ | {
48
+ allowed: false;
49
+ kind: GrantKind;
50
+ reason: "grant_required" | "account_required" | "config_invalid";
51
+ error: string;
52
+ unblock: string;
53
+ workspace: string | null;
54
+ };
55
+
56
+ const isGrantValue = (value: unknown): value is GrantValue =>
57
+ value === true || value === false || value === "verified";
58
+
59
+ /** The grants for the workspace `cwd` belongs to (read-only). */
60
+ export function resolveAutonomy(cwd: string): Autonomy {
61
+ const workspace = resolveRuntimeWorkspaceVcs(cwd);
62
+ const raw = (workspace as { autonomy?: unknown } | null)?.autonomy;
63
+ const grants: Autonomy["grants"] = {};
64
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
65
+ for (const kind of GRANT_KINDS) {
66
+ const value = (raw as Record<string, unknown>)[kind];
67
+ if (isGrantValue(value)) grants[kind] = value;
68
+ }
69
+ return {
70
+ workspace: workspace?.name ?? null,
71
+ grants,
72
+ source: Object.keys(grants).length > 0 ? "autonomy" : "host_authority",
73
+ accountConfigured: Boolean(workspace?.vcs?.account),
74
+ };
75
+ }
76
+
77
+ const grantHint = (workspace: string | null, kind: GrantKind): string =>
78
+ `ask the user to set "autonomy": {"${kind}": ${kind === "merge" ? '"verified"' : "true"}} for workspace "${workspace ?? "?"}" in ~/.config/workit/workspaces.json (S16: workit grant set ${workspace ?? "<workspace>"} ${kind}=${kind === "merge" ? "verified" : "true"})`;
79
+
80
+ /**
81
+ * The seam every delivery verb consults before an external effect. `forge`
82
+ * says the effect goes through a GitHub/GitLab account (a push to a local
83
+ * path does not).
84
+ */
85
+ export function requireGrant(
86
+ cwd: string,
87
+ kind: GrantKind,
88
+ options: { forge?: boolean } = {},
89
+ ): GrantDecision {
90
+ let autonomy: Autonomy;
91
+ try {
92
+ autonomy = resolveAutonomy(cwd);
93
+ } catch (error) {
94
+ return {
95
+ allowed: false,
96
+ kind,
97
+ reason: "config_invalid",
98
+ error: `grant_unreadable: ${error instanceof Error ? error.message : String(error)}`,
99
+ unblock: "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
100
+ workspace: null,
101
+ };
102
+ }
103
+ const { workspace } = autonomy;
104
+ const value = autonomy.grants[kind];
105
+ if (value === false)
106
+ return {
107
+ allowed: false,
108
+ kind,
109
+ reason: "grant_required",
110
+ error: `grant_required: ${kind} is not granted for workspace "${workspace ?? "?"}"`,
111
+ unblock: grantHint(workspace, kind),
112
+ workspace,
113
+ };
114
+ if (
115
+ autonomy.source === "autonomy" &&
116
+ !autonomy.accountConfigured &&
117
+ options.forge !== false &&
118
+ kind !== "release"
119
+ )
120
+ return {
121
+ allowed: false,
122
+ kind,
123
+ reason: "account_required",
124
+ error: `account_required: workspace "${workspace ?? "?"}" has autonomy grants but no vcs.account, so the credential cannot be checked`,
125
+ unblock: `set vcs.account for workspace "${workspace ?? "?"}" in ~/.config/workit/workspaces.json`,
126
+ workspace,
127
+ };
128
+ return {
129
+ allowed: true,
130
+ kind,
131
+ source: value === undefined ? "host_authority" : "autonomy",
132
+ requireVerdict: kind === "merge" && value !== true,
133
+ workspace,
134
+ };
135
+ }
@@ -9,6 +9,7 @@ import { planUninstall, type UninstallPaths } from "./uninstall";
9
9
  import {
10
10
  findHostExecutable,
11
11
  installedHostApp,
12
+ isClaudeWorkitInstalled,
12
13
  isCodexWorkitInstalled,
13
14
  isPiWorkitInstalled,
14
15
  type HostId,
@@ -24,7 +25,7 @@ export type HostDetection = {
24
25
 
25
26
  export type DetectHostsOptions = UninstallPaths;
26
27
 
27
- const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
28
+ const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi", "claude-code"];
28
29
 
29
30
  /** Hosts the setup wizard configures through their native install paths. */
30
31
  export const WIZARD_HOSTS: HostId[] = [...HOSTS];
@@ -40,6 +41,7 @@ export function emptyDetection(): Record<HostId, HostDetection> {
40
41
  cursor: { detected: false, configured: false },
41
42
  codex: { detected: false, configured: false },
42
43
  pi: { detected: false, configured: false },
44
+ "claude-code": { detected: false, configured: false },
43
45
  };
44
46
  }
45
47
 
@@ -52,8 +54,10 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
52
54
  const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
53
55
  const found = emptyDetection();
54
56
  for (const host of HOSTS) {
57
+ // Claude Code ships as the `claude` CLI (the desktop app embeds it).
58
+ const executable = host === "claude-code" ? "claude" : host;
55
59
  const detected =
56
- findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env);
60
+ findHostExecutable(executable, { home, env }) !== null || installedHostApp(host, home, env);
57
61
  found[host] = {
58
62
  detected,
59
63
  configured:
@@ -61,7 +65,9 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
61
65
  ? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
62
66
  : host === "pi"
63
67
  ? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
64
- : (configuredByHost.get(host) ?? false),
68
+ : host === "claude-code"
69
+ ? isClaudeWorkitInstalled(home, env)
70
+ : (configuredByHost.get(host) ?? false),
65
71
  };
66
72
  }
67
73
  return found;