git:20260727.8a3975d to git:20260728.101a4b3

41 added, 48 removed. Audit A to A.

# packages/agentbundle — agent context
## PyPI publishing
- **Publish only from `main`.** Never push an `agentbundle-v*` tag from a
- feature branch, research branch, or worktree. Tags pushed from non-`main`
- refs trigger the release workflow and will publish to PyPI — there is no
- branch guard.
-
- **After every merge to `main` that bumps the version, tag and push immediately.**
- A merged version bump that isn't tagged leaves PyPI stale. The release workflow
- runs on tag push only (`push: tags: agentbundle-v*`); it does not run on merge.
+ Tag from `main` only. Never tag from a feature or research branch — there is no branch guard on the release workflow.
**Workflow:**
- 1. Bump `version.py` `CLI_VERSION`, `pyproject.toml` `version`, and CHANGELOG in the same PR.
+ 1. Bump `version.py` (`CLI_VERSION`), `pyproject.toml` (`version`), and CHANGELOG in the same PR.
2. Merge to `main`.
- 3. Tag the merge commit: `git tag agentbundle-v<version> <sha> && git push origin agentbundle-v<version>`.
- 4. Confirm the `release-agentbundle` workflow's `publish-pypi` job completes green.
+ 3. `git tag agentbundle-v<version> <sha> && git push origin agentbundle-v<version>`
+ 4. Confirm `release-agentbundle` / `publish-pypi` goes green.
- **Version rule:** the next version after what is currently on PyPI. Check
- `pip index versions agentbundle` before choosing a version number to avoid
- collisions with any prior research-branch publish.
+ **Version rule:** next after what's on PyPI — run `pip index versions agentbundle` before choosing.
## Engine-Change-RFC requirement
- Any PR that touches `packages/agentbundle/**` must include an
- `Engine-Change-RFC: <RFC-NNNN or ADR-NNNN>` trailer in at least one commit
- message. The `lint-catalogue-curation-guard` tool enforces this on every
- build; without the trailer the build will fail. Cite the RFC or ADR that
- governs the change — or ADR-0056 for general engine additions — and place the
- trailer on its own line after the commit message body.
+ Every PR touching `packages/agentbundle/**` needs an `Engine-Change-RFC: <RFC-NNNN or ADR-NNNN>` trailer in at least one commit. Use ADR-0056 for general additions. `lint-catalogue-curation-guard` enforces this; missing trailer = build failure.
## Windows portability — test isolation
- Two patterns cause test failures on `windows-latest` CI; avoid them in new
- integration tests:
+ **User-scope root isolation.** Patching `HOME` alone doesn't work on Windows — `expanduser` reads `USERPROFILE`. Also patch `AGENTBUNDLE_USER_ROOT` (checked first, bypasses `expanduser`):
+ ```python
+ patch.dict(os.environ, {"HOME": str(self.home), "AGENTBUNDLE_USER_ROOT": str(self.home)})
+ ```
- **User-scope root isolation.** `patch.dict(os.environ, {"HOME": ...})` does
- not redirect user-scope installs on Windows because `scope.resolve_user_root()`
- calls `Path("~").expanduser()` which reads `USERPROFILE`, not `HOME`. Two
- equivalent patterns exist in the suite; use whichever fits the test shape:
+ **Shell dispatch.** Don't call `sh -c <path>` in tests — Git Bash strips backslashes from Windows paths. Guard with `if sys.platform == "win32": return`.
- - Preferred for `setUp`-based tests: also patch `AGENTBUNDLE_USER_ROOT`.
- `resolve_user_root()` checks it first and bypasses `expanduser` entirely.
- ```python
- patch.dict(os.environ, {"HOME": str(self.home), "AGENTBUNDLE_USER_ROOT": str(self.home)})
- ```
- - Existing tests that patch `USERPROFILE` directly also work, since
- `expanduser` reads `USERPROFILE` on Windows.
+ **Concurrent install race.** Thread-based concurrent-install tests that assert both adapter rows land can race on Windows (TOCTOU in inband-detection). Skip with `@unittest.skipIf(sys.platform == "win32", ...)`.
- **Shell dispatch.** Do not invoke `sh -c <path>` (or `/usr/bin/bash -c
- <path>`) in tests — on Windows, Git for Windows bash strips backslashes from
- paths (`C:\Users\...` → `C:Users...`). Guard the dispatch portion only,
- keeping platform-independent assertions intact:
+ **Subprocess encoding (cp1252).** Force UTF-8 in subprocess env to avoid `UnicodeEncodeError` on characters like `✓`:
+ ```python
+ env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
+ result = subprocess.run([sys.executable, str(script)], env=env,
+ capture_output=True, text=True, encoding="utf-8")
+ ```
+ **CRLF vs LF byte comparisons.** Git `autocrlf` converts line endings on Windows checkout. Normalize before byte-comparing:
```python
- if sys.platform == "win32":
- return # sh -c <windows-path> strips backslashes
- result = subprocess.run(["sh", "-c", command], ...)
+ def _norm(p): return p.read_bytes().replace(b"\r\n", b"\n")
```
- **Concurrent install race.** Thread-based concurrent-install tests that
- assert both adapter rows land can fail on Windows: the inband-detection
- TOCTOU window (disk state visible before state-file commit) causes cursor to
- detect orphans and return rc=1. This is a test-harness artifact — the
- statelock's cross-process `O_CREAT|O_EXCL` guarantee is not exercised by
- thread-based tests on any platform. Skip failing concurrent tests with
- `@unittest.skipIf(sys.platform == "win32", ...)` and open a follow-up to
- either root-cause the thread-model race or convert workers to subprocesses.
+ **Symlinks and execute bits.** `os.symlink()` and `os.chmod(..., 0o755)` require Developer Mode on Windows CI. Skip tests that rely on them:
+ ```python
+ @unittest.skipIf(sys.platform == "win32", "symlinks require Developer Mode on Windows")
+ ```
+
+ **Root-path detection.** `str(path) == "/"` is always `False` on Windows. Use `normalised == normalised.parent`.
+
+ **`file://` URLs.** `f"file://{path.as_posix()}"` produces `file://C:/path` (broken on Windows). Use `path.as_uri()` → `file:///C:/path`.
+
+ ## Gate G — release impact
+
+ Changes under `packages/agentbundle/agentbundle/` trigger Gate G. PR must have all three:
+ 1. Version bump in `version.py` and `pyproject.toml`.
+ 2. Changelog entry in `CHANGELOG.md`.
+ 3. `Engine-Change-RFC:` trailer.
+
+ **Version collision on rebase.** If main bumped while your branch was open, resolve to `<main-version> + 0.0.1` (e.g. main at `0.21.0` → use `0.21.1`). In rebase conflicts `HEAD` = main — take its version, increment patch.
+
+ ## SAST (Semgrep) false-positives
+
+ `dangerous-subprocess-use-tainted-env-args` fires on `subprocess.run` even with `shell=False`. Prefer the library API (`zipapp.create_archive` over `python -m zipapp`). If `# nosemgrep` is needed, place it on the line Semgrep anchors to — for multi-line calls that's the first argument-list line, not `subprocess.run(`.