Linear workflow conventions for a small two-person team. Owns ticket creation defaults (including assignee), status flow semantics, label set, title shape…

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 150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ticket } from '../types'
5
6// Active tickets for this session, most recent first. $.state survives hot
7// reloads; team keys seen in issue results persist across sessions in $.store.
8const tickets = atom({ plugin: 'linear', key: 'tickets' } as const, [])
9const TEAM_KEYS = 'teamKeys'
10const ISSUE_TOOLS = ['mcp__linear-server__get_issue', 'mcp__linear-server__save_issue']
11const IDENTIFIER = /^[A-Z][A-Z0-9]{1,9}-\d+$/
12const MENTION = /\b[A-Z]{2,10}-\d+\b/g
13// Nerd Font md-ticket (U+F0516); Linear's own logo is in no terminal font.
14const GLYPH = '\u{F0516}'
15
16type Engine = EngineInterface
17
18const teamKeyOf = (id: string) => id.slice(0, id.lastIndexOf('-'))
19
20// Front-loads `seen`, keeping a known title when the new sighting has none.
21const promote = (list: readonly Ticket[], seen: readonly Ticket[]) => {
22 const known = new Map(list.map(t => [t.id, t]))
23 const fresh = seen.map(t => ({ ...known.get(t.id), ...t, title: t.title ?? known.get(t.id)?.title }))
24 const ids = new Set(fresh.map(t => t.id))
25 return [...fresh, ...list.filter(t => !ids.has(t.id))]
26}
27
28const remember = async ($: Engine, seen: readonly Ticket[]) => {
29 if (seen.length === 0) return
30 await update($, tickets, list => promote(list, seen))
31}
32
33const readTeamKeys = async ($: Engine) => {
34 const stored = await $.store.get(TEAM_KEYS)
35 return new Set(Array.isArray(stored) ? stored.filter(k => typeof k === 'string') : [])
36}
37
38const learnTeamKey = async ($: Engine, id: string) => {
39 const keys = await readTeamKeys($)
40 if (keys.has(teamKeyOf(id))) return
41 await $.store.set(TEAM_KEYS, [...keys, teamKeyOf(id)])
42}
43
44// get_issue and save_issue answer with the issue as JSON text: `id` is the
45// identifier (JUN-468), `uuid` the database id.
46export const parseIssue = (text: string | undefined): Ticket | undefined => {
47 if (!text) return undefined
48 try {
49 const issue: unknown = JSON.parse(text)
50 if (typeof issue !== 'object' || issue === null) return undefined
51 const { id, identifier, title } = issue as Record<string, unknown>
52 const key = [identifier, id].find(v => typeof v === 'string' && IDENTIFIER.test(v))
53 if (typeof key !== 'string') return undefined
54 return typeof title === 'string' ? { id: key, title } : { id: key }
55 } catch {
56 return undefined
57 }
58}
59
60const describe = (list: readonly Ticket[]) =>
61 list.map(t => (t.title ? `- ${t.id}: ${t.title}` : `- ${t.id}`)).join('\n')
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: 'ticket',
67 description: 'Show, clear, or drop the Linear tickets this session tracks',
68 argumentHint: '[clear | drop <ID>]',
69 })
70 return next(e)
71 })
72
73 on('tool.call', async ($, e, next) => {
74 const ran = await next(e)
75 if (!ISSUE_TOOLS.includes(e.tool) || ran.deny !== undefined || ran.isError) return ran
76 const issue = parseIssue(ran.text)
77 if (issue) {
78 await learnTeamKey($, issue.id)
79 await remember($, [issue])
80 }
81 return ran
82 })
83
84 on('prompt.submit', async ($, e, next) => {
85 const keys = await readTeamKeys($)
86 const ids = [...new Set(e.text.match(MENTION) ?? [])].filter(id => keys.has(teamKeyOf(id)))
87 await remember($, ids.map(id => ({ id })))
88 return next(e)
89 })
90
91 on('prompt.compose', async ($, e, next) => {
92 const composed = await next(e)
93 const list = await read($, tickets)
94 if (list.length === 0) return composed
95 const text = [
96 '# Active Linear tickets',
97 'This session is working on these Linear tickets, most recent first. Keep them in mind across compaction, and name them in commits and PRs where the repo convention asks for it.',
98 describe(list),
99 ].join('\n\n')
100 return {
101 sections: [...composed.sections, { id: 'linear:active', text, scope: 'session' as const }],
102 }
103 })
104
105 // The band above the prompt: the newest ticket, its title dimmed and cut to
106 // the row, and how many more are active. Reading the atom while drawing
107 // redraws the band whenever the set changes.
108 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109 const [first, ...rest] = await read($, tickets)
110 if (e.props.hasSurvey || first === undefined) return next(e)
111 const { Box, Text } = $.ui.resolve(e)
112 // marginTop: a blank row between the conversation and the band.
113 return (
114 <Box marginTop={1}>
115 <Text bold>
116 {GLYPH} {first.id}
117 </Text>
118 {first.title && (
119 <Box flexShrink={1}>
120 <Text dimColor wrap="truncate-end">
121 {' '}
122 {first.title}
123 </Text>
124 </Box>
125 )}
126 {rest.length > 0 && <Text dimColor> +{rest.length}</Text>}
127 </Box>
128 )
129 })
130
131 on('command.run', { command: 'ticket' }, async ($, e) => {
132 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
133 if (verb === 'clear') {
134 await update($, tickets, () => [])
135 return { text: 'Cleared the active Linear tickets.' }
136 }
137 if (verb === 'drop') {
138 const id = arg.toUpperCase()
139 if (!id) return { text: 'Usage: /ticket drop <ID>' }
140 const before = await read($, tickets)
141 if (!before.some(t => t.id === id)) return { text: `${id} is not an active ticket.` }
142 await update($, tickets, list => list.filter(t => t.id !== id))
143 return { text: `Dropped ${id}.` }
144 }
145 if (verb !== '') return { text: 'Usage: /ticket [clear | drop <ID>]' }
146 const list = await read($, tickets)
147 return { text: list.length === 0 ? 'No active Linear tickets.' : describe(list) }
148 })
149}
150types/index.d.ts 8 lines1export type Ticket = { id: string; title?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'linear': { tickets: Ticket[] }
6 }
7}
8