feat(#766): distribute gsd-core as a native Claude Code plugin (#797)

* feat(#766): distribute gsd-core as a native Claude Code plugin

Add an additive .claude-plugin/plugin.json manifest plus hooks/hooks.json so gsd-core can be installed as a first-class Claude Code plugin (marketplace or zero-friction @skills-dir), with /gsd-core: namespaced commands and lifecycle management — alongside the unchanged npm/file-copy installer.

- .claude-plugin/plugin.json: validated with 'claude plugin validate --strict'
- hooks/hooks.json: mirrors the installer's always-on Claude hook wiring via ${CLAUDE_PLUGIN_ROOT}
- package.json: ship .claude-plugin in the npm tarball
- tests/issue-766-plugin-manifest.test.cjs: manifest + always-on-hook-contract drift guards
- docs: install-on-your-runtime.md + FEATURES.md

Closes #766

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#766): add ADR-766 + glossary entry for Claude Code Plugin Manifest Module

Record the plugin manifest as the Seam projecting gsd-core's artifact surfaces onto the Claude Code plugin contract (sibling of the Runtime Artifact Layout Module, ADR-3660), with the defined kind->field mapping and the always-on hook projection rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 12:02:39 -04:00
committed by GitHub
parent 571d7b5a1c
commit f66c4a082c
10 changed files with 555 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 797
---
**gsd-core can now be installed as a native Claude Code plugin** — a new `.claude-plugin/plugin.json` manifest enables installing gsd-core via `claude plugin install` or the zero-friction `~/.claude/skills/` auto-load path (`gsd-core@skills-dir`), with slash commands auto-namespaced as `/gsd-core:<command>` (e.g. `/gsd-core:plan-phase`) and lifecycle management via `claude plugin enable|disable|update`. gsd-core's always-on guard and update hooks are wired for the plugin path through `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`. This is additive — the existing npm / file-copy installer is unchanged.

View File

@@ -0,0 +1,16 @@
{
"name": "gsd-core",
"displayName": "GSD Core",
"version": "1.3.1-dev.0",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"author": {
"name": "open-gsd",
"url": "https://github.com/open-gsd"
},
"homepage": "https://github.com/open-gsd/gsd-core",
"repository": "https://github.com/open-gsd/gsd-core",
"license": "MIT",
"keywords": ["spec-driven-development", "planning", "workflow", "context-engineering", "claude-code", "gsd"],
"commands": "./commands/gsd/",
"hooks": "./hooks/hooks.json"
}

View File

@@ -124,6 +124,9 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl
### Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60.
### Claude Code Plugin Manifest Module
Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module.
### Knowledge Graph Module
Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.

View File

@@ -1020,6 +1020,8 @@ fix(03-01): correct auth token expiry
| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config |
**Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported.
---
### 37. Hook System

View File

@@ -0,0 +1,70 @@
# Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract
- **Status:** Accepted
- **Date:** 2026-06-07
- **Issue:** #766
- **Implementation:** PR #797
## Context
gsd-core has, until now, reached Claude Code through exactly one Adapter: the file-copy installer. The **Runtime Artifact Layout Module** (ADR-3660) projects gsd-core's artifact surfaces (`commands`, `agents`, `skills`) onto per-runtime filesystem placements, and the **Runtime Install Policy Module** (ADR-58) composes those placements with command text and config intentions into a typed install plan that adapters write to `~/.claude/` / `.claude/`.
Claude Code now exposes a second, first-class way to receive the same surfaces: the **plugin contract** — a `.claude-plugin/plugin.json` manifest plus a `hooks/hooks.json`, consumed either by a marketplace install or by the zero-friction `@skills-dir` path. This contract is an *external interface owned by Claude Code*, not by gsd-core: it has its own schema, its own namespacing rules (`/<plugin-name>:<command>`), its own validation tool (`claude plugin validate`), and its own constraints (notably: plugin-shipped agents may not carry `hooks` / `permissionMode` / `mcpServers` frontmatter — Claude Code silently ignores them).
Before this ADR, the only record of how gsd-core maps onto that external contract was the manifest files themselves. A hand-authored config file with no named Seam invites drift: the manifest's hook wiring silently diverges from what the Installer Module wires into `settings.json`; the identity fields drift from the Package Identity Module; and a future maintainer has no single place that says *which gsd-core surface maps to which manifest field, and why*. The plugin contract is exactly the kind of external interface that earns a defined, typed mapping rather than an ad-hoc file — the same reasoning that gave the file-copy path the Runtime Artifact Layout Module.
This is the structural signal the architecture review looks for: **two Adapters at one Seam.** The file-copy layout and the plugin manifest are two projections of *the same* gsd-core artifact surfaces onto two different distribution contracts. That makes the distribution Seam real, and the plugin-side projection deserves a name.
## Decision
Introduce the **Claude Code Plugin Manifest Module** as the Seam that owns the projection of gsd-core's artifact surfaces onto the Claude Code plugin contract. It is the plugin-contract sibling of the Runtime Artifact Layout Module: where that Module projects surfaces onto filesystem placements, this Module projects the same surfaces onto `.claude-plugin/plugin.json` + `hooks/hooks.json`.
The mapping is **defined, not incidental**:
| gsd-core surface / source | Claude Code plugin field | Rule / invariant |
|---|---|---|
| Package Identity Module `binName` | `name` | `gsd-core` — drives the `/gsd-core:` command namespace; must be kebab-case (no colon/space/uppercase). |
| Package Identity Module `repoUrl` | `repository`, `homepage` | derived, never re-typed. |
| `package.json` `version` / `description` / `license` | `version` / `description` / `license` | `version` is **required** for `claude plugin validate --strict` (a missing version is a strict failure), so it is synced to `package.json` and held by a drift-guard test. |
| Command surface (`commands/gsd/*.md`) | `commands: "./commands/gsd/"` | exposed as `/gsd-core:<command>`; namespacing replaces the file-copy path's `/gsd:<command>` (an additive UX change, not a data-format break). |
| Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: <string>` form is rejected by the plugin schema; relying on Claude Code's default `agents/` discovery loads them and stays self-maintaining. Agents are already plugin-safe — their `hooks`/`permissionMode` frontmatter is inert. |
| Always-on hook policy (subset of the Installer Module's `settings.json` wiring) | `hooks: "./hooks/hooks.json"` | see below. |
The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable.
The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root.
This is **additive**. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract.
## What stays OUTSIDE this Module
To keep the Seam honest about where the plugin contract ends:
- **Runtime execution.** The Module projects the command/agent/hook *surface* and lifecycle metadata. It does not make gsd commands self-contained: their backing logic still resolves the gsd runtime CLI (`gsd-tools`) and `node` on `PATH`. The plugin delivers discoverability and lifecycle (`claude plugin enable|disable|update`); it does not replace the runtime.
- **The file-copy install.** Filesystem placement, `settings.json` merge semantics, and per-runtime config rendering remain owned by the Runtime Artifact Layout / Install Policy / Installer Modules.
- **Marketplace listing.** Publishing gsd-core to a marketplace registry is an external, out-of-repo act.
- **Manifest emission by the installer.** Having `bin/install.js` drop the manifest in-place for the npm `@skills-dir` path is a follow-up; the repo-root manifest already serves the marketplace and git-clone `@skills-dir` paths.
## Consequences
- gsd-core gains a one-command install/update/disable lifecycle and automatic `/gsd-core:` namespacing that prevents slash-command collisions, without disturbing the file-copy path.
- The plugin contract gains a named place in the glossary (`CONTEXT.md`) and a defined mapping, so future surface additions have an obvious projection target instead of an ad-hoc file edit.
- **Latent duplication is now named, not hidden.** The always-on hook policy is currently encoded twice — imperatively in the Installer Module's `settings.json` wiring, and declaratively in `hooks/hooks.json` — kept in agreement only by the drift-guard test. This ADR records that as the known cost of a *static* manifest. Elevating the Module from a hand-authored manifest to a **generated projection** (stamping `plugin.json` from the Package Identity Module + `package.json`, and `hooks/hooks.json` from `managed-hooks-registry.cjs` + a shared always-on-hook policy) would collapse the duplication to one source — the same generated-single-source move ADR-457 made for `.cjs` and the Runtime Install Policy Module made for install plans. Deferred; see Open questions.
- The `name` field is a stability surface: it is the published `/gsd-core:` namespace. Changing it is a user-visible break under Hyrum's law, the same way command names are.
- Rollout is incremental: this ADR + the hand-authored manifest land first (#766/PR#797); installer-emit, release-time version stamping, and the generated projection are tracked follow-ups under #766.
## Open questions
- Should this Module be **generated** rather than hand-authored, deriving `version` (and identity) at build/release time so a `package.json` bump cannot leave `plugin.json` stale? The release pipeline bumps via `npm version --no-git-tag-version` with no regeneration hook, so today the drift-guard test enforces the sync manually (idiomatic with the repo's other drift guards, but a release speed-bump).
- Should the always-on-hook policy be lifted into a single shared source consumed by *both* the Installer Module and this Module, retiring the dual hand-encoding?
## References
- ADR-3660 — Runtime Artifact Layout Module (the file-copy sibling: projects the same surfaces onto filesystem placements).
- ADR-58 — Runtime Install Policy Module (typed install-plan projection for the file-copy path).
- ADR-457 — Generated single-source (the precedent a generated manifest projection would follow).
- ADR-0008 — Installer Migration Module (adjacent installer Seam).
- Package Identity Module (`gsd-core/bin/lib/package-identity.cjs`) — source of the manifest's identity fields.
- Installer Module (`bin/install.js`) — owns the `settings.json` always-on hook wiring this Module mirrors for the plugin path.
- `CONTEXT.md` § Glossary — Domain modules and seams (where this Module is registered).
- Claude Code plugin contract: <https://code.claude.com/docs/en/plugins-reference>.

View File

@@ -55,6 +55,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
| [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed |
| [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed |
| [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted |
| [766-claude-code-plugin-manifest-module.md](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted |
## Seam map

View File

@@ -44,6 +44,50 @@ CLAUDE_CONFIG_DIR=~/.claude-alt npx @opengsd/gsd-core@latest --claude --global
---
### Claude Code — native plugin install
GSD Core ships a `.claude-plugin/plugin.json` manifest, which enables installation and lifecycle management through the Claude Code plugin system. This path is **additive** — the npm installer above remains fully supported, and the two approaches differ in namespace and lifecycle only.
**Install paths**
*Option A — marketplace or git install (once listed):*
```bash
claude plugin install gsd-core
```
*Option B — zero-friction skills-dir load:* Claude Code automatically discovers any directory under `~/.claude/skills/` that contains a `.claude-plugin/plugin.json` as a plugin. To use gsd-core this way, place (or symlink) the gsd-core package directory there:
```bash
# Example: place the package under ~/.claude/skills/gsd-core/
# Claude Code loads it as gsd-core@skills-dir on the next session start.
# No explicit install step required.
```
**Command namespace**
Plugin commands are namespaced as `/gsd-core:<command>` — for example, `/gsd-core:plan-phase`. This is distinct from the classic npm/file-copy installer, which exposes commands as `/gsd:<command>`. Use whichever namespace corresponds to your install method.
**Lifecycle**
```bash
claude plugin enable gsd-core
claude plugin disable gsd-core
claude plugin update gsd-core
```
**Hooks**
The plugin wires gsd-core's always-on guard and update hooks automatically via `hooks/hooks.json`. No manual hook registration is required.
**Prerequisites**
The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package) must be available on your `PATH` for gsd commands to execute their backing logic. The plugin delivers the command, agent, and hook surface; the npm package delivers the runtime CLI.
Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<script>"`. Some Claude Code distributions ship as a standalone binary and do not expose a `node` executable on `PATH`; in those environments the plugin's hooks will not run. Verify with `node --version` before relying on the plugin hooks.
---
### Gemini CLI
```bash

40
hooks/hooks.json Normal file
View File

@@ -0,0 +1,40 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-prompt-guard.js\"", "timeout": 5 },
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-guard.js\"", "timeout": 5 }
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js\"", "timeout": 5 }
]
}
],
"PostToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit|Agent|Task",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
]
},
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js\"", "timeout": 5 }
]
}
]
}
}

View File

@@ -12,6 +12,7 @@
"gsd-core",
"assets",
"agents",
".claude-plugin",
"hooks",
"scripts"
],

View File

@@ -0,0 +1,373 @@
'use strict';
/**
* Regression tests for issue #766: additive Claude Code plugin manifest.
*
* Asserts structural and semantic correctness of:
* .claude-plugin/plugin.json — plugin manifest
* hooks/hooks.json — plugin hook wiring
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
// ─── Section A: plugin.json ───────────────────────────────────────────────────
describe('A: .claude-plugin/plugin.json', () => {
let manifest;
test('exists and is valid JSON', () => {
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
manifest = JSON.parse(raw); // throws on invalid JSON
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
});
test('name equals identity.binName ("gsd-core")', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
});
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.match(
manifest.name,
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
);
});
test('version matches package.json version', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
});
test('repository equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
});
test('homepage equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
});
test('license matches package.json license', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.license, pkg.license, 'license must match package.json');
});
test('author.name is a non-empty string', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
'author.name must be a non-empty string'
);
});
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
});
test('hooks field is "./hooks/hooks.json" and that file exists', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.hooks, './hooks/hooks.json', 'hooks must be "./hooks/hooks.json"');
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.hooks);
assert.ok(fs.existsSync(resolvedHooks), `resolved hooks file must exist: ${resolvedHooks}`);
});
test('no "$schema" key (intentionally omitted)', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
});
});
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
describe('B: hooks/hooks.json', () => {
let hooksConfig;
test('exists and is valid JSON with top-level "hooks" object', () => {
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
hooksConfig = JSON.parse(raw);
assert.ok(
typeof hooksConfig === 'object' && hooksConfig !== null &&
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
'hooks.json must have a top-level "hooks" object'
);
});
test('every event name is one of: SessionStart, PreToolUse, PostToolUse', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
const validEvents = new Set(['SessionStart', 'PreToolUse', 'PostToolUse']);
for (const eventName of Object.keys(hooksConfig.hooks)) {
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
}
});
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
for (const entry of eventEntries) {
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
for (const hook of entry.hooks) {
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
assert.ok(
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
);
}
}
}
});
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
const allScripts = [];
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
allScripts.push(m[1]);
}
}
}
}
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
for (const scriptPath of allScripts) {
const fullPath = path.join(ROOT, scriptPath);
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
const basename = path.basename(scriptPath);
assert.ok(
MANAGED_HOOKS.includes(basename),
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
);
}
});
test('all six always-on hooks are wired', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
const REQUIRED_HOOKS = [
'gsd-check-update.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-worktree-path-guard.js',
'gsd-context-monitor.js',
'gsd-read-injection-scanner.js',
];
// Collect all basenames wired in hooks.json
const wiredBasenames = new Set();
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
wiredBasenames.add(m[1]);
}
}
}
}
for (const required of REQUIRED_HOOKS) {
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
}
});
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
let found = false;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
found = true;
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
}
}
}
}
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
});
});
// ─── Section C: Optional CLI integration test ─────────────────────────────────
describe('C: claude plugin validate (CLI integration)', () => {
const claudeAvailable = (() => {
try {
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
return result.status === 0;
} catch (_) {
return false;
}
})();
test(
'claude plugin validate . --strict exits 0 (skip if claude not on PATH)',
{ skip: !claudeAvailable ? 'claude binary not available on PATH' : false },
() => {
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 15000,
});
assert.equal(
result.status,
0,
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
);
});
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
describe('D: always-on hook contract drift guard', () => {
/**
* Parses hooks.json and builds a map:
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
*
* script: basename of the .js/.sh file referenced in the command string
* timeout: numeric value from hook.timeout, or undefined if absent
*/
function buildHookMap() {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
const hooksConfig = JSON.parse(raw);
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
const map = {};
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
map[eventName] = map[eventName] || {};
for (const entry of eventEntries) {
const matcher = entry.matcher || '';
map[eventName][matcher] = map[eventName][matcher] || [];
for (const hook of entry.hooks) {
const m = hook.command.match(scriptRe);
if (m) {
map[eventName][matcher].push({
script: m[1],
timeout: hook.timeout,
});
}
}
}
}
return map;
}
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
const map = buildHookMap();
const groups = map['SessionStart'];
assert.ok(groups, 'SessionStart must be present in hooks.json');
// There must be exactly one entry group (key '' = no matcher)
const noMatcherHooks = groups[''];
assert.ok(
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
);
const h = noMatcherHooks[0];
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
});
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 2,
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
});
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit|MultiEdit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
});
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
});
test('PostToolUse Read group: gsd-read-injection-scanner.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
const hooks = groups['Read'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Read must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
});
});
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
const CONFIG_GATED_HOOKS = [
'gsd-workflow-guard.js',
'gsd-validate-commit.sh',
'gsd-graphify-update.sh',
'gsd-session-state.sh',
'gsd-phase-boundary.sh',
'gsd-update-banner.js',
'gsd-statusline.js',
'gsd-check-update-worker.js',
];
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
// Check raw text — simple and resistant to structure changes
for (const hookBasename of CONFIG_GATED_HOOKS) {
assert.ok(
!raw.includes(hookBasename),
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
`(it is opt-in and must not run unconditionally on the plugin path)`
);
}
});
});