@jaguilar87/gaia 5.1.0-rc.3 → 5.1.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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "gaia",
11
11
  "description": "Security-first multi-agent orchestration for Claude Code. Specialized agents cover the full development lifecycle — analysis, planning, execution, deployment — with codebase-aware context injection. Every command is risk-classified: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
12
- "version": "5.1.0-rc.3",
12
+ "version": "5.1.0",
13
13
  "category": "devops",
14
14
  "author": {
15
15
  "name": "jaguilar87",
@@ -18,7 +18,8 @@
18
18
  "homepage": "https://github.com/metraton/gaia#readme",
19
19
  "source": {
20
20
  "source": "github",
21
- "repo": "metraton/gaia"
21
+ "repo": "metraton/gaia",
22
+ "ref": "v5.1.0"
22
23
  }
23
24
  }
24
25
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia",
3
- "version": "5.1.0-rc.3",
3
+ "version": "5.1.0",
4
4
  "description": "Security-first multi-agent orchestration for Claude Code. Agents span the full lifecycle; commands are risk-classified \u2014 reads run free, state changes need approval, irreversible ops blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87",
@@ -20,177 +20,5 @@
20
20
  "devops",
21
21
  "security",
22
22
  "orchestration"
23
- ],
24
- "hooks": {
25
- "PreToolUse": [
26
- {
27
- "matcher": "Bash",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
32
- }
33
- ]
34
- },
35
- {
36
- "matcher": "Task",
37
- "hooks": [
38
- {
39
- "type": "command",
40
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
41
- }
42
- ]
43
- },
44
- {
45
- "matcher": "Agent",
46
- "hooks": [
47
- {
48
- "type": "command",
49
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
50
- }
51
- ]
52
- },
53
- {
54
- "matcher": "SendMessage",
55
- "hooks": [
56
- {
57
- "type": "command",
58
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
59
- }
60
- ]
61
- },
62
- {
63
- "matcher": "Read|Edit|Write|Glob|Grep|WebSearch|WebFetch|NotebookEdit",
64
- "hooks": [
65
- {
66
- "type": "command",
67
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
68
- }
69
- ]
70
- }
71
- ],
72
- "PostToolUse": [
73
- {
74
- "matcher": "Bash",
75
- "hooks": [
76
- {
77
- "type": "command",
78
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
79
- }
80
- ]
81
- },
82
- {
83
- "matcher": "AskUserQuestion",
84
- "hooks": [
85
- {
86
- "type": "command",
87
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
88
- }
89
- ]
90
- }
91
- ],
92
- "SubagentStop": [
93
- {
94
- "matcher": "*",
95
- "hooks": [
96
- {
97
- "type": "command",
98
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.py"
99
- }
100
- ]
101
- }
102
- ],
103
- "SessionStart": [
104
- {
105
- "matcher": "startup",
106
- "hooks": [
107
- {
108
- "type": "command",
109
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"
110
- }
111
- ]
112
- }
113
- ],
114
- "SessionEnd": [
115
- {
116
- "hooks": [
117
- {
118
- "type": "command",
119
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_end_hook.py"
120
- }
121
- ]
122
- }
123
- ],
124
- "PreCompact": [
125
- {
126
- "hooks": [
127
- {
128
- "type": "command",
129
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_compact.py"
130
- }
131
- ]
132
- }
133
- ],
134
- "PostCompact": [
135
- {
136
- "hooks": [
137
- {
138
- "type": "command",
139
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_compact.py"
140
- }
141
- ]
142
- }
143
- ],
144
- "Stop": [
145
- {
146
- "hooks": [
147
- {
148
- "type": "command",
149
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.py"
150
- }
151
- ]
152
- }
153
- ],
154
- "TaskCompleted": [
155
- {
156
- "hooks": [
157
- {
158
- "type": "command",
159
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/task_completed.py"
160
- }
161
- ]
162
- }
163
- ],
164
- "SubagentStart": [
165
- {
166
- "matcher": "*",
167
- "hooks": [
168
- {
169
- "type": "command",
170
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_start.py"
171
- }
172
- ]
173
- }
174
- ],
175
- "UserPromptSubmit": [
176
- {
177
- "hooks": [
178
- {
179
- "type": "command",
180
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py"
181
- }
182
- ]
183
- }
184
- ],
185
- "ElicitationResult": [
186
- {
187
- "hooks": [
188
- {
189
- "type": "command",
190
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/elicitation_result.py"
191
- }
192
- ]
193
- }
194
- ]
195
- }
23
+ ]
196
24
  }
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.0] - 2026-07-03
11
+
12
+ ## [5.1.0-rc.4] - 2026-07-03
13
+
10
14
  ## [5.1.0-rc.3] - 2026-07-03
11
15
 
12
16
  ## [5.1.0-rc.2] - 2026-07-02
@@ -31,7 +31,7 @@ if [[ "${1:-}" == "--functional" ]]; then
31
31
  fi
32
32
 
33
33
  # ---------------------------------------------------------------------------
34
- # (a) Pack -> prepack regenerates the root inline plugin.json + hooks/hooks.json
34
+ # (a) Pack -> prepack regenerates the root plugin.json (metadata only) + hooks/hooks.json
35
35
  # ---------------------------------------------------------------------------
36
36
  cd "${REPO_ROOT}"
37
37
  echo "[dryrun] packing tarball (prepack regenerates root manifests)..."
@@ -118,15 +118,24 @@ else
118
118
  echo " [PASS] no dist/ in tarball (package root is the plugin)"
119
119
  fi
120
120
 
