@jaguilar87/gaia 5.2.0-rc.1 → 5.2.0-rc.2

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.2.0-rc.1",
12
+ "version": "5.2.0-rc.2",
13
13
  "category": "devops",
14
14
  "author": {
15
15
  "name": "jaguilar87",
@@ -19,7 +19,7 @@
19
19
  "source": {
20
20
  "source": "github",
21
21
  "repo": "metraton/gaia",
22
- "ref": "v5.2.0-rc.1"
22
+ "ref": "v5.2.0-rc.2"
23
23
  }
24
24
  }
25
25
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia",
3
- "version": "5.2.0-rc.1",
3
+ "version": "5.2.0-rc.2",
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",
package/CHANGELOG.md CHANGED
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
29
29
  - `gaia context prune-workspaces --yes` is now correctly classified T3 (state-mutating): it hard-deletes `workspaces` rows, but the `context` group carried no mutative verb and classified read-only by elimination. Only the destructive subcommand is anchored (`COMMAND_SUBCOMMAND_MUTATIVE_UPGRADES[("gaia","context")]`); other `context` subcommands stay read-only. Separately, the Step 5 ALWAYS-dangerous flag scan now runs before the read-only-verb early return, so `git fetch --prune` (a read-only verb with a destructive flag) escalates to T3 instead of being skipped.
30
30
  - SubagentStop M4 fence footgun: a turn that built its contract via the `gaia contract` CLI and ran `gaia contract finalize` (valid terminal row) but forgot to echo the fenced `agent_contract_handoff` in its response text was hard-rejected by the full-verdict gate. `adapt_subagent_stop` now reconstructs the envelope from the agent's own finalized draft when the fence is missing, so the gate parses the completed contract; non-fatal (falls back to the unchanged gate when no finalized row exists). The minted-agent-id resolver was factored into a shared `resolve_minted_agent_id` reused by the backstop, truncation salvage, and this path.
31
31
 
32
+ ## [5.2.0-rc.2] - 2026-07-17
33
+
32
34
  ## [5.2.0-rc.1] - 2026-07-17
33
35
 
34
36
  ## [5.1.3] - 2026-07-07
package/bin/cli/doctor.py CHANGED
@@ -103,7 +103,16 @@ def _derive_workspace(override: str = None) -> Path:
103
103
  Algorithm
104
104
  ---------
105
105
  1. If *override* is given (from --workspace), validate it has .claude/
106
- and return it directly.
106
+ and return it directly. (Highest priority -- always wins.)
107
+ 1b. Else if the ``GAIA_WORKSPACE_PATH`` env var is set AND points at a dir
108
+ containing ``.claude/``, return it. This is set by the Windows launcher
109
+ (see install.py Step 6.5), which bakes the resolved workspace so a
110
+ global-shim invocation resolves the SAME workspace POSIX derives from
111
+ ``__file__``. Without this, the npm global ``gaia.cmd`` runs ``bin/gaia``
112
+ from the npm prefix, ``__file__`` lands there, and the derivation below
113
+ yields the npm prefix as the "workspace" -- a false CRITICAL. An env var
114
+ that is unset OR points at a dir without ``.claude/`` falls through to
115
+ the ``__file__`` derivation (it is a hint, not an override).
107
116
  2. Resolve Path(__file__) to its realpath and find the FIRST (leftmost)
108
117
  ``node_modules`` path segment; the workspace is everything before it.
109
118
  3. Sanity-check that ``@jaguilar87`` and ``gaia`` both appear somewhere
@@ -148,6 +157,16 @@ def _derive_workspace(override: str = None) -> Path:
148
157
  sys.exit(2)
149
158
  return ws
150
159
 
160
+ # --- GAIA_WORKSPACE_PATH env (baked by the Windows launcher) ---
161
+ # Consulted BEFORE the __file__ derivation but AFTER --workspace. Only
162
+ # honored when it resolves to a dir with .claude/; otherwise fall through
163
+ # (never a cwd walk-up -- that was removed on purpose). See docstring 1b.
164
+ env_ws = os.environ.get("GAIA_WORKSPACE_PATH")
165
+ if env_ws:
166
+ candidate = Path(env_ws).expanduser().resolve()
167
+ if (candidate / ".claude").is_dir():
168
+ return candidate
169
+
151
170
  # --- Derive from __file__ realpath ---
