Skip to content
Merged
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ This context describes how one plugin moves from authoring into Claude Code and
An agent environment that discovers, installs, and executes plugins. Claude Code and Codex are the supported harnesses.
_Avoid_: Host, runtime environment

**Harness Identity**:
The canonical lowercase discriminator for a supported Harness together with its harness-owned manifest directory, hook declaration path, plugin-root environment variable, and human-readable display name.
_Avoid_: Qualification Client, host identity

**Plugin Repository**:
The workspace containing a plugin's source, tests, documentation, and release history.
_Avoid_: Plugin, when referring to the whole repository
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0009-canonical-harness-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Establish canonical harness identity

## Status

Accepted — 2026-08-14.

## Context

Claude Code and Codex need shared vocabulary for payload identity while retaining
harness-specific discovery, trust, installation, and restoration behavior. The
same harness facts were previously re-derived across scripts, and qualification
client labels risked being mistaken for new harness or driver branches.

[ADR-0001](0001-one-payload-native-harness-adapters.md) keeps native harness
adapters distinct around one payload. [ADR-0003](0003-reviewed-versioned-releases.md)
keeps Claude and Codex replacement state and lifecycle behavior distinct. The
identity vocabulary and driver seams must preserve both decisions.

## Decision

Use `claude` and `codex` as the canonical lowercase harness IDs. Keep their
harness-owned values in `scripts/harness-identity.ts`:

- `claude` displays as `Claude`, reads its native manifest from
`.claude-plugin`, declares hooks at `./hooks/claude/hooks.json`, and receives
the installed plugin root through `CLAUDE_PLUGIN_ROOT`.
- `codex` displays as `Codex`, reads its native manifest from `.codex-plugin`,
declares hooks at `./hooks/codex/hooks.json`, and receives the installed
plugin root through `PLUGIN_ROOT`.

Freeze `claude-cli` and `codex-cli` as the qualification-client IDs for CLI
journeys and receipts. Keep `codex-desktop` as vocabulary mapped to the `codex`
harness only; it does not create a desktop-specific code path.

Give the Claude install driver a Claude-specific dependency-injection interface
parallel to `CodexDriverDependencies`. Preserve the same lifecycle shape:
preflight, capture, mutate, verify, then restore on failure. Carry harness-typed
state payloads through each driver. Never collapse Claude and Codex state into a
unioned common struct.
3 changes: 3 additions & 0 deletions docs/agents/doc-targets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ targets:
- plugin/hooks/native-capability-hook
- plugin/hooks/claude/hooks.json
- plugin/hooks/codex/hooks.json
docs/adr/0009-canonical-harness-identity.md:
- scripts/harness-identity.ts

# Payload docs. capability-tour/SKILL.md enumerates the skills in prose;
# scripts/native-capability-surface.test.ts now guards that line, but the rest
Expand Down Expand Up @@ -78,6 +80,7 @@ targets:
CONTEXT.md:
- .claude-plugin/marketplace.json
- plugin/.codex-plugin/plugin.json
- scripts/harness-identity.ts

# Deliberately uncovered: docs/releasing.md is a link index, docs/adr/0001 and
# 0006 are stable rationale, and hello-world / skill-a / skill-b SKILL.md claim
Expand Down
9 changes: 4 additions & 5 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { builtinModules } from "node:module"
import { tmpdir } from "node:os"
import { dirname, join, relative, resolve } from "node:path"