121
- # The inline-hooks workaround MUST be present, and every referenced entry-point
122
- # file must exist under the plugin root.
123
- if python3 - "${ROOT}/.claude-plugin/plugin.json" "${ROOT}" <<'PY'
121
+ # Hooks are declared ONLY in hooks/hooks.json. plugin.json must NOT embed an
122
+ # inline 'hooks' block -- CC reads both, so an inline block double-registers
123
+ # every hook. Assert its absence, and assert every entry point referenced by
124
+ # hooks/hooks.json exists under the plugin root.
125
+ if python3 - "${ROOT}/.claude-plugin/plugin.json" "${ROOT}/hooks/hooks.json" "${ROOT}" <<'PY'
124
126
  import json, os, re, sys
125
127
 
126
- plugin_json, root = sys.argv[1], sys.argv[2]
127
- data = json.load(open(plugin_json))
128
+ plugin_json, hooks_json, root = sys.argv[1], sys.argv[2], sys.argv[3]
129
+
130
+ plugin = json.load(open(plugin_json))
131
+ assert "hooks" not in plugin, (
132
+ "plugin.json embeds an inline 'hooks' block -- hooks must live only in "
133
+ "hooks/hooks.json (an inline block double-registers every hook)"
134
+ )
135
+
136
+ data = json.load(open(hooks_json))
128
137
  hooks = data.get("hooks")
129
- assert isinstance(hooks, dict) and hooks, "plugin.json is missing a non-empty inline 'hooks' block"
138
+ assert isinstance(hooks, dict) and hooks, "hooks/hooks.json is missing a non-empty 'hooks' block"
130
139
 
131
140
  missing = []
132
141
  for event, entries in hooks.items():
@@ -135,13 +144,13 @@ for event, entries in hooks.items():
135
144
  m = re.search(r"\$\{CLAUDE_PLUGIN_ROOT\}/(\S+\.py)", h.get("command", ""))
136
145
  if m and not os.path.isfile(os.path.join(root, m.group(1))):
137
146
  missing.append(m.group(1))
138
- assert not missing, f"inline hook entry points not found under plugin root: {sorted(set(missing))}"
139
- print(f" inline hooks: {len(hooks)} events, all entry points resolve")
147
+ assert not missing, f"hook entry points not found under plugin root: {sorted(set(missing))}"
148
+ print(f" hooks.json: {len(hooks)} events, all entry points resolve; plugin.json has no inline hooks")
140
149
  PY
141
150
  then
142
- echo " [PASS] inline hooks block valid + entry points resolve"
151
+ echo " [PASS] hooks declared once (hooks.json only) + entry points resolve"
143
152
  else
144
- echo " [FAIL] inline hooks block invalid or entry points missing"
153
+ echo " [FAIL] hooks declaration invalid (inline block present or entry points missing)"
145
154
  fail=1
146
155
  fi
147
156
 
@@ -601,9 +601,24 @@ class PrePublishValidator {
601
601
  if (plugin.version !== expectedVersion) {
602
602
  versionMismatches.push(`marketplace/${plugin.name}: ${plugin.version}`);
603
603
  }
604
+ // For git/github object sources a `source.ref` pins `/plugin install`
605
+ // to a fixed release tag (`v<version>`). release:prepare's
606
+ // bumpMarketplace writes it atomically with the version. Tolerant
607
+ // when absent (refless resolves the repo's default HEAD -- a valid
608
+ // pre-pin state); strict when present so a stale/hand-edited ref that
609
+ // would serve the wrong tag is caught here rather than after publish.
610
+ const src = plugin.source;
611
+ if (src && typeof src === 'object' &&
612
+ (src.source === 'github' || src.source === 'git') &&
613
+ src.ref !== undefined) {
614
+ const expectedRef = `v${expectedVersion}`;
615
+ if (src.ref !== expectedRef) {
616
+ versionMismatches.push(`marketplace/${plugin.name}.source.ref: ${src.ref}`);
617
+ }
618
+ }
604
619
  }
605
620
  if (!versionMismatches.some(m => m.startsWith('marketplace/'))) {
606
- this.log(` ✓ marketplace.json plugin versions match`, 'success');
621
+ this.log(` ✓ marketplace.json plugin versions${marketplaceData.plugins.some(p => p.source && p.source.ref) ? ' + source.ref pins' : ''} match`, 'success');
607
622
  }
608
623
  }
609
624
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.1.0-rc.3",
3
+ "version": "5.1.0",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gaia"
3
- version = "5.1.0-rc.3"
3
+ version = "5.1.0"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -4,9 +4,14 @@ Build script for the gaia plugin.
4
4
 
5
5
  Under the `source: npm` delivery model the published package root IS the
6
6
  plugin -- there is no dist/ bundle. This script only regenerates the two