152
171
  script_path = Path(__file__).resolve()
153
172
  parts = script_path.parts
@@ -61,8 +61,9 @@ Flags:
61
61
  Useful when running install just to refresh the DB
62
62
  schema from a non-Gaia directory.
63
63
  --no-path Skip creating the ~/.local/bin/gaia launcher. By default
64
- install writes a workspace-aware bash launcher so `gaia`
65
- is callable from any cwd.
64
+ install writes a workspace-aware launcher so `gaia` is
65
+ callable from any cwd: a bash launcher on POSIX, and
66
+ `gaia.cmd` + `gaia.ps1` on Windows.
66
67
  """
67
68
 
68
69
  from __future__ import annotations
@@ -92,30 +93,90 @@ _SEED_SURFACE_ROUTING = _PACKAGE_ROOT / "tools" / "scan" / "seed_surface_routing
92
93
 
93
94
 
94
95
  # ---------------------------------------------------------------------------
95
- # PATH launcher (~/.local/bin/gaia -- workspace-bound bash launcher)
96
+ # PATH launcher (~/.local/bin/gaia -- workspace-bound launcher)
96
97
  # ---------------------------------------------------------------------------
97
-
98
- # Hardcoded launcher template. The workspace path is resolved at install time
99
- # (the cwd of `gaia install`) and baked into the script verbatim. No discovery,
100
- # no env vars, no fallbacks -- the shim is a 3-line exec to a fixed path.
101
98
  #
102
- # Rationale: the previous workspace-aware launcher walked up from $PWD looking
103
- # for node_modules/@jaguilar87/gaia/, which failed silently from any cwd
104
- # outside the consumer workspace tree (e.g. /tmp). Same conceptual bug that
105
- # rc.5 fixed in doctor.py for the Python layer.
99
+ # Platform split (Step 6.5): the launcher form is chosen by platform. This is a
100
+ # hard guard, not a preference -- a bash script has no meaning to the Windows
101
+ # shell (PowerShell opens an "open with" dialog on an extensionless file), and
102
+ # the POSIX shim's ``{workspace}/node_modules/...`` target does not exist under
103
+ # ``npm install -g`` on any platform. So:
104
+ #
105
+ # * POSIX -> one bash launcher at ``<link>`` (unchanged behavior).
106
+ # * Windows -> ``<link>.cmd`` and ``<link>.ps1``, each of which
107
+ # (a) bakes the resolved workspace path,
108
+ # (b) exports ``GAIA_WORKSPACE_PATH`` so `gaia doctor` resolves the
109
+ # correct workspace from the env instead of deriving it from
110
+ # ``__file__`` (which, via the npm global shim, lands in the npm
111
+ # prefix and yields a false CRITICAL -- see doctor._derive_workspace),
112
+ # (c) execs the ACTUAL installed ``bin/gaia`` dispatcher
113
+ # (``_gaia_entrypoint()`` = ``<package_root>/bin/gaia``), which is
114
+ # valid for BOTH a global (`-g`) and a local install, unlike the
115
+ # POSIX shim's workspace-relative ``node_modules`` path.
106
116
  #
107
- # Re-running `gaia install` from a different workspace rewrites the shim to
108
- # point at that workspace -- the install action is what selects which Gaia
109
- # install ~/.local/bin/gaia targets.
117
+ # PATH precedence vs npm's own shim (Windows): `npm install -g @jaguilar87/gaia`
118
+ # writes its own ``gaia.cmd`` into the npm global prefix (on PATH), and that
119
+ # shim execs ``bin/gaia`` WITHOUT setting GAIA_WORKSPACE_PATH -- which is
120
+ # exactly the origin of the false-CRITICAL bug. Gaia's launcher coexists with
121
+ # and wins over npm's by being written to Gaia's own bin dir (default
122
+ # ``~/.local/bin``): when that dir precedes the npm prefix on PATH, cmd.exe /
123
+ # PowerShell resolve ``gaia`` (``gaia.cmd`` / ``gaia.ps1``) to Gaia's launcher,
124
+ # which sets GAIA_WORKSPACE_PATH before dispatching. Where Gaia's dir is NOT
125
+ # ahead of the npm prefix, npm's shim still runs and still dispatches correctly
126
+ # for workspace-independent subcommands; only the workspace-derivation path
127
+ # needs the env var, and the doctor `__file__` fallback keeps working. The
128
+ # env-var contract is the load-bearing coupling, not the PATH order.
129
+ #
130
+ # Re-running `gaia install` from a different workspace rewrites the launcher(s)
131
+ # to point at that workspace -- the install action is what selects which
132
+ # workspace the launcher targets.
133
+
134
+ # POSIX bash launcher. The workspace path is resolved at install time and baked
135
+ # in verbatim. No discovery, no env vars, no fallbacks -- a 3-line exec.
110
136
  _LAUNCHER_TEMPLATE = """#!/bin/bash
