No description.

Personal plugin marketplace shared by Claude Code and Codex, with canonical AgentForge definitions, native runtime manifests, and automated validation. Claude supports the full catalog; Codex enrolls 16 packages, with completed fresh-task runtime smoke acceptance for 8.
See Dual-agent operating model for ownership boundaries, runtime mappings, installation, and runtime acceptance.
jdh-agents/
├── MARKETPLACE.yaml # Canonical AgentForge collection definition
├── .claude-plugin/ # Compiler output — the remote-install entry point
│ └── marketplace.json # Root copy of the Claude publication
├── plugins/ # Authoring source — hand-edited, never installed from
│ └── [plugin-name]/
│ ├── PACKAGE.yaml # Canonical AgentForge package definition
│ └── ... # Plugin files (commands, agents, skills, etc.)
├── marketplaces/ # Compiler output — committed, never hand-edited
│ ├── claude/ # Self-contained Claude marketplace root (19 packages)
│ │ ├── .claude-plugin/marketplace.json
│ │ └── plugins/[name]/
│ └── codex/ # Self-contained Codex marketplace root (16 packages)
│ ├── .agents/plugins/marketplace.json
│ └── plugins/[name]/
├── .betterleaks.toml # Privacy-gate rules
├── .betterleaks-baseline.json # Pre-existing findings, accepted once
├── scripts/ # Automation tooling
│ ├── agentforge.sh # Fetches + sha256-verifies the pinned compiler
│ ├── privacy-scan.sh # History secret/privacy gate (pinned Betterleaks)
│ └── tests/ # Gate self-test + attention-workflow behavior
└── .github/workflows/ # CI/CD automation
└── validate.yml # GitHub Actions workflow
Each directory under marketplaces/ is a complete marketplace root, so a runtime is pointed at that directory rather than at the repository. Pointing a runtime at the repository root is what previously let Codex resolve canonical Claude sources instead of its own projection.
The one exception is .claude-plugin/marketplace.json, which exists so that Claude Code can install from the repository remotely -- see Root manifest. It is a compiled copy, not a hand-written one, and it resolves packages back into marketplaces/claude/.
Consuming vs. authoring. Installing and using these plugins needs nothing but this repository —
marketplaces/is compiled output and is committed, so every plugin is ready to install as-is. Authoring (thecompilestep below) additionally needs the AgentForge compiler, whichscripts/agentforge.shfetches and verifies on first use — no manual install, and no credential, since that repository is public.
Nothing here is needed to browse the repo. These are what the plugins and the tooling expect at runtime.
For the marketplace tooling (compile, check):
bash and curl — scripts/agentforge.sh fetches the pinned compiler binary and verifies it against a per-platform sha256 before executing itgit — scripts/privacy-scan.sh scans committed history, so it needs the repository's commits (in CI, actions/checkout with fetch-depth: 0)uv — only to run scripts/tests/test_attention_workflow.py, which declares its own pytest dependency in a PEP 723 header. The repo declares no project, so there is nothing to install and no lockfile to sync.Per plugin. Most plugins are self-contained, but several are inert or misleading without an external account or binary. Check this table before installing one and wondering why it does nothing:
| Plugin | Needs |
|---|---|
librarian, debate | Obsidian vault + obsidian-mcp MCP server; obsidian-cli on PATH |
coach | Obsidian vault + obsidian-cli; Todoist (via the claude.ai connector) |
compass | Obsidian vault + obsidian-cli; Kagi MCP server (optional, for research) |
teach | Obsidian vault + obsidian-cli; DEVONthink MCP server (optional) |
pm | Obsidian vault; Linear MCP server; ndr on PATH |
linear, spec-flow | Linear MCP server (spec-flow also uses Context7) |
attention-workflow | Linear or Fibery MCP server |
em | A tracker (Linear MCP server, Fibery MCP server, or gh) and a VCS; ndr, workspaces, craft, pm optional |
craft | gh, git/jj, ndr; IaC skills additionally want tflint, checkov, trivy, infracost |
langfuse | A Langfuse account + uv on PATH (the Stop hook runs via uv run) |
skillsmith | gh on PATH (for upstream-review) |
pr-watch | gh (GitHub) and/or fj (Forgejo) on PATH, each already logged in; git for Forgejo head commits |
introspect | Local Claude Code transcripts under ~/.claude/projects/; invocable from Claude Code or Codex, but does not parse Codex rollout files |
shake-tune | Klippain Shake Tune PNG output from a Klipper printer |
commit, feedback | Nothing beyond git (commit also supports jj) |
Vault-backed plugins default to a vault named Loose Ends. That is an example, not a requirement — point them at your own vault by editing the paths in the skill bodies.
Claude Code installs remotely, with no clone:
/plugin marketplace add jdh313/jdh-agents
That resolves .claude-plugin/marketplace.json at the repository root, which is a compiled copy of the Claude publication whose package sources point back into marketplaces/claude/. It is generated, never hand-written -- see Root manifest.
A local clone still works, and is what Codex needs:
git clone https://github.com/jdh313/jdh-agents
/plugin marketplace add /path/to/jdh-agents/marketplaces/claude
Codex local marketplace. Fifteen of the nineteen packages declare targets.codex and are therefore enrolled -- enrollment means the package compiled and published for Codex, not that it was exercised on a Codex runtime. Six have a passing fresh-task smoke test on top of that: the four installed below plus librarian and teach.
codex plugin marketplace add /path/to/jdh-agents/marketplaces/codex
codex plugin add commit@jdh-agents
codex plugin add craft@jdh-agents
codex plugin add linear@jdh-agents
codex plugin add spec-flow@jdh-agents
Both publications keep the marketplace name jdh-agents, so an existing install survives the repoint: only the path each runtime resolves changes.
Claude Code's marketplace add <owner>/<repo> form reads .claude-plugin/marketplace.json at the repository root, so remote install needs a manifest there -- but the compiled Claude publication lives under marketplaces/claude/, and its package sources are relative to that directory.
MARKETPLACE.yaml's Claude publication therefore declares root-manifest: true. AgentForge writes a second copy of the same registry at the repository root and rewrites every package source from ./plugins/<name> to ./marketplaces/claude/plugins/<name>, so both copies enrol the same packages and resolve to the same bytes. The Codex publication does not declare it: Codex is installed from a local clone, and a second root file would collide with nothing useful.
Like everything under marketplaces/, the root manifest is generated. Do not hand-edit it -- agentforge compile rewrites it, and agentforge check fails on drift in it, reporting the path with a <root> prefix rather than relative to marketplaces/.
Step 3 requires the AgentForge compiler.
bash mkdir -p plugins/my-plugin ``plugins/my-plugin/PACKAGE.yaml. Declare only the runtimes whose native mappings have been validated.bash scripts/agentforge.sh compile MARKETPLACE.yaml --out marketplaces `` The pinned compiler is fetched and sha256-verified on first use; nothing to install or configure.bash scripts/agentforge.sh check MARKETPLACE.yaml --out marketplaces --claude-native ``bash scripts/privacy-scan.sh ``See docs/agentforge-compatibility.md for the current target matrix, payload handling, and reviewed compatibility limitations.
Two commands drive the registry. There is no repo-specific CLI to install.
Compiles MARKETPLACE.yaml into the committed publication roots under marketplaces/, plus the root manifest beside MARKETPLACE.yaml. AgentForge stages into a temporary directory and publishes by rename, so a failed compile leaves the committed tree untouched and a successful one prunes every stale file.
scripts/agentforge.sh compile MARKETPLACE.yaml --out marketplaces
scripts/agentforge.sh is a ~40-line wrapper that pins the compiler by release version and per-platform sha256, fetches it on first use, verifies the bytes before executing them, and caches it under .cache/agentforge/<version>/. The hash is re-verified on every run, so a corrupted or tampered cache is replaced rather than trusted. CI runs this same script — there is no separate CI pin.
Diffs the compilation plan against the committed tree without writing, and reports missing, extra, changed, and permission drift. It also gates managed output content, parses every managed .json, validates skill frontmatter, checks manifest parity, and resolves every declared plugin path. With --claude-native it cross-checks the Claude publication using claude plugin validate --strict.
scripts/agentforge.sh check MARKETPLACE.yaml --out marketplaces --claude-native
AgentForge owns the cross-runtime translation from Claude disable-model-invocation: true metadata to Codex policy.allow_implicit_invocation: false skill sidecars, and reports it as a translated-construct note during check.
scripts/privacy-scan.sh
Hard-fails on absolute machine-home paths and secret-shaped assignments across the repository's committed history. Every finding fails; there is no advisory tier. The pinned Betterleaks binary fetches and sha256-verifies itself on first use, so there is nothing to install.
This is the one gate AgentForge cannot own: AgentForge only ever sees files a publication declares, so a leak in an undeclared file — a doc, a workflow, a decision atom — is invisible to it.
GitHub Actions runs on every push and pull request:
scripts/tests/test_privacy_gate.sh, then scripts/privacy-scan.sh over committed historyagentforge check --claude-native with AgentForge pinned to release v0.4.0claude plugin validate --strict for the generated Claude publication, using Claude Code 2.1.216jdh313/agentforge publishes per-platform release binaries, so the workflow downloads the pinned agentforge-linux-x64 binary and verifies it against a recorded SHA256 checksum instead of checking out and building the compiler from source.
MARKETPLACE.yaml and plugins/*/PACKAGE.yaml are the only maintained sources of marketplace and package metadata, and plugins/ is the only maintained source of plugin content. Everything under marketplaces/ is committed compiler output — manifests and bodies alike. Edit the source and run scripts/agentforge.sh compile; never hand-edit a file under marketplaces/, because the next compile republishes the whole tree and silently discards the edit.
This is a personal marketplace maintained by one person, published so others can install it. There is no service-level agreement, and new plugin submissions are unlikely to be merged — forking is a first-class answer.
SECURITY.md. Read the threat model there before installing; plugins are instructions and scripts your agent executes with your permissions, and three of them ship hooks that run automatically.CONTRIBUTING.md covers what lands, the pinned-compiler workflow, and the install trap behind most "my copy is stale" reports.Apache-2.0 (see LICENSE).
Portions are derived from third-party work under other terms — notably twelve skills across craft, pm, skillsmith, and teach adapted from mattpocock/skills (MIT), and the langfuse plugin, forked from langfuse/Claude-Observability-Plugin (MIT). Both upstreams' notices are reproduced in full. Required notices, the full MIT text, and a per-skill provenance table are in THIRD-PARTY-NOTICES.md. Each adapted skill also carries upstream: provenance in its frontmatter and an UPSTREAM.md ledger of intentional divergences.
hooks/register.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Placement } from '../types'
5
6// Which subagent works in which jjx workspace. jjx puts every workspace at
7// `<repo>-spaces/<slug>`, so a path of that shape in a spawn prompt or in one
8// of the subagent's own tool calls places it; the latest sighting wins.
9const agents = atom({ plugin: 'workspaces', key: 'agents' } as const, [])
10const PANE = 'workspaces'
11const TITLE = 'Workspaces'
12const SPACE = /\/([^/\s'"`]+)-spaces\/([^/\s'"`]+)/g
13// Tool arguments that carry a path or a command naming one.
14const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'cwd', 'command'] as const
15const NONE = '(no workspace)'
16const RUNNING = '●'
17const DONE = '✓'
18
19type Engine = EngineInterface
20
21// The last `<repo>-spaces/<slug>` the text names, as that string.
22export const workspaceIn = (text: string | undefined): string | undefined => {
23 if (!text) return undefined
24 const found = [...text.matchAll(SPACE)].at(-1)
25 return found && `${found[1]}-spaces/${found[2]}`
26}
27
28export const workspaceOfCall = (call: Record<string, unknown>): string | undefined => {
29 for (const field of PATH_FIELDS) {
30 const value = call[field]
31 const space = typeof value === 'string' ? workspaceIn(value) : undefined
32 if (space) return space
33 }
34 return undefined
35}
36
37const slugOf = (space: string) => space.slice(space.lastIndexOf('/') + 1)
38const repoOf = (space: string) => space.slice(0, space.lastIndexOf('-spaces/'))
39
40// Groups agents by workspace, sorted, the unplaced last.
41export const byWorkspace = (list: readonly Placement[]) => {
42 const groups = new Map<string, Placement[]>()
43 for (const agent of list) {
44 const key = agent.workspace ?? NONE
45 groups.set(key, [...(groups.get(key) ?? []), agent])
46 }
47 return [...groups].sort(([a], [b]) => (a === NONE ? 1 : b === NONE ? -1 : a.localeCompare(b)))
48}
49
50const describe = (list: readonly Placement[]) =>
51 byWorkspace(list)
52 .map(([space, members]) =>
53 [
54 space,
55 ...members.map(a => ` ${a.isDone ? 'done' : 'runs'} ${a.description} (${a.type})`),
56 ].join('\n'),
57 )
58 .join('\n')
59
60// Applies `change` to one agent; opens the pane when it lands somewhere new.
61const touch = async ($: Engine, id: string, change: (a: Placement | undefined) => Placement | undefined) => {
62 let moved = false
63 await update($, agents, list => {
64 const before = list.find(a => a.id === id)
65 const after = change(before)
66 if (after === undefined) return list
67 moved = after.workspace !== undefined && after.workspace !== before?.workspace
68 return before ? list.map(a => (a.id === id ? after : a)) : [...list, after]
69 })
70 if (moved) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'spaces',
77 description: 'Show which subagents work in which jj workspaces',
78 argumentHint: '[clear]',
79 })
80 return next(e)
81 })
82
83 on('agent.spawn', async ($, e, next) => {
84 const started = await next(e)
85 const { agentId } = started
86 if (agentId === undefined) return started
87 const workspace = workspaceIn(e.cwd) ?? workspaceIn(e.prompt)
88 await touch($, agentId, () => ({
89 id: agentId,
90 description: e.description,
91 type: e.subagentType,
92 workspace,
93 isDone: false,
94 }))
95 return started
96 })
97
98 // Only a subagent's own calls place it; the main loop's carry no agentId.
99 on('tool.call', async ($, e, next) => {
100 if (e.agentId !== undefined) {
101 const workspace = workspaceOfCall(e as unknown as Record<string, unknown>)
102 const known = (await read($, agents)).some(a => a.id === e.agentId)
103 // Spawned before this module loaded: the roster still knows it.
104 const info = !known && workspace ? (await $.agent.list()).find(i => i.id === e.agentId) : undefined
105 if (info) {
106 await touch($, info.id, () => ({ id: info.id, description: info.description, type: info.type, isDone: false }))
107 }
108 await touch($, e.agentId, a =>
109 a && (a.isDone || (workspace && workspace !== a.workspace))
110 ? { ...a, isDone: false, workspace: workspace ?? a.workspace }
111 : undefined,
112 )
113 }
114 return next(e)
115 })
116
117 // A subagent's run is one turn of its loop; a message may resume it later.
118 on('turn.complete', async ($, e, next) => {
119 if (e.agentId !== undefined) {
120 await touch($, e.agentId, a => (a && !a.isDone ? { ...a, isDone: true } : undefined))
121 }
122 return next(e)
123 })
124
125 on('command.run', { command: 'spaces' }, async ($, e) => {
126 const verb = e.args.trim()
127 if (verb === 'clear') {
128 await update($, agents, list => list.filter(a => !a.isDone))
129 return { text: 'Cleared finished subagents.' }
130 }
131 if (verb !== '') return { text: 'Usage: /spaces [clear]' }
132 await $.ui.open({ id: PANE, title: TITLE })
133 const list = await read($, agents)
134 return { text: list.length === 0 ? 'No subagents yet.' : describe(list) }
135 })
136
137 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138 const { Box, Text } = $.ui.resolve(e)
139 const groups = byWorkspace(await read($, agents))
140 return (
141 <Box flexDirection="column">
142 {groups.length === 0 && <Text dimColor>No subagents yet.</Text>}
143 {groups.map(([space, members]) => (
144 <Box flexDirection="column" marginBottom={1}>
145 <Box>
146 <Text bold>{space === NONE ? NONE : slugOf(space)}</Text>
147 {space !== NONE && <Text dimColor> {repoOf(space)}</Text>}
148 </Box>
149 {members.map(a => (
150 <Box>
151 <Text dimColor={a.isDone}>
152 {' '}
153 {a.isDone ? DONE : RUNNING} {a.description}
154 </Text>
155 <Box flexShrink={1}>
156 <Text dimColor wrap="truncate-end">
157 {' '}
158 {a.type}
159 </Text>
160 </Box>
161 </Box>
162 ))}
163 </Box>
164 ))}
165 </Box>
166 )
167 })
168}
169types/index.d.ts 16 lines1// One subagent of this session and the jjx workspace it was last seen in:
2// `workspace` is `<repo>-spaces/<slug>`, absent until a path names one.
3export type Placement = {
4 id: string
5 description: string
6 type: string
7 workspace?: string
8 isDone: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'workspaces': { agents: Placement[] }
14 }
15}
16