7
- generated manifests (.claude-plugin/plugin.json with inline hooks, and
7
+ generated manifests (.claude-plugin/plugin.json and the canonical
8
8
  hooks/hooks.json) in place; it never cleans or copies component files.
9
9
 
10
+ Hooks are declared in exactly ONE place: hooks/hooks.json (the standard
11
+ plugin convention Claude Code reads). plugin.json does NOT embed an inline
12
+ `hooks` block -- doing so made Claude Code register every hook twice (once
13
+ from the inline block, once from hooks.json), so every event fired twice.
14
+
10
15
  Usage:
11
16
  python3 scripts/build-plugin.py <plugin-name> --manifests-only [--output-dir <path>]
12
17
 
@@ -233,10 +238,11 @@ def _get_entry_point(event_name: str, entries: list[str]) -> str:
233
238
  def generate_plugin_json(manifest: dict) -> dict:
234
239
  """Generate .claude-plugin/plugin.json from manifest.
235
240
 
236
- Embeds hooks inline under the 'hooks' key so that CC can load them at
237
- runtime from the plugin cache, bypassing the install-time path expansion
238
- bug where ${CLAUDE_PLUGIN_ROOT} is resolved to $CWD/.claude/ instead of
239
- the plugin cache directory.
241
+ Does NOT embed a `hooks` key. Hooks are declared solely in
242
+ hooks/hooks.json (the standard plugin convention Claude Code reads).
243
+ Embedding them inline here as well made CC read BOTH sources and register
244
+ every hook twice, so every event (SessionStart, SessionEnd, ...) fired
245
+ twice. Emitting hooks in exactly one place -- hooks.json -- fixes that.
240
246
  """
241
247
  version = manifest.get("version", "0.0.0")
242
248
  if version == "from:package.json":
@@ -248,12 +254,9 @@ def generate_plugin_json(manifest: dict) -> dict:
248
254
  plugin_name = manifest["plugin_name"]
249
255
  homepage = "https://github.com/metraton/gaia#readme"
250
256
 
251
- # Build inline hooks structure from the same logic as generate_hooks_json().
252
- # CC docs say plugin.json can declare hooks as a path, array, or inline object.
253
- # Inline here avoids install-time path expansion via hooks/hooks.json.
254
- hooks_data = generate_hooks_json(manifest)
255
- inline_hooks = hooks_data["hooks"]
256
-
257
+ # NOTE: no `hooks` key here on purpose. Hooks live only in hooks/hooks.json
258
+ # (generated by generate_hooks_json / write_root_manifests). Declaring them
259
+ # here too caused CC to register each hook twice (double-firing).
257
260
  return {
258
261
  "name": plugin_name,
259
262
  "version": version,
@@ -268,7 +271,6 @@ def generate_plugin_json(manifest: dict) -> dict:
268
271
  "keywords": ["security", "devops"],
269
272
  "engines": {"claude-code": ">=2.1.0"},
270
273
  "categories": ["devops", "security", "orchestration"],
271
- "hooks": inline_hooks,
272
274
  }
273
275
 
274
276
 
@@ -290,11 +292,11 @@ def write_root_manifests(plugin_name: str, output_dir: Path) -> None:
290
292
  """Regenerate ONLY the two plugin manifests into an existing directory.
291
293
 
292
294
  Writes:
293
- - <output_dir>/.claude-plugin/plugin.json (with the inline `hooks` block --
294
- the ${CLAUDE_PLUGIN_ROOT} workaround so CC loads hooks from the plugin
295
- cache under a `source: npm` install)
296
- - <output_dir>/hooks/hooks.json (canonical, manifest-derived;
297
- the npm surface reads this via merge_local_hooks() in _install_helpers.py)
295
+ - <output_dir>/.claude-plugin/plugin.json (metadata only -- NO inline
296
+ `hooks` block; declaring hooks here as well made CC register them twice)
297
+ - <output_dir>/hooks/hooks.json (canonical, manifest-derived,
298
+ the SINGLE source of hook declarations; the npm surface reads this via
299
+ merge_local_hooks() in _install_helpers.py)
298
300
 
299
301
  Unlike build_plugin(), this NEVER cleans (`rmtree`) or copies component files.
300
302
  It is the pack-time / release-prepare mechanism that makes the repo root (and
@@ -345,8 +347,7 @@ def write_root_manifests(plugin_name: str, output_dir: Path) -> None:
345
347
  with open(plugin_json_path, "w") as f:
346
348
  json.dump(plugin_json, f, indent=2)
347
349
  f.write("\n")
348
- inline_events = len(plugin_json.get("hooks", {}))
349
- print(f" Generated: .claude-plugin/plugin.json (inline hooks: {inline_events} events)", file=sys.stderr)
350
+ print(" Generated: .claude-plugin/plugin.json (metadata only, no inline hooks)", file=sys.stderr)
350
351
  print("Root manifests regenerated.", file=sys.stderr)
351
352
 
352
353
 
@@ -2,10 +2,16 @@
2
2
  """Hooks-drift guard: fail if the committed generated hook artifacts diverge
3
3
  from what the manifest would produce.
4
4
 
5
- Background: ``hooks/hooks.json`` and the inline ``hooks`` object embedded in
6
- ``.claude-plugin/plugin.json`` are GENERATED from ``build/gaia.manifest.json``
5
+ Background: ``hooks/hooks.json`` is GENERATED from ``build/gaia.manifest.json``
7
6
  via ``generate_hooks_json`` (scripts/build-plugin.py), invoked by
8
- ``npm run generate:plugin-root``. The manifest is the single source of truth.
7
+ ``npm run generate:plugin-root``. The manifest is the single source of truth,
8
+ and hooks/hooks.json is the SINGLE place hooks are declared.
9
+
10
+ ``.claude-plugin/plugin.json`` must NOT embed an inline ``hooks`` block:
11
+ Claude Code reads both the inline block and hooks/hooks.json, so declaring
12
+ hooks in both registered every hook twice and made every event fire twice.
13
+ This guard asserts hooks/hooks.json matches the manifest AND that plugin.json
14
+ carries no ``hooks`` key.
9
15
 
10
16
  If a committed artifact no longer matches the manifest, someone either
11
17
  hand-edited a generated file or forgot to re-run the generator after editing
@@ -78,14 +84,14 @@ def main() -> int:
78
84
  )
79
85
  _report_diff("hooks/hooks.json", expected, hooks_json)
80
86
 
81
- if plugin_hooks != expected:
87
+ if plugin_hooks:
82
88
  drift = True