111
137
  # gaia -- workspace-bound launcher (workspace path hardcoded at install time)
112
138
  # Generated by `gaia install`. Re-run install from another workspace to retarget.
113
139
  exec python3 "{workspace_path}/node_modules/@jaguilar87/gaia/bin/gaia" "$@"
114
140
  """
115
141
 
142
+ # Windows launchers. Both bake the resolved workspace, export GAIA_WORKSPACE_PATH,
143
+ # and dispatch to the ACTUAL installed bin/gaia (global- or local-install safe).
144
+ _CMD_LAUNCHER_TEMPLATE = """@echo off
145
+ REM gaia -- workspace-bound launcher (generated by `gaia install`)
146
+ REM Re-run install from another workspace to retarget. Exports GAIA_WORKSPACE_PATH
147
+ REM so `gaia doctor` resolves this workspace instead of deriving from __file__.
148
+ set "GAIA_WORKSPACE_PATH={workspace_path}"
149
+ python "{gaia_bin}" %*
150
+ """
151
+
152
+ _PS1_LAUNCHER_TEMPLATE = """# gaia -- workspace-bound launcher (generated by `gaia install`)
153
+ # Re-run install from another workspace to retarget. Exports GAIA_WORKSPACE_PATH
154
+ # so `gaia doctor` resolves this workspace instead of deriving from __file__.
155
+ $env:GAIA_WORKSPACE_PATH = "{workspace_path}"
156
+ & python "{gaia_bin}" @args
157
+ exit $LASTEXITCODE
158
+ """
159
+
160
+
161
+ def _is_windows() -> bool:
162
+ """True on Windows. Isolated so the platform guard is trivially patchable."""
163
+ return sys.platform == "win32"
164
+
165
+
166
+ def _gaia_entrypoint() -> Path:
167
+ """Absolute path to the installed ``bin/gaia`` dispatcher.
168
+
169
+ This is ``<package_root>/bin/gaia`` -- the real location of the running
170
+ Gaia package, which resolves correctly under both a global (`npm i -g`)
171
+ and a local install. The POSIX shim's ``{workspace}/node_modules/...``
172
+ assumption breaks under `-g` (there is no workspace-relative node_modules);
173
+ the Windows launchers bake this resolved path instead.
174
+ """
175
+ return _PACKAGE_ROOT / "bin" / "gaia"
176
+
116
177
 
117
178
  def _render_launcher(workspace: Path) -> str:
118
- """Render the launcher script with the workspace path baked in.
179
+ """Render the POSIX bash launcher with the workspace path baked in.
119
180
 
120
181
  The workspace must be an absolute, resolved path -- the rendered script
121
182
  references it verbatim. Quoting in the template uses double quotes so
@@ -124,21 +185,40 @@ def _render_launcher(workspace: Path) -> str:
124
185
  return _LAUNCHER_TEMPLATE.format(workspace_path=str(workspace))
125
186
 
126
187
 
188
+ def _render_cmd_launcher(workspace: Path, gaia_bin: Path) -> str:
189
+ """Render the Windows ``gaia.cmd`` launcher (workspace + bin baked in)."""
190
+ return _CMD_LAUNCHER_TEMPLATE.format(
191
+ workspace_path=str(workspace), gaia_bin=str(gaia_bin)
192
+ )
193
+
194
+
195
+ def _render_ps1_launcher(workspace: Path, gaia_bin: Path) -> str:
196
+ """Render the Windows ``gaia.ps1`` launcher (workspace + bin baked in)."""
197
+ return _PS1_LAUNCHER_TEMPLATE.format(
198
+ workspace_path=str(workspace), gaia_bin=str(gaia_bin)
199
+ )
200
+
201
+
127
202
  def _install_path_launcher(
128
203
  target_path: Path | None = None,
129
204
  link_path: Path | str = "~/.local/bin/gaia",
130
205
  overwrite: bool = False,
131
206
  workspace: Path | str | None = None,
207
+ gaia_bin: Path | str | None = None,
132
208
  ) -> dict:
133
- """Install the workspace-bound bash launcher at `link_path`.
209
+ """Install the workspace-bound launcher at `link_path`.
210
+
211
+ Platform-guarded (Step 6.5): on Windows this writes ``<link>.cmd`` and
212
+ ``<link>.ps1`` (see ``_install_windows_launchers``); on POSIX it writes a
213
+ single bash launcher at ``<link>``. The POSIX behavior below is unchanged.
134
214
 
135
- The launcher is a 3-line script that execs into a hardcoded absolute path
136
- pointing at ``<workspace>/node_modules/@jaguilar87/gaia/bin/gaia``. There
137
- is no discovery logic, no env-var override, no fallback chain -- the path
138
- is fixed at install time and only changes when ``gaia install`` runs again
139
- from a different workspace.
215
+ The POSIX launcher is a 3-line script that execs into a hardcoded absolute
216
+ path pointing at ``<workspace>/node_modules/@jaguilar87/gaia/bin/gaia``.
217
+ There is no discovery logic, no env-var override, no fallback chain -- the
218
+ path is fixed at install time and only changes when ``gaia install`` runs
219
+ again from a different workspace.
140
220
 
141
- Behavior:
221
+ Behavior (POSIX):
142
222
  - If `link_path` is a symlink (legacy install): unlink, write launcher.
143
223
  - If `link_path` is a regular file with the expected content: noop.
144
224
  - If `link_path` is a regular file with different content and
@@ -151,10 +231,14 @@ def _install_path_launcher(
151
231
  target_path: accepted for API compatibility; the launcher embeds the
152
232
  workspace path instead. Ignored.
153
233
  link_path: where the shim is written (default ``~/.local/bin/gaia``).
234
+ On Windows, ``.cmd``/``.ps1`` suffixes are derived from this base.
154
235
  overwrite: replace existing different-content files when True.
155
236
  workspace: absolute path to the consumer workspace (the directory
156
237
  that contains ``node_modules/@jaguilar87/gaia/``). Resolved with
157
238
  ``Path.cwd().resolve()`` when None.
239
+ gaia_bin: Windows only -- the ``bin/gaia`` dispatcher the launchers
240
+ exec. Defaults to ``_gaia_entrypoint()`` (the installed package's
241
+ ``bin/gaia``, valid for global and local installs). Ignored on POSIX.
158
242
 
159
243
  Returns a dict with `action`, `path`, and `details`. `action` is one
160
244
  of: created, replaced, migrated, noop, skipped, error.
@@ -167,6 +251,20 @@ def _install_path_launcher(
167
251
  else:
168
252
  workspace_resolved = Path(workspace).expanduser().resolve()
169
253
 
254
+ # Windows: emit gaia.cmd + gaia.ps1 instead of a bash shim. The POSIX path
255
+ # below is left entirely unchanged.
256
+ if _is_windows():
257
+ entry = (
258
+ Path(gaia_bin).expanduser() if gaia_bin is not None
259
+ else _gaia_entrypoint()
260
+ )
261
+ return _install_windows_launchers(
262
+ link=link,
263
+ workspace=workspace_resolved,
264
+ gaia_bin=entry,
265
+ overwrite=overwrite,
266
+ )
267
+
170
268
  parent = link.parent
171
269
  try:
172
270
  parent.mkdir(parents=True, exist_ok=True)
@@ -268,6 +366,86 @@ def _install_path_launcher(
268
366
  }
269
367
 
270
368
 
369
+ def _install_windows_launchers(
370
+ link: Path,
371
+ workspace: Path,
372
+ gaia_bin: Path,
373
+ overwrite: bool = False,
374
+ ) -> dict:
375
+ """Write the Windows ``gaia.cmd`` + ``gaia.ps1`` launchers.
376
+
377
+ Both are derived from ``link`` by suffix: ``<link>.cmd`` and ``<link>.ps1``
378
+ (so a default ``~/.local/bin/gaia`` yields ``gaia.cmd`` / ``gaia.ps1``).
379
+ Each bakes the resolved ``workspace``, exports ``GAIA_WORKSPACE_PATH``, and
380
+ dispatches to ``gaia_bin`` (the actual installed ``bin/gaia``).
381
+
382
+ Idempotent and non-destructive, mirroring the POSIX branch:
383
+ - missing -> created
384
+ - present, matches -> noop
385
+ - present, drifted -> replaced (overwrite=True) / skipped (overwrite=False)
386
+ - a directory in the way -> skipped
387
+
388
+ The aggregate ``action`` is the "strongest" over the two files: error >
389
+ skipped > replaced > created > noop. ``path`` lists both files.
390
+ """
391
+ parent = link.parent
392
+ try:
393
+ parent.mkdir(parents=True, exist_ok=True)
394
+ except OSError as exc:
395
+ return {
396
+ "action": "error",
397
+ "path": str(link),
398
+ "details": f"failed to create parent {parent}: {exc}",
399
+ }
400
+
401
+ targets = [
402
+ (link.with_suffix(".cmd"), _render_cmd_launcher(workspace, gaia_bin)),
403
+ (link.with_suffix(".ps1"), _render_ps1_launcher(workspace, gaia_bin)),
404
+ ]
405
+
406
+ # Rank so the aggregate reports the most significant per-file outcome.
407
+ rank = {"noop": 0, "created": 1, "replaced": 2, "skipped": 3, "error": 4}
408
+ per_file: list[str] = []
409
+ worst = "noop"
410
+
411
+ for path, content in targets:
412
+ action = _write_windows_launcher_file(path, content, overwrite=overwrite)
413
+ per_file.append(f"{path.name}={action}")
414
+ if rank[action] > rank[worst]:
415
+ worst = action
416
+
417
+ return {
418
+ "action": worst,
419
+ "path": ", ".join(str(p) for p, _ in targets),
420
+ "details": "; ".join(per_file),
421
+ }
422
+
423
+
424
+ def _write_windows_launcher_file(path: Path, content: str, overwrite: bool) -> str:
425
+ """Write one Windows launcher file idempotently. Returns the action string."""
426
+ if path.is_dir():
427
+ return "skipped"
428
+ if path.exists():
429
+ try:
430
+ current = path.read_text()
431
+ except OSError:
432
+ return "error"
433
+ if current == content:
434
+ return "noop"
435
+ if not overwrite:
436
+ return "skipped"
437
+ try:
438
+ path.write_text(content)
439
+ except OSError:
440
+ return "error"
441
+ return "replaced"
442
+ try:
443
+ path.write_text(content)
444
+ except OSError:
445
+ return "error"
446
+ return "created"
447
+
448
+
271
449
  # Backward-compatible alias -- existing tests/imports continue to work
272
450
  # while migrating to the new name.
273
451
  _create_path_symlink = _install_path_launcher
@@ -632,7 +810,7 @@ def register(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
632
810
  dest="no_path",
633
811
  action="store_true",
634
812
  default=False,
635
- help="Skip creating the ~/.local/bin/gaia symlink",
813
+ help="Skip creating the ~/.local/bin/gaia launcher",
636
814
  )
637
815
  return p
638
816
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.2.0-rc.1",
3
+ "version": "5.2.0-rc.2",
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.2.0-rc.1"
3
+ version = "5.2.0-rc.2"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -169,7 +169,7 @@ There is **no npm postinstall hook**. `package.json` carries an explicit `_insta
169
169
  4. Merge hooks from `hooks.json` into `settings.local.json`.
170
170
  5. Create `.claude/{agents, tools, hooks, config, skills}` symlinks (5) plus a `CHANGELOG.md` file link.
171
171
  6. Write `plugin-registry.json` with `installed[].name == "gaia"` (the single unified plugin identity).
172
- 7. Write the `~/.local/bin/gaia` PATH launcher unless `--no-path`.
172
+ 7. Write the PATH launcher unless `--no-path`: POSIX still gets the `~/.local/bin/gaia` bash shim; Windows instead gets `gaia.cmd` + `gaia.ps1` (`_install_windows_launchers` / `_render_cmd_launcher` / `_render_ps1_launcher` in `bin/cli/install.py`), each baking in the resolved workspace and exporting `GAIA_WORKSPACE_PATH` before dispatching to `bin/gaia`.
173
173
 
174
174
  Note: no `project-context.json` is written. Project context lives in `~/.gaia/gaia.db`. Run `gaia scan` separately to populate it -- install never triggers a scan.
175
175