import { HARNESS_IDENTITIES } from "./harness-identity"
import { loadPluginConfig } from "./plugin-config"
import { compareCodeUnits, pluginPayloadInventory } from "./plugin-files"
import { checkRuntimeCustodyFiles, loadSkillCatalog, shellQuote } from "./runtime-custody-config"
Expand Down Expand Up @@ -1756,17 +1757,15 @@ export function validateBunOnlyPayload(root: string): void {
throw new Error(`Bun payload closure: unexpected payload file ${path}`)
}
}
for (const manifestPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"] as const) {
for (const identity of Object.values(HARNESS_IDENTITIES)) {
const manifestPath = `${identity.manifestDirectory}/plugin.json`
const manifest = JSON.parse(readFileSync(join(root, "plugin", manifestPath), "utf8")) as {
hooks?: unknown
description?: unknown
interface?: { capabilities?: unknown }
[key: string]: unknown
}
const expectedHooks =
manifestPath === ".claude-plugin/plugin.json"
? "./hooks/claude/hooks.json"
: "./hooks/codex/hooks.json"
const expectedHooks = identity.hooksDeclarationPath
if (manifest.hooks !== expectedHooks) {
throw new Error(`Bun payload closure: invalid hook declaration in ${manifestPath}`)
}
Expand Down
5 changes: 3 additions & 2 deletions scripts/codex-production-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
assertExactHarnessRecovery,
type HarnessRecoverySnapshot,
} from "./harness-install-recovery"
import type { HarnessId } from "./harness-identity"
import { loadPluginConfig } from "./plugin-config"
import { payloadInventorySha256, pluginPayloadInventory } from "./plugin-files"

Expand Down Expand Up @@ -188,7 +189,7 @@ export interface CodexProductionUpdateResult {
/** Whether the invocation previewed or applied the transaction. */
mode: "preview" | "apply"
/** Only supported production-update harness. */
harness: "codex"
harness: HarnessId
/** Whether this invocation changed native state. */
changed: boolean
/** Whether selected and prior Releases differ. */
Expand Down Expand Up @@ -1085,7 +1086,7 @@ function restorePriorRelease(
const restored = inspectCurrentState(repositoryRoot, environment, "recovery")
verifyReleaseState(restored, restoration, current.source, current.enabled, addResult.installedPath)
try {
assertExactHarnessRecovery(recoverySnapshot(current), recoverySnapshot(restored), "Codex")
assertExactHarnessRecovery(recoverySnapshot(current), recoverySnapshot(restored), "codex")
} catch {
throw new CodexProductionUpdateError(
"recovery",
Expand Down
22 changes: 13 additions & 9 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
} from "node:fs"
import { join, resolve } from "node:path"

import {
HARNESS_IDENTITIES,
QUALIFICATION_CLIENT_HARNESSES,
type HarnessId,
} from "./harness-identity"
import { copyPluginPayload } from "./plugin-files"
import { loadPluginConfig } from "./plugin-config"

Expand Down Expand Up @@ -56,10 +61,8 @@ Examples:
bun run dev -- claude --dry-run --json
`

type Harness = "claude" | "codex"

interface Options {
harness: Harness
harness: HarnessId
check: boolean
launch: boolean
dryRun: boolean
Expand All @@ -78,8 +81,8 @@ function parseOptions(arguments_: string[]): Options | null {
return null
}

const harness = arguments_[0]
if (harness !== "claude" && harness !== "codex") fail(`unknown command: ${harness}`)
const harness = arguments_[0] as HarnessId
if (!Object.hasOwn(HARNESS_IDENTITIES, harness)) fail(`unknown command: ${harness}`)

const flags = new Set(arguments_.slice(1))
for (const flag of flags) {
Expand Down Expand Up @@ -235,22 +238,23 @@ async function main(): Promise<void> {
if (!options) return

if (options.dryRun) {
const isClaude = options.harness === QUALIFICATION_CLIENT_HARNESSES["claude-cli"]
const plan = {
harness: options.harness,
build: "bun run build",
source: options.harness === "claude" ? pluginRoot : stagedPluginRoot,
source: isClaude ? pluginRoot : stagedPluginRoot,
install:
options.harness === "claude"
isClaude
? `claude --settings ${JSON.stringify(claudeSessionSettings)} --plugin-dir ${JSON.stringify(pluginRoot)}`
: `codex plugin add ${pluginName}@${developmentMarketplaceName}`,
reload:
options.harness === "claude"
isClaude
? "Run /reload-plugins after the watcher rebuilds"
: "Start a fresh Codex task after reinstall",
}
if (options.json) console.log(JSON.stringify(plan))
else console.log(Object.values(plan).join("\n"))
} else if (options.harness === "claude") {
} else if (options.harness === QUALIFICATION_CLIENT_HARNESSES["claude-cli"]) {
await runClaude(options)
} else {
runCodex(options)
Expand Down
48 changes: 48 additions & 0 deletions scripts/harness-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"

import {
HARNESS_IDENTITIES,
QUALIFICATION_CLIENT_HARNESSES,
type HarnessId,
type QualificationClient,
} from "./harness-identity"

describe("harness identities", () => {
test("preserve canonical paths, environment variables, and display names", () => {
const expected = {
claude: {
hooksDeclarationPath: "./hooks/claude/hooks.json",
manifestDirectory: ".claude-plugin",
pluginRootEnvVar: "CLAUDE_PLUGIN_ROOT",
displayName: "Claude",
},
codex: {
hooksDeclarationPath: "./hooks/codex/hooks.json",
manifestDirectory: ".codex-plugin",
pluginRootEnvVar: "PLUGIN_ROOT",
displayName: "Codex",
},
} as const satisfies Record<HarnessId, (typeof HARNESS_IDENTITIES)[HarnessId]>

expect(HARNESS_IDENTITIES).toEqual(expected)
expect(Object.keys(HARNESS_IDENTITIES).sort()).toEqual(["claude", "codex"])
expect(HARNESS_IDENTITIES.codex.pluginRootEnvVar).not.toBe("CODEX_PLUGIN_ROOT")
})
})

describe("qualification clients", () => {
test("map every client to its canonical harness", () => {
const expected = {
"claude-cli": "claude",
"codex-cli": "codex",
"codex-desktop": "codex",
} as const satisfies Record<QualificationClient, HarnessId>

expect(QUALIFICATION_CLIENT_HARNESSES).toEqual(expected)
expect(Object.keys(QUALIFICATION_CLIENT_HARNESSES).sort()).toEqual([
"claude-cli",
"codex-cli",
"codex-desktop",
])
})
})
77 changes: 77 additions & 0 deletions scripts/harness-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Harness-owned paths and presentation vocabulary.
*
* @example
* ```typescript
* const identity: HarnessIdentity = HARNESS_IDENTITIES.claude
* ```
*/
export interface HarnessIdentity {
/** Plugin-relative hook declaration referenced by the native manifest. */
hooksDeclarationPath: string
/** Plugin-relative directory containing the native manifest. */
manifestDirectory: string
/** Host-provided environment variable containing the installed plugin root. */
pluginRootEnvVar: string
/** Human-readable harness name used in diagnostics. */
displayName: string
}

/**
* Canonical harness identities and their native integration values.
*
* @example
* ```typescript
* const hooksPath = HARNESS_IDENTITIES.claude.hooksDeclarationPath
* ```
*/
export const HARNESS_IDENTITIES = {
claude: {
hooksDeclarationPath: "./hooks/claude/hooks.json",
manifestDirectory: ".claude-plugin",
pluginRootEnvVar: "CLAUDE_PLUGIN_ROOT",
displayName: "Claude",
},
codex: {
hooksDeclarationPath: "./hooks/codex/hooks.json",
manifestDirectory: ".codex-plugin",
pluginRootEnvVar: "PLUGIN_ROOT",
displayName: "Codex",
},
} as const satisfies Record<string, HarnessIdentity>

/**
* Canonical lowercase harness discriminator.
*
* @example
* ```typescript
* const harness: HarnessId = "codex"
* ```
*/
export type HarnessId = keyof typeof HARNESS_IDENTITIES

/**
* Qualification clients mapped to the harness whose payload they exercise.
*
* `codex-desktop` is vocabulary only; this registry does not create a desktop code path.
*
* @example
* ```typescript
* const harness = QUALIFICATION_CLIENT_HARNESSES["codex-desktop"]
* ```
*/
export const QUALIFICATION_CLIENT_HARNESSES = {
"claude-cli": "claude",
"codex-cli": "codex",
"codex-desktop": "codex",
} as const satisfies Record<string, HarnessId>

/**
* Client vocabulary used by native qualification receipts and journeys.
*
* @example
* ```typescript
* const client: QualificationClient = "claude-cli"
* ```
*/
export type QualificationClient = keyof typeof QUALIFICATION_CLIENT_HARNESSES
Loading