83
89
  print(
84
- "hooks-drift guard: .claude-plugin/plugin.json inline hooks != "
85
- "generate_hooks_json(build/gaia.manifest.json)",
90
+ "hooks-drift guard: .claude-plugin/plugin.json embeds an inline "
91
+ "'hooks' block -- hooks must be declared ONLY in hooks/hooks.json. "
92
+ "An inline block makes Claude Code register every hook twice.",
86
93
  file=sys.stderr,
87
94
  )
88
- _report_diff(".claude-plugin/plugin.json", expected, plugin_hooks)
89
95
 
90
96
  if drift:
91
97
  print(
@@ -94,9 +94,25 @@ function bumpMarketplace(rel, version) {
94
94
  const plugins = data.plugins || [];
95
95
  if (plugins.length === 0) throw new Error(`${rel}: no plugins[] to bump`);
96
96
  const befores = plugins.map((p) => `${p.name}=${p.version}`);
97
- for (const plugin of plugins) plugin.version = version;
97
+ const tag = `v${version}`;
98
+ const refUpdates = [];
99
+ for (const plugin of plugins) {
100
+ plugin.version = version;
101
+ // Pin the source to the release tag for git/github sources so
102
+ // `/plugin install` serves a fixed, reproducible tag instead of tracking
103
+ // moving default-branch HEAD. Bumped atomically with the version so the
104
+ // ref can never go stale (the earlier flagged follow-up). Guard: only
105
+ // git/github object sources carry a ref -- npm/local sources must not.
106
+ const src = plugin.source;
107
+ if (src && typeof src === 'object' &&
108
+ (src.source === 'github' || src.source === 'git')) {
109
+ src.ref = tag;
110
+ refUpdates.push(`${plugin.name}.source.ref=${tag}`);
111
+ }
112
+ }
98
113
  writeText(rel, JSON.stringify(data, null, 2) + '\n');
99
- return `${rel}: [${befores.join(', ')}] -> all ${version}`;
114
+ const refNote = refUpdates.length ? ` (${refUpdates.join(', ')})` : '';
115
+ return `${rel}: [${befores.join(', ')}] -> all ${version}${refNote}`;
100
116
  }
101
117
 
102
118
  function bumpPyproject(rel, version) {
@@ -129,7 +129,7 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
129
129
 
130
130
  ## 2. Plugin Packaging
131
131
 
132
- Gaia ships as a **single, unified** plugin named `gaia`. There is **no `dist/` bundle** -- the npm package root (`@jaguilar87/gaia`) IS the plugin. The root `.claude-plugin/plugin.json` (hooks embedded inline) and `hooks/hooks.json` are generated from `build/gaia.manifest.json` at pack time (`prepack` -> `generate:plugin-root`) and tracked in git. One bundle carries all hooks, modules, agents, skills, tools, and config, and every install runs the full orchestrator surface.
132
+ Gaia ships as a **single, unified** plugin named `gaia`. There is **no `dist/` bundle** -- the npm package root (`@jaguilar87/gaia`) IS the plugin. The root `.claude-plugin/plugin.json` (metadata only -- no inline hooks) and `hooks/hooks.json` (the canonical location Claude Code loads hooks from) are generated from `build/gaia.manifest.json` at pack time (`prepack` -> `generate:plugin-root`) and tracked in git. One bundle carries all hooks, modules, agents, skills, tools, and config, and every install runs the full orchestrator surface.
133
133
 
134
134
  ### Security Tiers (quick reference)
135
135
 
@@ -148,7 +148,7 @@ Enforcement: `blocked_commands.py` (permanent deny) + `mutative_verbs.py` (nonce
148
148
 
149
149
  ### Build
150
150
 
151
- There is **no `dist/` bundle** -- the package root IS the plugin. `scripts/build-plugin.py gaia --manifests-only --output-dir .` (run via `npm run generate:plugin-root`) reads `build/gaia.manifest.json`, resolves `"all"` fields to concrete file lists, and regenerates `.claude-plugin/plugin.json` (inline hooks) + `hooks/hooks.json` in place at the package root.
151
+ There is **no `dist/` bundle** -- the package root IS the plugin. `scripts/build-plugin.py gaia --manifests-only --output-dir .` (run via `npm run generate:plugin-root`) reads `build/gaia.manifest.json`, resolves `"all"` fields to concrete file lists, and regenerates `.claude-plugin/plugin.json` (metadata only -- no inline hooks) + `hooks/hooks.json` (the canonical location Claude Code loads hooks from) in place at the package root.
152
152
 
153
153
  ### Publish
154
154
 
@@ -16,10 +16,10 @@ The norm for getting Gaia onto a machine and into the registry, organized as thr
16
16
 
17
17
  Gaia ships as a **single** plugin named `gaia` (`scripts/build-plugin.py` has `VALID_PLUGINS = ("gaia",)`). **The package root IS the plugin, and it is the same tree as the git repo root** -- there is no `dist/` bundle. That single tree reaches a workspace through **two distribution channels**, and a change can pass on one while breaking the other:
18
18
 
19
- - **plugin surface (git marketplace source)** -- Claude Code consumes the **git repository directly**: `.claude-plugin/marketplace.json` advertises the `gaia` plugin with `{"source": "github", "repo": "metraton/gaia"}`, so `/plugin marketplace add` + `/plugin install` make CC **clone the repo** into its plugin cache. **This is the channel that loads Gaia's agents, skills, and hooks in Claude Code.** The git source was adopted (commit `a43ef22`) precisely because the prior `source: npm` entry loaded **0 skills** on `/plugin install` -- a confirmed CC gap -- while the identical tree loads all 32 skills via a git/local source. CC reads the repo root's `.claude-plugin/plugin.json` with **hooks embedded inline** (the `${CLAUDE_PLUGIN_ROOT}` workaround) -- it does **not** use `settings.local.json` for hooks. The root `.claude-plugin/plugin.json` (inline hooks) and `hooks/hooks.json` are **generated from the manifest** (`prepack` / `generate:plugin-root`) and tracked in git, so the cloned tree already carries them. Git sources support version pinning by ref/sha; otherwise version resolution falls back to the marketplace entry's `version` field (still bumped atomically by `release:prepare`, which touches `plugin.version`, not `source`).
19
+ - **plugin surface (git marketplace source)** -- Claude Code consumes the **git repository directly**: `.claude-plugin/marketplace.json` advertises the `gaia` plugin with `{"source": "github", "repo": "metraton/gaia"}`, so `/plugin marketplace add` + `/plugin install` make CC **clone the repo** into its plugin cache. **This is the channel that loads Gaia's agents, skills, and hooks in Claude Code.** The git source was adopted (commit `a43ef22`) precisely because the prior `source: npm` entry loaded **0 skills** on `/plugin install` -- a confirmed CC gap -- while the identical tree loads all 32 skills via a git/local source. CC loads hooks from the repo root's `hooks/hooks.json` (the standard plugin convention) -- it does **not** use `settings.local.json` for hooks, and the root `.claude-plugin/plugin.json` is **metadata only** (no inline `hooks` block). Hooks are declared in exactly ONE place because CC reads both a plugin.json inline block AND `hooks/hooks.json`; declaring them in both double-registered every hook and fired every event twice (fixed in `a1b1245`). Both `.claude-plugin/plugin.json` and `hooks/hooks.json` are **generated from the manifest** (`prepack` / `generate:plugin-root`) and tracked in git, so the cloned tree already carries them. Git sources support version pinning by ref/sha; otherwise version resolution falls back to the marketplace entry's `version` field (still bumped atomically by `release:prepare`, which touches `plugin.version`, not `source`).
20
20
  - **CLI / npm surface (npm package `@jaguilar87/gaia`)** -- `npm|pnpm install @jaguilar87/gaia` (and `gaia dev`) install the npm package; this is what provides the `gaia` **CLI** and the workspace wiring. `gaia install` then wires the workspace: it symlinks `.claude/{agents,tools,hooks,config,skills}` (plus a `CHANGELOG.md` link) to the installed package and merges hook events into `settings.local.json`. The DB is bootstrapped **lazily on first `gaia` CLI use** (`_ensure_db_bootstrapped` in `bin/gaia`) -- there is **no npm `postinstall`** (removed so the install is non-invasive and works identically under npm and pnpm, which ignores lifecycle scripts by default). This surface reads hooks from the package root's generated `hooks/hooks.json` (via `merge_local_hooks` in `_install_helpers.py`), which is regenerated at pack time from the manifest.
21
21
 
22
- The two channels ship from the **same source tree** (git repo root and npm package root are one and the same). That is why Layer 2 exists: the plugin surface can only be proven by materializing the plugin root -- packing the tarball, extracting it, and mounting the extracted root in a live Claude Code -- nothing else exercises the inline `plugin.json` / CC plugin loader.
22
+ The two channels ship from the **same source tree** (git repo root and npm package root are one and the same). That is why Layer 2 exists: the plugin surface can only be proven by materializing the plugin root -- packing the tarball, extracting it, and mounting the extracted root in a live Claude Code -- nothing else exercises the root `plugin.json` / `hooks.json` / CC plugin loader.
23
23
 
24
24
  ## The three intentions
25
25
 
@@ -55,7 +55,7 @@ gaia release check # add --functional for the opt-in live plugin pr
55
55
 
56
56
  1. `pre-publish:validate` -- the version-drift gate (`validate-manifests` in `ci.yml`, via `bin/pre-publish-validate.js --validate-only`). This is what catches a `package.json` / `pyproject.toml` / `plugin.json` / `marketplace.json` desync before it ships.
57
57
  2. `gaia:verify-install:local` -- packs (via the shared `_pack_helpers.pack_tarball`, the same primitive `gaia dev` uses) and installs into a throwaway sandbox (`bin/validate-sandbox.sh --target sandbox`). This proves the **npm surface** of exactly what a registry publish would ship. (This is `gaia-verify` mode `npm-sandbox`.)
58
- 3. **Plugin-surface dry-run** (`bin/plugin-dryrun.sh`) -- packs the tarball itself, extracts it to a throwaway temp dir (the package root IS the plugin), and runs a **headless, offline** gate: filesystem asserts (root inline `plugin.json`, `hooks/hooks.json`, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) plus `claude plugin validate`. It touches no real workspace and spawns no session. `--functional` forwards to the script's own opt-in live `claude --plugin-dir <temp> -p '...'` probe (needs Claude auth/tokens -- never implicit). **SKIPs** (not fails) when the `claude` binary is not on PATH. This is the only place the plugin surface is proven before a tag exists. (This is `gaia-verify` mode `plugin`.) See the plugin-surface principle below for why a green npm sandbox does not cover it.
58
+ 3. **Plugin-surface dry-run** (`bin/plugin-dryrun.sh`) -- packs the tarball itself, extracts it to a throwaway temp dir (the package root IS the plugin), and runs a **headless, offline** gate: filesystem asserts (root `plugin.json` with NO inline `hooks` block, `hooks/hooks.json`, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) plus `claude plugin validate`. It touches no real workspace and spawns no session. `--functional` forwards to the script's own opt-in live `claude --plugin-dir <temp> -p '...'` probe (needs Claude auth/tokens -- never implicit). **SKIPs** (not fails) when the `claude` binary is not on PATH. This is the only place the plugin surface is proven before a tag exists. (This is `gaia-verify` mode `plugin`.) See the plugin-surface principle below for why a green npm sandbox does not cover it.
59
59
  4. `npm test` -- the L1 suite (the tests CI runs that reproduce locally).
60
60
 
61
61
  A `check` run that reports `FAIL` on gate 1 has failed a *subset* of CI, not passed a stand-in for it; a `FAIL`/`SKIP` on gate 3 means the plugin surface was never run (SKIP only when `claude` is genuinely absent) -- both gaps surface only after publish, when the fix costs another release. For the raw npm-script forms each gate wraps (useful when diagnosing which gate failed), see `reference.md` -> "Layer 2 runbook".
@@ -73,7 +73,7 @@ gaia release publish [version] # add --dry-run to preview the seque
73
73
  | Step | Action | Notes |
74
74
  |------|--------|-------|
75
75
  | **(a)** | Determine the version | Default to the next **patch**. If the change is major/minor, **confirm with the user** (`NEEDS_INPUT`) before proceeding -- never silently pick major/minor. Pass the confirmed version as `gaia release publish`'s argument, or let it default to `patch`. |
76
- | **(b)** | `release:prepare <version>` -- `gaia release publish` step 1 | The atomic core: bumps the hand-owned version sources at once (`package.json`, `pyproject.toml`, `.claude-plugin/marketplace.json`, `CHANGELOG.md`), runs `generate:plugin-root` (regenerating the ROOT `.claude-plugin/plugin.json` inline-hooks + `hooks/hooks.json` from the manifest -- `plugin.json` version is inherited from `package.json`, not hand-bumped), then `pre-publish:validate`. Fails loud on any drift. No `dist/` bundle. This wraps `scripts/release-prepare.mjs` -- invoked by the flow, never run by hand. |
76
+ | **(b)** | `release:prepare <version>` -- `gaia release publish` step 1 | The atomic core: bumps the hand-owned version sources at once (`package.json`, `pyproject.toml`, `.claude-plugin/marketplace.json`, `CHANGELOG.md`), runs `generate:plugin-root` (regenerating the ROOT `.claude-plugin/plugin.json` (metadata only) + `hooks/hooks.json` from the manifest -- `plugin.json` version is inherited from `package.json`, not hand-bumped), then `pre-publish:validate`. Fails loud on any drift. No `dist/` bundle. This wraps `scripts/release-prepare.mjs` -- invoked by the flow, never run by hand. |
77
77
  | **(c)** | Pre-flight that reproduces CI -- step 2 | `pre-publish:validate` already ran inside (b). Now `npm test` runs so the local gate matches CI before the tag exists. Layer 2 should already be green. |
78
78
  | **(d)** | Commit -- step 3 | `git add` (the version-source paths only) + `git commit` -- local-safe, not T3. Idempotent: nothing-to-commit on a tree already at the target version is a PASS, not a failure. |
79
79
  | **(e)** | Tag, **force-free** -- step 4 | A *new* annotated tag (`v<version>`); never moves an existing one. If the remote diverged, reconcile with **merge, not rebase** (rebase forces a tag move, hard-denied locally). See `reference.md` -> "Reconciling a diverged remote". |
@@ -103,7 +103,7 @@ A fresh install or an edit is invisible until Claude Code picks it up, and *how*
103
103
  ## Principles -- why the sequence is normed, not optional
104
104
 
105
105
  - **The pre-flight reproduces what CI validates, not a subset of it.** When the local check skips a gate CI runs (`pre-publish:validate`), that gate's failures surface only *after* publishing, on the published tarball, where the only remedy is another release. That is exactly how a `pyproject.toml` drift shipped green-local and red-CI. Layer 2 step 1 and Layer 3 step (c) close the gap. See `reference.md` -> "The pre-flight reproduces what CI validates".
106
- - **The plugin surface is only proven by packing the tarball and mounting the extracted root.** The npm sandbox exercises the symlink / `settings.local.json` path; it never touches the root inline `plugin.json` / `hooks.json` or CC's plugin loader. The tarball can be missing files, carry broken inline hooks, or fail to expose `bin/gaia` on PATH -- and none of that shows until CC mounts it. Layer 2 step 3 (`gaia:plugin-dryrun` -- pack, extract, headless validate) is the only pre-tag check that runs the plugin surface. Skipping it means the plugin breaks silently in production.
106
+ - **The plugin surface is only proven by packing the tarball and mounting the extracted root.** The npm sandbox exercises the symlink / `settings.local.json` path; it never touches the root `plugin.json` / `hooks.json` or CC's plugin loader. The tarball can be missing files, carry a broken `hooks.json`, or fail to expose `bin/gaia` on PATH -- and none of that shows until CC mounts it. Layer 2 step 3 (`gaia:plugin-dryrun` -- pack, extract, headless validate) is the only pre-tag check that runs the plugin surface. Skipping it means the plugin breaks silently in production.
107
107
  - **Bump every version source in one step, never one at a time.** `pre-publish:validate` requires `package.json`, `pyproject.toml`, `.claude-plugin/plugin.json` (generated), `.claude-plugin/marketplace.json`, and the `CHANGELOG.md` top header to agree. `release:prepare` writes the hand-owned sources from one target version and regenerates `plugin.json` (version inherited from `package.json`), so a hand-desync is impossible. See `scripts/release-prepare.mjs`.
108
108
  - **Tag force-free; reconcile with merge, never rebase.** `publish.yml` no longer commits artifacts back to `main` (read-only checkout), so the remote does not auto-advance on release. But if the remote ever diverges, rebasing rewrites hashes and forces a tag move (`git tag -f` / `--force`), hard-denied by local hooks (`git_destructive` in `blocked_commands.py`, exit 2, not approvable). Merge preserves hashes and tags; a new release gets a *new* tag, never a moved one. See `reference.md` -> "Reconciling a diverged remote".
109
109
  - **A release ends at an installed, validated version -- not at the tag.** Pushing the tag only starts `publish.yml`. The intention is not satisfied until the workflow reaches its outcome, npm serves the new version, and it installs cleanly into the target (Layer 3 step (h)).
@@ -117,7 +117,7 @@ A fresh install or an edit is invisible until Claude Code picks it up, and *how*
117
117
  - **Bumping version sources one at a time** -- desyncs a source by hand; `pre-publish:validate` rejects the tree and a forgotten file ships if the check is skipped. Always go through `gaia release publish` (which invokes `release:prepare`), never a hand-edit.
118
118
  - **Rebase to reconcile a diverged remote** -- forces a tag move, hard-denied locally. Merge instead.
119
119
  - **Single-surface testing** -- a change can pass the npm sandbox and break the plugin mount, or vice versa. Layer 2 runs both surfaces for a reason.
120
- - **Stale root manifests** -- editing `build/gaia.manifest.json` or a hook entry without regenerating means the tarball ships old inline hooks. `prepack` regenerates at every `npm pack`, and `release:prepare` regenerates via `generate:plugin-root`; the dry-run packs fresh, so a stale manifest surfaces there.
120
+ - **Stale root manifests** -- editing `build/gaia.manifest.json` or a hook entry without regenerating means the tarball ships a stale `hooks/hooks.json`. `prepack` regenerates at every `npm pack`, and `release:prepare` regenerates via `generate:plugin-root`; the dry-run packs fresh, so a stale manifest surfaces there.
121
121
  - **Blanket "restart Claude Code"** -- npm/pnpm hooks reload automatically; only the plugin surface needs `/reload-plugins`, and only a slash-command change needs a full restart. See "Reloading a change".
122
122
  - **Assuming a `postinstall` ran** -- there is none. The DB is bootstrapped lazily on first `gaia` CLI use; under pnpm a lifecycle hook would never fire anyway. If the DB is missing, run any `gaia` command (or `gaia install`), not "re-run postinstall".
123
123
  - **Local npm publish** -- bypasses the pipeline's pack + validate + sandbox gate.
@@ -37,7 +37,7 @@ gaia install --workspace <TARGET>
37
37
  cd /home/jorge/ws/me/gaia
38
38
  npm run gaia:install-local -- --workspace <TARGET>
39
39
  ```
40
- `gaia:install-local` runs `npm pack` (whose `prepack` regenerates the root inline `plugin.json` + `hooks/hooks.json`) + `validate-sandbox.sh --target local`. `gaia dev --mode pack` auto-detects npm vs pnpm from the target workspace, so it covers this case too; use the raw npm script only when diagnosing.
40
+ `gaia:install-local` runs `npm pack` (whose `prepack` regenerates the root `plugin.json` (metadata only) + `hooks/hooks.json`) + `validate-sandbox.sh --target local`. `gaia dev --mode pack` auto-detects npm vs pnpm from the target workspace, so it covers this case too; use the raw npm script only when diagnosing.
41
41
 
42
42
  **fresh (wipe install metadata first):**
43
43
  Append `--fresh` to the `validate-sandbox.sh` form, or manually clear `node_modules/ package.json package-lock.json` (or the pnpm equivalents) in `<TARGET>` before reinstalling. Use when a prior install left state you want gone.
@@ -101,7 +101,7 @@ Sandbox path prints on exit; inspect `.claude/`, rerun checks, then `rm -rf` man
101
101
  ```
102
102
  npm run gaia:plugin-dryrun # pack -> temp extract -> headless validate -> trap cleanup
103
103
  ```
104
- `bin/plugin-dryrun.sh` packs the exact npm tarball (its `prepack` regenerates the root inline `plugin.json` + `hooks/hooks.json`), extracts it to a throwaway temp dir (the package root IS the plugin), and runs a headless, offline gate: filesystem asserts (root inline `plugin.json`, `hooks/hooks.json`, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) + `claude plugin validate`. It touches no real workspace and spawns no session; both temps are removed by an EXIT trap. `gaia release check` SKIPs (not fails) this gate when `claude` is not on PATH.
104
+ `bin/plugin-dryrun.sh` packs the exact npm tarball (its `prepack` regenerates the root `plugin.json` (metadata only) + `hooks/hooks.json`), extracts it to a throwaway temp dir (the package root IS the plugin), and runs a headless, offline gate: filesystem asserts (root `plugin.json` present with NO inline `hooks` block, `hooks/hooks.json` with every entry point resolving, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) + `claude plugin validate`. It touches no real workspace and spawns no session; both temps are removed by an EXIT trap. `gaia release check` SKIPs (not fails) this gate when `claude` is not on PATH.
105
105
 
106
106
  For an optional live functional probe (needs Claude auth/tokens -- opt-in, never implicit):
107
107
  ```
@@ -139,7 +139,7 @@ gaia release publish --dry-run [version] # preview the six-step sequence, exe
139
139
  1. Layer 2 (pre-release) must be green first -- run `gaia release check`.
140
140
  2. **`release:prepare <version>`** (`gaia release publish` step 1) -- the atomic bump. This wraps `scripts/release-prepare.mjs`, invoked by the flow, **never run by the user by hand**. In one command it:
141
141
  - writes `<version>` to the hand-owned sources at once -- `package.json`, `pyproject.toml`, `.claude-plugin/marketplace.json` (top-level `version`), and the `CHANGELOG.md` top header (inserts a dated stub above the current top if absent -- edit its body before release);
142
- - runs `npm run generate:plugin-root` to regenerate the ROOT `.claude-plugin/plugin.json` (inline hooks) + `hooks/hooks.json` from the manifest -- `plugin.json`'s version is inherited from `package.json` (`from:package.json`), so it is NOT hand-bumped. No `dist/` bundle;
142
+ - runs `npm run generate:plugin-root` to regenerate the ROOT `.claude-plugin/plugin.json` (metadata only -- no inline hooks) + `hooks/hooks.json` from the manifest -- `plugin.json`'s version is inherited from `package.json` (`from:package.json`), so it is NOT hand-bumped. No `dist/` bundle;
143
143
  - runs `npm run pre-publish:validate` and fails loud on any drift.
144
144
 
145
145
  This replaces hand-bumping one file at a time. The two real escapes a hand-bump leaves are a `pyproject.toml` left behind on a prior version (caught only by `pre-publish:validate`) and a `marketplace.json` that still advertises the old top-level version. `release:prepare` makes the desync impossible because all hand-owned sources are written from one target version and `plugin.json` is generated from it. For a bare semver: `5.0.5` for stable, `5.1.0-rc.1` for RC (no leading `v` -- the tag adds it). Idempotent: re-running with the same version is a no-op bump that re-validates.
@@ -177,7 +177,7 @@ Symptoms encountered in real install sessions, with the root cause and the fix.
177
177
  | `.claude/` not wired after install | `gaia install` not run, or it exited non-zero | `cat ~/.gaia/last-install-error.json` (written by `gaia install` on failure). Re-run `gaia install --workspace <path>`. If it persists, file a bug. |
178
178
  | `gaia doctor` walks up to the user `.claude/` instead of the workspace | Workspace not initialized (`.claude/` missing or no `plugin-registry.json`) | Re-run `gaia install --workspace <path>`. |
179
179
  | DB missing / `no such table` on first use | Lazy bootstrap did not run (e.g. `gaia` never invoked yet) | Run any `gaia` command (it triggers `_ensure_db_bootstrapped`), or `gaia install` for the full seed. There is no postinstall to "re-run". |
180
- | Plugin mounts but hooks never fire | Generated inline `plugin.json` / `hooks.json` broken at the package root, or CC did not reload | Regenerate (`npm run generate:plugin-root`), re-run `npm run gaia:plugin-dryrun`, and `/reload-plugins`. Inspect the root `.claude-plugin/plugin.json` for the inline `hooks` block. |
180
+ | Plugin mounts but hooks never fire | Generated `hooks/hooks.json` broken at the package root, or CC did not reload | Regenerate (`npm run generate:plugin-root`), re-run `npm run gaia:plugin-dryrun`, and `/reload-plugins`. Inspect the root `hooks/hooks.json` (the canonical hook source); `.claude-plugin/plugin.json` is metadata only and must NOT carry an inline `hooks` block. |
181
181
  | `bootstrap exited 1: table projects has no column named identity` | Schema/bootstrap drift (old seed SQL) | Update to a build whose seed matches the current schema. |
182
182
  | `Permission denied` invoking a hook `.py` | Exec bit lost on cross-platform checkout | Hooks are invoked via `python3 <script>` (see `build-plugin.py` `generate_hooks_json`), so the exec bit should not matter; if it does, update to a build that uses the `python3 <script>` invoker. |
183
183
  | Agent says "no conozco Gaia" or "developer agent does not exist" | `settings.local.json` missing or mis-wired (npm surface) | Re-run `gaia install`. In plugin surface, `/reload-plugins`. |
@@ -205,7 +205,7 @@ A green pre-flight only protects the release if it runs the same gates CI runs.
205
205
  **Pre-publish gate (Layer 2):**
206
206
  - `pytest tests/` green (or `npm test` for the L1 subset).
207
207
  - `npm run pre-publish:validate` green locally -- the version-drift gate (`validate-manifests` in `ci.yml`).
208
- - `npm pack --dry-run` confirms `scripts/bootstrap_database.sh`, `.claude-plugin/plugin.json` (with inline hooks), and `hooks/hooks.json` are included in the tarball, and that `dist/` is NOT.
208
+ - `npm pack --dry-run` confirms `scripts/bootstrap_database.sh`, `.claude-plugin/plugin.json` (metadata only), and `hooks/hooks.json` (the canonical hook source) are included in the tarball, and that `dist/` is NOT.
209
209
  - `npm run gaia:verify-install:local` green (npm surface).
210
210
  - `npm run gaia:plugin-dryrun` green (plugin surface -- pack + extract + headless validate).
211
211
 
@@ -214,7 +214,7 @@ A green pre-flight only protects the release if it runs the same gates CI runs.
214
214
  The workflow at `.github/workflows/publish.yml` runs on every GitHub Release event (read-only checkout -- it neither commits nor pushes). It:
215
215
  - Checks out the exact tagged commit.
216
216
  - Installs deps with `npm ci`.
217
- - Packs the tarball with `npm pack` -- `prepack` (clean + `generate:plugin-root`) regenerates the root inline `plugin.json` + `hooks/hooks.json`, so the tarball root is a valid plugin root (the same tree the git plugin source serves). No `dist/` bundle is built or committed back.
217
+ - Packs the tarball with `npm pack` -- `prepack` (clean + `generate:plugin-root`) regenerates the root `plugin.json` (metadata only) + `hooks/hooks.json`, so the tarball root is a valid plugin root (the same tree the git plugin source serves). No `dist/` bundle is built or committed back.
218
218
  - Runs `npm run pre-publish:validate` (after pack, so it sees the fresh root manifests).
219
219
  - Auto-detects npm tag from the version string: `*-rc.*` -> `rc`, `*-beta.*` -> `beta`, `*-alpha.*` -> `alpha`, else -> `latest`.
220
220
  - Runs the sandbox validation harness against the packed tarball (the gate).