From ee714c25ac77276f7766b1d436c5d504cdf70df4 Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 18:01:39 +0800 Subject: [PATCH 1/7] feat(installer): add release installer Signed-off-by: Kent --- .github/workflows/quality-gate.yml | 4 +- .github/workflows/release.yml | 23 +- .github/workflows/validate.yml | 4 + AGENTS.md | 10 +- COMPATIBILITY.md | 15 +- CONTRIBUTING.md | 24 +- README.md | 40 +- SECURITY.md | 25 +- docs/design-installation.md | 50 ++ scripts/build_release_assets.py | 93 ++++ scripts/install.sh.in | 301 ++++++++++++ scripts/tests/test_build_release_assets.py | 135 ++++++ scripts/tests/test_installer.py | 539 +++++++++++++++++++++ 13 files changed, 1239 insertions(+), 24 deletions(-) create mode 100644 docs/design-installation.md create mode 100755 scripts/build_release_assets.py create mode 100644 scripts/install.sh.in create mode 100644 scripts/tests/test_build_release_assets.py create mode 100644 scripts/tests/test_installer.py diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 1e6d1bb..e6cafb4 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -91,6 +91,8 @@ jobs: cargento.skills.cargento.tests.test_server \ scripts.tests.test_validate_plugins \ scripts.tests.test_bump_version \ + scripts.tests.test_build_release_assets \ + scripts.tests.test_installer \ scripts.tests.test_lint_embedded # The threshold lives in pyproject.toml ([tool.coverage.report] @@ -215,7 +217,7 @@ jobs: - name: Confirm sqlite3 is present on this runner run: python -c "import sqlite3; print(sqlite3.sqlite_version)" - name: Run unittest suite - run: python -m unittest cargento.skills.cargento.tests.test_server scripts.tests.test_validate_plugins scripts.tests.test_bump_version scripts.tests.test_lint_embedded + run: python -m unittest cargento.skills.cargento.tests.test_server scripts.tests.test_validate_plugins scripts.tests.test_bump_version scripts.tests.test_build_release_assets scripts.tests.test_installer scripts.tests.test_lint_embedded # The single required status check. Branch protection requires this job, so # every job above must succeed (a skip or failure anywhere fails the gate). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f203481..ecec932 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,8 @@ jobs: python3 scripts/validate_plugins.py python3 -m unittest scripts/tests/test_validate_plugins.py python3 -m unittest scripts/tests/test_bump_version.py + python3 -m unittest scripts/tests/test_build_release_assets.py + python3 -m unittest scripts/tests/test_installer.py python3 -m unittest cargento/skills/cargento/tests/test_server.py - name: Bump version fields, re-validate, and push the release commit @@ -206,16 +208,31 @@ jobs: TARGET="${RELEASE_COMMIT:-$(git rev-parse HEAD)}" git push --force origin "$TARGET:refs/heads/stable" - - name: Publish the GitHub Release + - name: Build release assets from the released commit + run: | + set -euo pipefail + TARGET="${RELEASE_COMMIT:-$(git rev-parse HEAD)}" + git checkout --detach "$TARGET" + python3 scripts/build_release_assets.py --tag "$TAG" --output-dir dist + + - name: Publish the GitHub Release and assets env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if gh release view "$TAG" > /dev/null 2>&1; then - echo "Release $TAG already exists — nothing to publish." + gh release upload "$TAG" \ + "dist/install.sh" \ + "dist/cargento-runtime-$VERSION.tar.gz" \ + "dist/cargento-runtime-$VERSION.tar.gz.sha256" \ + --clobber + echo "Release $TAG already exists — assets refreshed." exit 0 fi gh release create "$TAG" \ --title "Cargento $VERSION" \ --generate-notes \ - --verify-tag + --verify-tag \ + "dist/install.sh" \ + "dist/cargento-runtime-$VERSION.tar.gz" \ + "dist/cargento-runtime-$VERSION.tar.gz.sha256" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e413e7f..6dc7d03 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -32,6 +32,10 @@ jobs: run: python3 -m unittest scripts/tests/test_validate_plugins.py - name: Run release bump-script unit tests run: python3 -m unittest scripts/tests/test_bump_version.py + - name: Run release-asset construction tests + run: python3 -m unittest scripts/tests/test_build_release_assets.py + - name: Run POSIX installer tests + run: python3 -m unittest scripts/tests/test_installer.py - name: Check version-field parity across all owned locations run: python3 scripts/bump_version.py --current - name: Run dashboard server tests diff --git a/AGENTS.md b/AGENTS.md index ac12141..53b6916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,12 @@ cargento/ # plugin root ├── notify_hook.py # loopback POST forwarder for the user-installed Claude hooks ├── agents/openai.yaml # Codex presentation metadata └── tests/test_server.py # server unit tests +scripts/ +├── build_release_assets.py # deterministic runtime/checksum/installer builder +├── install.sh.in # rendered POSIX installer +└── tests/ + ├── test_build_release_assets.py + └── test_installer.py ``` The Codex/AGY marketplace lives at `.agents/plugins/marketplace.json`. There is no Claude @@ -45,6 +51,7 @@ shipped skill body, lives in the `sync-docs` skill at `.claude/skills/sync-docs/ | `COMPATIBILITY.md` | The cross-harness and cross-platform contract, and the Python floor. | | `SECURITY.md` | Security invariants, accepted exposures, and private reporting. | | `cargento/skills/cargento/SKILL.md` | The shipped product surface. A validated artifact — see the portability rules below. | +| `docs/design-installation.md` | Installer ownership, trust boundary, and rejected distribution alternatives. | | `docs/design-*.md` | Durable design rationale, including alternatives that were tried and rejected. | | `docs/plans/*.md` | Transient plans for unshipped work. Delete a plan once its work ships. | | `.claude/skills/*/SKILL.md` | Repository development skills (`sync-docs`). Not shipped with the plugin, so the portability rules below do not apply to them. | @@ -93,6 +100,7 @@ git diff "$(git merge-base origin/main HEAD)"..HEAD \ -- '*plugin.json' '*marketplace.json' '*gemini-extension.json' | grep -E '^[+-].*"version"' coverage run -m unittest cargento.skills.cargento.tests.test_server \ scripts.tests.test_validate_plugins scripts.tests.test_bump_version \ + scripts.tests.test_build_release_assets scripts.tests.test_installer \ scripts.tests.test_lint_embedded coverage report # enforces the fail_under threshold from pyproject.toml # Native validators, if the CLIs are installed (they are not available on stock runners): @@ -135,7 +143,7 @@ git tag v0.2.0 # v-prefixed is canonical (bare 0.2.0 also works — pick git push origin v0.2.0 ``` -The Release workflow validates the tag (must be on main, strict semver, strictly greater than every existing release tag — back-tagging is impossible), runs the contract validator plus the validator, bump-version and server test modules on the main tip — not the whole quality gate, which already ran on every commit that reached main — writes one `chore(release)` bump commit via `scripts/bump_version.py`, moves the tag onto that commit, and publishes a GitHub Release. Every step is idempotent: a re-run after a partial failure resumes cleanly, and tagging the version the manifests already carry releases it as-is (that is how the initial 0.1.0 ships). If main advances between tag push and the run, the release includes those extra commits. Release tags are immutable — a tag ruleset blocks deleting or moving them. +The Release workflow validates the tag (must be on main, strict semver, strictly greater than every existing release tag — back-tagging is impossible), runs the contract validator plus the validator, bump-version, release-asset, installer, and server test modules on the main tip — not the whole quality gate, which already ran on every commit that reached main — writes one `chore(release)` bump commit via `scripts/bump_version.py`, and moves the tag onto that commit. It then builds `install.sh`, the runtime archive, and its SHA-256 checksum from that released commit and uploads all three to the GitHub Release. Every step is idempotent: a re-run after a partial failure resumes cleanly and refreshes the assets, and tagging the version the manifests already carry releases it as-is (that is how the initial 0.1.0 ships). If main advances between tag push and the run, the release includes those extra commits. Release tags are immutable — a tag ruleset blocks deleting or moving them. ## Portability Rules diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 1ff5820..82fa3b7 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -9,21 +9,23 @@ The repository keeps one shared skill implementation for all clients. Platform-n | Skill UI metadata | `agents/openai.yaml` | Ignored | Ignored | Ignored | Optional Codex presentation data beside the shared skill | | MCP | Not used | Not used | Not used | Not used | Cargento reads local session stores directly; no MCP server is bundled | | Hooks | None | None | None | None | The dashboard's optional Claude `Notification` and `SessionEnd` hooks are user-installed, never bundled (see [SKILL.md](cargento/skills/cargento/SKILL.md#notifications)) | +| Release installer | Not yet | `--plugin claude` | Not yet | Not yet | Installs the standalone CLI and exact `cargento@spacedock` identity together | | Recurring runs | Invoke the skill one pass at a time | Invoke the skill one pass at a time; a scheduler plugin can repeat it | Invoke the skill one pass at a time | Invoke the skill one pass at a time | The skill remains useful as a one-shot workflow | ## Platform-specific behavior This file owns the Python floor. The dashboard server is stdlib-only Python 3.11+, with `datetime.UTC` setting the floor, and it runs identically regardless of which harness launched it. -The floor is restated in six other places across four files, which must all move together: -`README.md`, `CONTRIBUTING.md` (twice, in the prerequisites and in the `server.py` design -constraints), `cargento/skills/cargento/SKILL.md`, and `pyproject.toml` (`[tool.ruff] target-version` -and `[tool.mypy] python_version`). The documentation-matches-code test guards the `SKILL.md` copy. -The rest are on you. +The floor is also restated in `README.md`, `CONTRIBUTING.md` (the prerequisites and the `server.py` +design constraints), `cargento/skills/cargento/SKILL.md`, `scripts/install.sh.in`, and +`pyproject.toml` (`[tool.ruff] target-version` and `[tool.mypy] python_version`). These declarations +must move together. The documentation-matches-code test guards the `SKILL.md` copy. The rest are on +you. | Capability | macOS | Linux | Windows | WSL2 | |---|---|---|---|---| | Harness discovery, dashboard, `/api/data` | yes | yes | yes | yes (Linux-side stores) | +| Release installer and `cargento` launcher | yes | yes | no | yes | | Turn ETA, token rate | yes | yes | yes | yes | | Task age from file birthtime | yes | falls back to mtime | Python 3.12+ only | falls back to mtime | | Needs-input popup, browser (tab open) | not needed | yes | yes | yes (host browser) | @@ -40,6 +42,9 @@ Other notes: - Store locations resolve per platform, and `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` are honored. Run `server.py --diagnose` to see every path searched and what was found there. - WSL2's `localhostForwarding` defaults on but can be switched off, and mirrored/NAT networking modes or corporate policy can also break host-browser access to `127.0.0.1:4553`. Probe before assuming; the fallback is `ssh -L` or a browser inside WSL. - Supported WSL topology is server and agents on the same side of the boundary. Reading a Windows-side store from inside WSL works over `/mnt/c`, but 9p latency and mtime granularity make state detection unreliable, so it is not supported. +- The release installer is POSIX-only. It supports macOS, Linux, and WSL with Python 3.11+, `curl`, + `tar`, a SHA-256 tool, and Claude Code. Native Windows and all non-Claude plugin selectors are + deferred. WSL needs a release smoke before shipping because hosted CI has no WSL runner. - `sqlite3` is an optional stdlib module. On a build without it (some musl/Alpine images) OpenCode, Cursor and Goose report undiscovered. Antigravity still appears, since its discovery and state come from store mtime and CLI logs, but without a token rate or turn ETA. ## Validation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f83249..c7d9e87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,12 @@ A flipped comparison is the cheapest mutation to try, and the most revealing: ch `<=`, or one `and` to `or`, and run the suite. Anything that still passes is a boundary nothing pins. +Installer changes belong in `scripts/tests/test_installer.py`; release-asset changes belong in +`scripts/tests/test_build_release_assets.py`. Installer tests use isolated homes, real locally built +runtime archives, and a stateful Claude command fixture. Keep the lagging marketplace-version case: +the plugin version is selected by the marketplace and is not required to equal the runtime archive +version. + Known flake: the page tests shell out to `node` with a 30-second timeout. On the Windows runner that occasionally expires on process start, surfacing as `subprocess.TimeoutExpired: … page_test.js`. It is a runner-speed artifact rather than a page bug, @@ -184,11 +190,19 @@ git push origin v0.2.0 The [Release workflow](.github/workflows/release.yml) refuses the tag unless it is on main, is strict semver, and is strictly greater than every existing release tag. Semver only moves forward, -and back-tagging is impossible. It then runs the contract validator plus the validator, bump-version -and server test modules on the main tip, rather than the whole quality gate, which already ran on -every commit that reached main. From there it writes one bump commit updating all owned version -fields, moves the tag onto the released commit, advances the `stable` branch to it, and publishes a -GitHub Release with generated notes. `stable` is what the shared +and back-tagging is impossible. It then runs the contract validator and the focused release test +modules on the main tip, rather than the whole quality gate, which already ran on every commit that +reached main. From there it writes one bump commit updating all owned version fields, moves the tag +onto the released commit, advances the `stable` branch to it, and builds three assets from that exact +commit: + +- `install.sh`, rendered with the release tag and asset names; +- `cargento-runtime-.tar.gz`, built from the authored `cargento/` tree; +- the archive's `.sha256` checksum. + +The workflow publishes those assets with generated notes. If a run stops after the release exists, +a rerun rebuilds the same deterministic assets and uploads them with replacement enabled. It does +not make a second release or another version bump. `stable` is what the shared [spacedock-dev/marketplace](https://github.com/spacedock-dev/marketplace) listing tracks, so a release that did not move it would leave the marketplace serving an older Cargento. The bump is skipped when the manifests already carry the tagged version, which is also how you diff --git a/README.md b/README.md index 066e8b5..62179b6 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,42 @@ This repo contains one plugin, `cargento`, the agent cartography dashboard skill ### Prerequisites - Python 3.11+. The server is stdlib-only, so there is nothing to install alongside it. -- To install it as a plugin: Codex, Claude Code, Antigravity/AGY, or Gemini CLI. +- For the supported installer: Claude Code, `curl`, `tar`, and either `sha256sum` or `shasum`. +- For manual plugin setup: Codex, Claude Code, Antigravity/AGY, or Gemini CLI. -You do not need all four. The dashboard maps every harness it finds on the machine regardless of -which one launched it, and it runs standalone with no client installed at all: +### Install the CLI and Claude plugin + +Choose the release tag you want, then run its installer: + +```bash +CARGENTO_TAG=vX.Y.Z # replace with the release tag you want +curl -fsSL "https://github.com/spacedock-dev/cargento/releases/download/$CARGENTO_TAG/install.sh" \ + | sh -s -- --plugin claude +``` + +The installer verifies the release checksum, installs a user-local `cargento` command, and sets up +the exact `cargento@spacedock` Claude plugin. A complete run ends with: + +```text +CLI: verified +Plugin (claude): verified +``` + +If `~/.local/bin` is not already on `PATH`, the result includes an `export PATH=...` line you can +copy. The installer does not edit shell startup files. Run `cargento --diagnose` to check the CLI. +A plugin failure after CLI activation is reported as a partial installation; rerun the same +installer command to repair it. + +### Manual and plugin-only setup + +You do not need every harness. The dashboard maps every harness it finds on the machine regardless +of which one launched it. From a checkout, it also runs without plugin installation: ```bash python3 cargento/skills/cargento/server.py --port 4553 ``` -### Claude Code installation +#### Claude Code Cargento is listed in the shared Spacedock marketplace, so if you already have that marketplace you only need the second line. @@ -45,7 +71,7 @@ claude plugin install cargento@spacedock Restart Claude Code after installation. -### Antigravity / AGY installation +#### Antigravity / AGY ```bash # From a local checkout, install the native AGY plugin @@ -54,7 +80,7 @@ agy plugin install "$PWD/cargento" Restart AGY after installation. -### Gemini CLI installation +#### Gemini CLI ```bash # From a local checkout, install the native Gemini CLI extension @@ -63,7 +89,7 @@ gemini extensions install "$PWD/cargento" Restart Gemini CLI after installation. -### Codex installation +#### Codex ```bash # Add the marketplace from a local checkout, then install the plugin diff --git a/SECURITY.md b/SECURITY.md index b7fcdcc..4902b17 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,12 @@ ## Scope -Cargento ships two components that touch the network. The dashboard server +Cargento ships three components that touch the network. The dashboard server (`cargento/skills/cargento/server.py`) reads local coding-agent session stores (transcripts, task files, SQLite databases) and serves them over HTTP. `notify_hook.py` is the small forwarder a user -wires into their own Claude Code hook settings, and it POSTs hook payloads to the dashboard. +wires into their own Claude Code hook settings, and it POSTs hook payloads to the dashboard. The +release `install.sh` downloads the runtime archive and checksum from the same versioned GitHub +Release, then invokes the Claude CLI to configure Claude-managed plugin state. The posture rests on two invariants: @@ -20,6 +22,25 @@ Anything that weakens either invariant is a security bug: a bind-address escape, the documented store paths and the project-read contract below (however the path was derived), writes to harness stores, or the hook client reaching a non-loopback destination. +## Installer trust and writes + +The installer checks SHA-256 before an archive enters the Cargento data root. It also rejects +absolute paths, parent traversal, links, and an unexpected archive layout before extraction. The +checksum catches corruption and mismatched assets. It is not an independent signature: GitHub HTTPS, +repository release controls, the archive, and its checksum share one distribution boundary. + +Direct installer writes are user-local and need no `sudo`. Runtime versions live under +`${XDG_DATA_HOME:-$HOME/.local/share}/cargento/releases/`, `current` is the stable activation link, +and the launcher defaults to `~/.local/bin/cargento`. `CARGENTO_DATA_ROOT` and `CARGENTO_BIN_DIR` +are explicit configuration and test overrides. The installer does not edit shell startup files. +Claude plugin and marketplace writes belong to the invoked Claude CLI, not to Cargento's filesystem +writer. + +The runtime archive and Claude plugin come from the same authored `cargento/` tree, but they have +separate version selection. The runtime is pinned to the release tag. Claude selects the plugin +version through `cargento@spacedock`; the installer verifies that exact identity and its enabled +state, not version equality. No launcher path depends on Claude's plugin cache. + ## Project reads (Spacedock stage strips) One feature reads paths that are not under a store root. When a session declares itself a Spacedock diff --git a/docs/design-installation.md b/docs/design-installation.md new file mode 100644 index 0000000..7d8d0b1 --- /dev/null +++ b/docs/design-installation.md @@ -0,0 +1,50 @@ +# Installation design + +## One owned runtime + +The standalone CLI lives under Cargento-owned user data, not inside a harness cache. Each release +archive contains the repository's authored `cargento/` tree. The installer puts it under a versioned +`releases/` directory, activates it through a stable `current` link, and writes a small launcher to +the user's bin directory. + +This keeps the runtime source aligned with the plugin without making the CLI depend on Claude. A +Claude cache can move, be pruned, or contain another plugin version without breaking the launcher. +Release directories are immutable in normal use. A rerun reuses a complete directory and repairs +the activation link or launcher. + +## Distribution and trust boundary + +GitHub Releases are the phase-one distribution channel. The release workflow builds the runtime +archive from the released commit, normalizes archive metadata for deterministic output, renders the +tag and filenames into `install.sh`, and uploads a SHA-256 checksum beside it. A resumed workflow +rebuilds and replaces the same three assets. + +The installer downloads both files from the exact tag, checks the digest, validates archive members, +and extracts only into a temporary directory before activating the release. SHA-256 detects +corruption or an asset mismatch. It does not provide an independent signature because the archive +and checksum are controlled by the same GitHub release boundary. + +## Claude state is an external contract + +Claude owns its marketplace and plugin state. The installer reads that state from the Claude CLI's +JSON output and mutates it only through Claude commands. It requires the `spacedock` marketplace to +point at `spacedock-dev/marketplace`, rejects a same-name collision, and verifies an enabled +`cargento@spacedock` identity after setup. + +The marketplace selects the plugin version. Its metadata can lag the runtime release, so version +equality is not part of the phase-one contract. The complete result is exact identity plus enabled +state. This also keeps the installer independent of undocumented cache layouts. + +## Partial installation + +CLI activation happens before Claude setup. If Claude fails, the verified CLI stays installed and +the installer reports a partial result. Rerunning the same command is the repair path. Runtime, +marketplace, and plugin checks are idempotent, so repair needs no manual cleanup. + +## Rejected expansions + +Discovering `server.py` in Claude's versioned cache was smaller, but it made the standalone command +depend on a harness-owned implementation detail. A general per-harness adapter framework, generated +marketplace, signing system, PyPI package, Homebrew formula, update command, and uninstaller would +add distribution surfaces before phase one needs them. Native Windows also needs its own launcher +and PowerShell semantics, so it remains a separate design problem. diff --git a/scripts/build_release_assets.py b/scripts/build_release_assets.py new file mode 100755 index 0000000..0d55f3f --- /dev/null +++ b/scripts/build_release_assets.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Build deterministic Cargento runtime and installer release assets.""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import re +import stat +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME_ROOT = ROOT / "cargento" +INSTALLER_TEMPLATE = ROOT / "scripts/install.sh.in" +STRICT_TAG = re.compile(r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +RELEASE_BASE = "https://github.com/spacedock-dev/cargento/releases/download" + + +def normalized_tar_info(info: tarfile.TarInfo) -> tarfile.TarInfo: + """Return deterministic metadata for a runtime archive member.""" + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + if info.isdir(): + info.mode = 0o755 + elif info.isfile(): + info.mode = 0o755 if info.mode & 0o111 else 0o644 + return info + + +def build_archive(destination: Path) -> None: + """Write the repository's authored Cargento tree as a deterministic tarball.""" + with ( + destination.open("wb") as raw, + gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as compressed, + tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as bundle, + ): + bundle.add( + RUNTIME_ROOT, + arcname="cargento", + recursive=True, + filter=normalized_tar_info, + ) + + +def build_assets(tag: str, output_dir: Path) -> tuple[Path, Path, Path]: + """Build and return the installer, runtime archive, and checksum paths.""" + match = STRICT_TAG.fullmatch(tag) + if match is None: + raise ValueError(f"tag {tag!r} is not strict semver") + version = ".".join(match.groups()) + archive_name = f"cargento-runtime-{version}.tar.gz" + output_dir.mkdir(parents=True, exist_ok=True) + archive = output_dir / archive_name + checksum = output_dir / f"{archive_name}.sha256" + installer = output_dir / "install.sh" + + build_archive(archive) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + checksum.write_text(f"{digest} {archive_name}\n") + rendered = ( + INSTALLER_TEMPLATE.read_text() + .replace("@CARGENTO_TAG@", tag) + .replace("@CARGENTO_VERSION@", version) + .replace("@CARGENTO_ARCHIVE@", archive_name) + .replace("@CARGENTO_RELEASE_BASE@", f"{RELEASE_BASE}/{tag}") + ) + installer.write_text(rendered) + installer.chmod(installer.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return installer, archive, checksum + + +def main() -> int: + """Build release assets from command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + try: + assets = build_assets(args.tag, args.output_dir) + except ValueError as error: + parser.error(str(error)) + for asset in assets: + print(asset) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install.sh.in b/scripts/install.sh.in new file mode 100644 index 0000000..cbf3983 --- /dev/null +++ b/scripts/install.sh.in @@ -0,0 +1,301 @@ +#!/bin/sh +set -eu + +CARGENTO_TAG='@CARGENTO_TAG@' +CARGENTO_VERSION='@CARGENTO_VERSION@' +CARGENTO_ARCHIVE='@CARGENTO_ARCHIVE@' +CARGENTO_RELEASE_BASE='@CARGENTO_RELEASE_BASE@' +MARKETPLACE_NAME='spacedock' +MARKETPLACE_REPOSITORY='spacedock-dev/marketplace' +PLUGIN_ID='cargento@spacedock' + +usage() { + printf '%s\n' 'Usage: cargento-install --plugin claude' >&2 + exit 64 +} + +fail() { + printf 'cargento-install: %s\n' "$1" >&2 + exit 1 +} + +partial_failure() { + printf '%s\n' 'Plugin (claude): failed' + printf '%s\n' \ + 'cargento-install: partial installation; the CLI is verified but the Claude plugin is not' \ + 'Recovery:' >&2 + printf ' curl -fsSL "%s/install.sh" | sh -s -- --plugin claude\n' \ + "$CARGENTO_RELEASE_BASE" >&2 + exit 1 +} + +if [ "$#" -ne 2 ] || [ "$1" != '--plugin' ] || [ "$2" != 'claude' ]; then + usage +fi + +for command_name in python3 curl tar claude mktemp mkdir rm ln mv chmod; do + if ! command -v "$command_name" >/dev/null 2>&1; then + fail "required command not found: $command_name" + fi +done + +PYTHON=$(command -v python3) +PYTHON_VERSION=$( + "$PYTHON" -c 'import sys; print(f"{sys.version_info.major} {sys.version_info.minor}")' 2>/dev/null +) || fail 'Python 3.11+ is required' +# The interpreter emits exactly two numeric fields; splitting them is intentional. +# shellcheck disable=SC2086 +set -- $PYTHON_VERSION +if [ "$#" -ne 2 ] || [ "$1" -lt 3 ] 2>/dev/null || { + [ "$1" -eq 3 ] 2>/dev/null && [ "$2" -lt 11 ] 2>/dev/null +}; then + fail 'Python 3.11+ is required' +fi + +if command -v sha256sum >/dev/null 2>&1; then + HASH_TOOL='sha256sum' +elif command -v shasum >/dev/null 2>&1; then + HASH_TOOL='shasum' +else + fail 'required SHA-256 tool not found: install sha256sum or shasum' +fi + +if [ -n "${CARGENTO_DATA_ROOT:-}" ]; then + DATA_ROOT=$CARGENTO_DATA_ROOT +elif [ -n "${XDG_DATA_HOME:-}" ]; then + DATA_ROOT=$XDG_DATA_HOME/cargento +else + DATA_ROOT=$HOME/.local/share/cargento +fi +BIN_DIR=${CARGENTO_BIN_DIR:-"$HOME/.local/bin"} +case "$DATA_ROOT:$BIN_DIR" in + *' +'*) fail 'install paths must not contain newlines' ;; +esac + +TEMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/cargento-install.XXXXXX") +cleanup() { + rm -rf "$TEMP_ROOT" +} +trap cleanup EXIT HUP INT TERM + +ARCHIVE_PATH=$TEMP_ROOT/$CARGENTO_ARCHIVE +CHECKSUM_PATH=$TEMP_ROOT/$CARGENTO_ARCHIVE.sha256 +if ! curl -fsSL "$CARGENTO_RELEASE_BASE/$CARGENTO_ARCHIVE" -o "$ARCHIVE_PATH"; then + fail "could not download $CARGENTO_ARCHIVE" +fi +if ! curl -fsSL "$CARGENTO_RELEASE_BASE/$CARGENTO_ARCHIVE.sha256" -o "$CHECKSUM_PATH"; then + fail "could not download $CARGENTO_ARCHIVE.sha256" +fi +if [ "$HASH_TOOL" = 'sha256sum' ]; then + if ! (cd "$TEMP_ROOT" && sha256sum -c "$CARGENTO_ARCHIVE.sha256" >/dev/null 2>&1); then + fail 'checksum verification failed' + fi +elif ! (cd "$TEMP_ROOT" && shasum -a 256 -c "$CARGENTO_ARCHIVE.sha256" >/dev/null 2>&1); then + fail 'checksum verification failed' +fi + +if ! "$PYTHON" - "$ARCHIVE_PATH" <<'PY' +import sys +import tarfile + +with tarfile.open(sys.argv[1], "r:gz") as bundle: + members = bundle.getmembers() +names = {member.name.rstrip("/") for member in members} +required = { + "cargento/skills/cargento/server.py", + "cargento/.claude-plugin/plugin.json", +} +safe = all( + member.name == "cargento" + or ( + member.name.startswith("cargento/") + and ".." not in member.name.split("/") + and not member.issym() + and not member.islnk() + ) + for member in members +) +if not safe or not required.issubset(names): + raise SystemExit(1) +PY +then + fail 'unexpected runtime archive layout' +fi + +mkdir "$TEMP_ROOT/runtime" +if ! tar -xzf "$ARCHIVE_PATH" -C "$TEMP_ROOT/runtime"; then + fail 'could not extract verified runtime archive' +fi + +RELEASES_DIR=$DATA_ROOT/releases +RELEASE_DIR=$RELEASES_DIR/$CARGENTO_VERSION +mkdir -p "$RELEASES_DIR" "$BIN_DIR" +if [ ! -e "$RELEASE_DIR" ]; then + mv "$TEMP_ROOT/runtime/cargento" "$RELEASE_DIR" +elif [ ! -f "$RELEASE_DIR/skills/cargento/server.py" ]; then + fail "existing release directory is incomplete: $RELEASE_DIR" +fi + +CURRENT_LINK=$DATA_ROOT/current +NEXT_LINK=$DATA_ROOT/.current.$$ +rm -f "$NEXT_LINK" +ln -s "releases/$CARGENTO_VERSION" "$NEXT_LINK" +if ! "$PYTHON" - "$NEXT_LINK" "$CURRENT_LINK" <<'PY' +import os +import sys + +os.replace(sys.argv[1], sys.argv[2]) +PY +then + fail "could not activate release $CARGENTO_VERSION" +fi + +LAUNCHER=$BIN_DIR/cargento +NEXT_LAUNCHER=$BIN_DIR/.cargento.$$ +"$PYTHON" - "$NEXT_LAUNCHER" "$PYTHON" "$CURRENT_LINK/skills/cargento/server.py" <<'PY' +import pathlib +import shlex +import sys + +launcher = pathlib.Path(sys.argv[1]) +interpreter = shlex.quote(sys.argv[2]) +server = shlex.quote(sys.argv[3]) +launcher.write_text(f'#!/bin/sh\nexec {interpreter} {server} "$@"\n') +PY +chmod 755 "$NEXT_LAUNCHER" +mv -f "$NEXT_LAUNCHER" "$LAUNCHER" + +if ! "$LAUNCHER" --diagnose --json >/dev/null 2>&1; then + fail 'installed CLI smoke test failed' +fi +printf 'Cargento %s installed\n' "$CARGENTO_TAG" +printf '%s\n' 'CLI: verified' + +MARKETPLACES_JSON=$TEMP_ROOT/marketplaces.json +if ! claude plugin marketplace list --json >"$MARKETPLACES_JSON"; then + partial_failure +fi +marketplace_status=0 +"$PYTHON" - "$MARKETPLACES_JSON" "$MARKETPLACE_NAME" "$MARKETPLACE_REPOSITORY" <<'PY' || marketplace_status=$? +import json +import sys + +entries = json.loads(open(sys.argv[1], encoding="utf-8").read()) +if not isinstance(entries, list): + raise SystemExit(12) +matches = [entry for entry in entries if entry.get("name") == sys.argv[2]] +if not matches: + raise SystemExit(10) +if len(matches) != 1 or matches[0].get("source") != "github" or matches[0].get("repo") != sys.argv[3]: + raise SystemExit(11) +PY +case "$marketplace_status" in + 0) ;; + 10) + if ! claude plugin marketplace add "$MARKETPLACE_REPOSITORY"; then + partial_failure + fi + ;; + 11) + printf 'cargento-install: marketplace name collision for %s\n' "$MARKETPLACE_NAME" >&2 + partial_failure + ;; + *) + printf '%s\n' 'cargento-install: invalid Claude marketplace JSON' >&2 + partial_failure + ;; +esac + +if ! claude plugin marketplace list --json >"$MARKETPLACES_JSON"; then + partial_failure +fi +if ! "$PYTHON" - "$MARKETPLACES_JSON" "$MARKETPLACE_NAME" "$MARKETPLACE_REPOSITORY" <<'PY' +import json +import sys + +entries = json.loads(open(sys.argv[1], encoding="utf-8").read()) +matches = [entry for entry in entries if entry.get("name") == sys.argv[2]] +valid = ( + len(matches) == 1 + and matches[0].get("source") == "github" + and matches[0].get("repo") == sys.argv[3] +) +raise SystemExit(0 if valid else 1) +PY +then + printf '%s\n' 'cargento-install: Claude marketplace verification failed' >&2 + partial_failure +fi + +PLUGINS_JSON=$TEMP_ROOT/plugins.json +if ! claude plugin list --json >"$PLUGINS_JSON"; then + partial_failure +fi +plugin_status=0 +"$PYTHON" - "$PLUGINS_JSON" "$PLUGIN_ID" <<'PY' || plugin_status=$? +import json +import sys + +entries = json.loads(open(sys.argv[1], encoding="utf-8").read()) +if not isinstance(entries, list): + raise SystemExit(12) +matches = [ + entry + for entry in entries + if entry.get("id") == sys.argv[2] and entry.get("scope") == "user" +] +if not matches: + raise SystemExit(10) +if len(matches) != 1: + raise SystemExit(12) +raise SystemExit(0 if matches[0].get("enabled") is True else 11) +PY +case "$plugin_status" in + 0) ;; + 10) + if ! claude plugin install --scope user "$PLUGIN_ID"; then + partial_failure + fi + ;; + 11) + if ! claude plugin enable --scope user "$PLUGIN_ID"; then + partial_failure + fi + ;; + *) + printf '%s\n' 'cargento-install: invalid Claude plugin JSON' >&2 + partial_failure + ;; +esac + +if ! claude plugin list --json >"$PLUGINS_JSON"; then + partial_failure +fi +if ! "$PYTHON" - "$PLUGINS_JSON" "$PLUGIN_ID" <<'PY' +import json +import sys + +entries = json.loads(open(sys.argv[1], encoding="utf-8").read()) +matches = [ + entry + for entry in entries + if entry.get("id") == sys.argv[2] and entry.get("scope") == "user" +] +valid = len(matches) == 1 and matches[0].get("enabled") is True +raise SystemExit(0 if valid else 1) +PY +then + printf '%s\n' 'cargento-install: Claude plugin verification failed' >&2 + partial_failure +fi + +printf '%s\n' 'Plugin (claude): verified' +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) + # The printed $PATH is a copyable shell expression, not an installer expansion. + # shellcheck disable=SC2016 + printf '\nAdd Cargento to PATH:\n export PATH="%s:$PATH"\n' "$BIN_DIR" + ;; +esac diff --git a/scripts/tests/test_build_release_assets.py b/scripts/tests/test_build_release_assets.py new file mode 100644 index 0000000..b047e75 --- /dev/null +++ b/scripts/tests/test_build_release_assets.py @@ -0,0 +1,135 @@ +"""Tests for deterministic installer release assets.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BUILDER = ROOT / "scripts/build_release_assets.py" + + +class BuildReleaseAssetsTests(unittest.TestCase): + def build(self, output: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(BUILDER), + "--tag", + "v1.2.3", + "--output-dir", + str(output), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + def test_builds_deterministic_self_consistent_release_assets(self) -> None: + with ( + tempfile.TemporaryDirectory(prefix="cargento-assets-a-") as first_dir, + tempfile.TemporaryDirectory(prefix="cargento-assets-b-") as second_dir, + ): + first = Path(first_dir) + second = Path(second_dir) + + first_result = self.build(first) + second_result = self.build(second) + + archive_name = "cargento-runtime-1.2.3.tar.gz" + archive = first / archive_name + checksum = first / f"{archive_name}.sha256" + installer = first / "install.sh" + built = first_result.returncode == 0 and second_result.returncode == 0 + first_bytes = archive.read_bytes() if built else b"" + second_bytes = (second / archive_name).read_bytes() if built else b"missing" + digest = hashlib.sha256(first_bytes).hexdigest() + installer_body = installer.read_text() if built else "" + if built: + with tarfile.open(archive, "r:gz") as bundle: + names = bundle.getnames() + else: + names = [] + self.assertEqual( + ( + first_result.returncode, + second_result.returncode, + first_bytes == second_bytes, + checksum.read_text() if built else "", + bool(installer.stat().st_mode & 0o111) if built else False, + "v1.2.3" in installer_body, + archive_name in installer_body, + ( + "https://github.com/spacedock-dev/cargento/releases/download/v1.2.3" + in installer_body + ), + "cargento/skills/cargento/server.py" in names, + "cargento/.claude-plugin/plugin.json" in names, + ), + ( + 0, + 0, + True, + f"{digest} {archive_name}\n", + True, + True, + True, + True, + True, + True, + ), + first_result.stderr or second_result.stderr, + ) + + def test_rejects_non_semver_tag_without_writing_assets(self) -> None: + with tempfile.TemporaryDirectory(prefix="cargento-assets-invalid-") as directory: + output = Path(directory) + + result = subprocess.run( + [ + sys.executable, + str(BUILDER), + "--tag", + "latest", + "--output-dir", + str(output), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual( + ( + result.returncode != 0, + "strict semver" in result.stderr, + list(output.iterdir()), + ), + (True, True, []), + ) + + def test_ci_builds_and_tests_installer_assets_without_renaming_gates(self) -> None: + release_workflow = (ROOT / ".github/workflows/release.yml").read_text() + quality_workflow = (ROOT / ".github/workflows/quality-gate.yml").read_text() + validate_workflow = (ROOT / ".github/workflows/validate.yml").read_text() + + self.assertEqual( + ( + 'python3 scripts/build_release_assets.py --tag "$TAG"' in release_workflow, + 'gh release upload "$TAG"' in release_workflow, + "scripts.tests.test_build_release_assets" in quality_workflow, + "scripts.tests.test_installer" in quality_workflow, + "name: quality-gate" in quality_workflow, + "scripts/tests/test_build_release_assets.py" in validate_workflow, + "scripts/tests/test_installer.py" in validate_workflow, + "\n validate:\n" in validate_workflow, + ), + (True, True, True, True, True, True, True, True), + ) diff --git a/scripts/tests/test_installer.py b/scripts/tests/test_installer.py new file mode 100644 index 0000000..0892f4b --- /dev/null +++ b/scripts/tests/test_installer.py @@ -0,0 +1,539 @@ +"""End-to-end tests for the POSIX Cargento installer.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import textwrap +import unittest +from hashlib import sha256 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BUILDER = ROOT / "scripts/build_release_assets.py" + + +@unittest.skipUnless(os.name == "posix", "the phase-one installer is POSIX-only") +class InstallerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory(prefix="cargento-installer-") + self.addCleanup(self.temporary_directory.cleanup) + self.root = Path(self.temporary_directory.name) + self.assets = self.root / "assets" + build = subprocess.run( + [ + sys.executable, + str(BUILDER), + "--tag", + "v1.2.3", + "--output-dir", + str(self.assets), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(build.returncode, 0, build.stderr) + self.installer = self.assets / "install.sh" + self.home = self.root / "home" + self.home.mkdir() + self.data_root = self.home / "data/cargento" + self.bin_dir = self.home / "bin" + self.tools = self.root / "tools" + self.tools.mkdir() + self.claude_state = self.root / "claude-state.json" + self.claude_log = self.root / "claude.log" + self._make_tools() + self.environment = { + "HOME": str(self.home), + "PATH": str(self.tools), + "CARGENTO_DATA_ROOT": str(self.data_root), + "CARGENTO_BIN_DIR": str(self.bin_dir), + "CARGENTO_TEST_ASSET_DIR": str(self.assets), + "FAKE_CLAUDE_STATE": str(self.claude_state), + "FAKE_CLAUDE_LOG": str(self.claude_log), + } + + def _write_executable(self, name: str, body: str) -> None: + path = self.tools / name + path.write_text(body) + path.chmod(0o755) + + def _link_tool(self, name: str) -> None: + target = shutil.which(name) + if target is None: + self.fail(f"test host must provide {name}") + (self.tools / name).symlink_to(target) + + def _make_tools(self) -> None: + for name in ("tar", "mktemp", "mkdir", "rm", "ln", "mv", "chmod"): + self._link_tool(name) + hash_tool = "sha256sum" if shutil.which("sha256sum") else "shasum" + self._link_tool(hash_tool) + (self.tools / "python3").symlink_to(sys.executable) + self._write_executable( + "curl", + textwrap.dedent( + f"""\ + #!{sys.executable} + import os + import pathlib + import shutil + import sys + + output_index = sys.argv.index("-o") + output = pathlib.Path(sys.argv[output_index + 1]) + source = pathlib.Path(os.environ["CARGENTO_TEST_ASSET_DIR"]) / sys.argv[output_index - 1].rsplit("/", 1)[-1] + shutil.copyfile(source, output) + """ + ), + ) + self._write_executable( + "claude", + textwrap.dedent( + f"""\ + #!{sys.executable} + import json + import os + import pathlib + import sys + + state_path = pathlib.Path(os.environ["FAKE_CLAUDE_STATE"]) + log_path = pathlib.Path(os.environ["FAKE_CLAUDE_LOG"]) + if state_path.exists(): + state = json.loads(state_path.read_text()) + else: + state = {{"marketplaces": [], "plugins": []}} + command = sys.argv[1:] + with log_path.open("a") as log: + log.write(" ".join(command) + "\\n") + if command == ["plugin", "marketplace", "list", "--json"]: + print(json.dumps(state["marketplaces"])) + elif command == ["plugin", "list", "--json"]: + print(json.dumps(state["plugins"])) + elif command == ["plugin", "marketplace", "add", "spacedock-dev/marketplace"]: + if any(item["name"] == "spacedock" for item in state["marketplaces"]): + raise SystemExit("duplicate marketplace add") + state["marketplaces"].append( + {{"name": "spacedock", "source": "github", "repo": "spacedock-dev/marketplace"}} + ) + elif command == ["plugin", "install", "--scope", "user", "cargento@spacedock"]: + marker = os.environ.get("FAKE_CLAUDE_FAIL_ONCE") + if marker and not pathlib.Path(marker).exists(): + pathlib.Path(marker).touch() + raise SystemExit(7) + if any( + item["id"] == "cargento@spacedock" and item["scope"] == "user" + for item in state["plugins"] + ): + raise SystemExit("duplicate plugin install") + # Deliberately lags runtime 1.2.3. Marketplace selection is authoritative. + state["plugins"].append( + {{ + "id": "cargento@spacedock", + "version": "0.4.2", + "scope": "user", + "enabled": True, + }} + ) + elif command == ["plugin", "enable", "--scope", "user", "cargento@spacedock"]: + plugin = next( + ( + item + for item in state["plugins"] + if item["id"] == "cargento@spacedock" and item["scope"] == "user" + ), + None, + ) + if plugin is None: + raise SystemExit("cannot enable absent plugin") + plugin["enabled"] = True + else: + raise SystemExit("unexpected claude command: " + " ".join(command)) + state_path.write_text(json.dumps(state)) + """ + ), + ) + + def run_installer( + self, + *arguments: str, + environment: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + env = self.environment.copy() + if environment: + env.update(environment) + return subprocess.run( + ["/bin/sh", str(self.installer), *arguments], + cwd=self.root, + env=env, + check=False, + capture_output=True, + text=True, + ) + + def assert_preflight_failure( + self, + result: subprocess.CompletedProcess[str], + message: str, + ) -> None: + self.assertEqual( + ( + result.returncode != 0, + message in result.stderr, + self.data_root.exists(), + self.bin_dir.exists(), + self.claude_state.exists(), + ), + (True, True, False, False, False), + ) + + def test_requires_exactly_the_supported_plugin_selector_before_mutation(self) -> None: + for arguments in ( + (), + ("--plugin",), + ("--plugin", "codex"), + ("--plugin", "claude", "--plugin", "claude"), + ("--unknown",), + ): + with self.subTest(arguments=arguments): + result = self.run_installer(*arguments) + self.assertEqual( + ( + result.returncode, + "Usage: cargento-install --plugin claude" in result.stderr, + self.data_root.exists(), + self.bin_dir.exists(), + self.claude_state.exists(), + ), + (64, True, False, False, False), + ) + + def test_preflight_rejects_old_python_without_mutation(self) -> None: + (self.tools / "python3").unlink() + self._write_executable( + "python3", + f"#!{sys.executable}\nimport sys\nprint('3 10')\n", + ) + old_python = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(old_python, "Python 3.11+") + + def test_preflight_rejects_missing_claude_without_mutation(self) -> None: + (self.tools / "claude").unlink() + missing_claude = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(missing_claude, "required command not found: claude") + + def test_preflight_rejects_missing_python_without_mutation(self) -> None: + (self.tools / "python3").unlink() + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "required command not found: python3") + + def test_preflight_rejects_missing_download_tool_without_mutation(self) -> None: + (self.tools / "curl").unlink() + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "required command not found: curl") + + def test_preflight_rejects_missing_hash_tool_without_mutation(self) -> None: + for name in ("sha256sum", "shasum"): + (self.tools / name).unlink(missing_ok=True) + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "required SHA-256 tool not found") + + def test_corrupt_archive_cannot_change_owned_install_paths(self) -> None: + archive = self.assets / "cargento-runtime-1.2.3.tar.gz" + archive.write_bytes(archive.read_bytes() + b"x") + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "checksum verification failed") + + def test_rejects_wrong_archive_layout_before_activation(self) -> None: + archive = self.assets / "cargento-runtime-1.2.3.tar.gz" + payload = self.root / "wrong.txt" + payload.write_text("wrong") + with tarfile.open(archive, "w:gz") as bundle: + bundle.add(payload, arcname="wrong.txt") + checksum = sha256(archive.read_bytes()).hexdigest() + (self.assets / f"{archive.name}.sha256").write_text(f"{checksum} {archive.name}\n") + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "unexpected runtime archive layout") + + def test_installs_cli_and_lagging_marketplace_plugin_idempotently(self) -> None: + shell_profile = self.home / ".zshrc" + shell_profile.write_text("# preserved\n") + + first = self.run_installer("--plugin", "claude") + second = self.run_installer("--plugin", "claude") + + current = self.data_root / "current" + launcher = self.bin_dir / "cargento" + if launcher.exists(): + diagnose = subprocess.run( + [str(launcher), "--diagnose", "--json"], + env=self.environment, + check=False, + capture_output=True, + text=True, + ) + launcher_body = launcher.read_text() + else: + diagnose = subprocess.CompletedProcess([], 127, "", "launcher missing") + launcher_body = "" + state = ( + json.loads(self.claude_state.read_text()) + if self.claude_state.exists() + else {"marketplaces": [], "plugins": []} + ) + log = self.claude_log.read_text().splitlines() if self.claude_log.exists() else [] + self.assertEqual( + ( + first.returncode, + second.returncode, + "CLI: verified" in first.stdout, + "Plugin (claude): verified" in first.stdout, + f'export PATH="{self.bin_dir}:$PATH"' in first.stdout, + shell_profile.read_text(), + current.is_symlink(), + os.readlink(current) if current.is_symlink() else None, + diagnose.returncode, + ".claude/plugins/cache" in launcher_body, + state["marketplaces"], + state["plugins"], + log.count("plugin marketplace add spacedock-dev/marketplace"), + log.count("plugin install --scope user cargento@spacedock"), + ), + ( + 0, + 0, + True, + True, + True, + "# preserved\n", + True, + "releases/1.2.3", + 0, + False, + [ + { + "name": "spacedock", + "source": "github", + "repo": "spacedock-dev/marketplace", + } + ], + [ + { + "id": "cargento@spacedock", + "version": "0.4.2", + "scope": "user", + "enabled": True, + } + ], + 1, + 1, + ), + first.stderr or second.stderr or diagnose.stderr, + ) + + def test_rejects_same_name_different_source_collision(self) -> None: + self.claude_state.write_text( + json.dumps( + { + "marketplaces": [ + {"name": "spacedock", "source": "github", "repo": "other/source"} + ], + "plugins": [], + } + ) + ) + + result = self.run_installer("--plugin", "claude") + + self.assertEqual( + ( + result.returncode != 0, + "marketplace name collision" in result.stderr, + "CLI: verified" in result.stdout, + "Plugin (claude): failed" in result.stdout, + "partial installation" in result.stderr, + ), + (True, True, True, True, True), + ) + + def test_enables_the_exact_plugin_identity_when_it_is_disabled(self) -> None: + self.claude_state.write_text( + json.dumps( + { + "marketplaces": [ + { + "name": "spacedock", + "source": "github", + "repo": "spacedock-dev/marketplace", + } + ], + "plugins": [ + { + "id": "cargento@spacedock", + "version": "0.4.2", + "scope": "user", + "enabled": False, + } + ], + } + ) + ) + + result = self.run_installer("--plugin", "claude") + + state = json.loads(self.claude_state.read_text()) if self.claude_state.exists() else {} + plugins = state.get("plugins", []) + self.assertEqual( + ( + result.returncode, + bool(plugins and plugins[0]["enabled"]), + ( + "plugin enable --scope user cargento@spacedock" in self.claude_log.read_text() + if self.claude_log.exists() + else False + ), + ), + (0, True, True), + result.stderr, + ) + + def test_installs_user_scope_when_only_project_scope_is_enabled(self) -> None: + self.claude_state.write_text( + json.dumps( + { + "marketplaces": [ + { + "name": "spacedock", + "source": "github", + "repo": "spacedock-dev/marketplace", + } + ], + "plugins": [ + { + "id": "cargento@spacedock", + "version": "0.4.2", + "scope": "project", + "enabled": True, + } + ], + } + ) + ) + + result = self.run_installer("--plugin", "claude") + + state = json.loads(self.claude_state.read_text()) if self.claude_state.exists() else {} + user_plugins = [ + plugin + for plugin in state.get("plugins", []) + if plugin["id"] == "cargento@spacedock" and plugin["scope"] == "user" + ] + self.assertEqual( + ( + result.returncode, + len(user_plugins), + bool(user_plugins and user_plugins[0]["enabled"]), + ), + (0, 1, True), + result.stderr, + ) + + def test_upgrade_atomically_repoints_current_to_the_new_release(self) -> None: + first = self.run_installer("--plugin", "claude") + upgraded_assets = self.root / "upgraded-assets" + build = subprocess.run( + [ + sys.executable, + str(BUILDER), + "--tag", + "v1.2.4", + "--output-dir", + str(upgraded_assets), + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + if build.returncode == 0: + second = subprocess.run( + ["/bin/sh", str(upgraded_assets / "install.sh"), "--plugin", "claude"], + cwd=self.root, + env={ + **self.environment, + "CARGENTO_TEST_ASSET_DIR": str(upgraded_assets), + }, + check=False, + capture_output=True, + text=True, + ) + else: + second = subprocess.CompletedProcess([], 127, "", "asset build failed") + current = self.data_root / "current" + self.assertEqual( + ( + first.returncode, + build.returncode, + second.returncode, + os.readlink(current) if current.is_symlink() else None, + ), + (0, 0, 0, "releases/1.2.4"), + first.stderr or build.stderr or second.stderr, + ) + + def test_partial_plugin_failure_keeps_cli_and_rerun_repairs_install(self) -> None: + fail_marker = self.root / "fail-once" + first = self.run_installer( + "--plugin", + "claude", + environment={"FAKE_CLAUDE_FAIL_ONCE": str(fail_marker)}, + ) + + launcher = self.bin_dir / "cargento" + if launcher.exists(): + diagnose = subprocess.run( + [str(launcher), "--diagnose", "--json"], + env=self.environment, + check=False, + capture_output=True, + text=True, + ) + else: + diagnose = subprocess.CompletedProcess([], 127, "", "launcher missing") + + second = self.run_installer( + "--plugin", + "claude", + environment={"FAKE_CLAUDE_FAIL_ONCE": str(fail_marker)}, + ) + recovery = ( + "curl -fsSL " + '"https://github.com/spacedock-dev/cargento/releases/download/v1.2.3/install.sh" ' + "| sh -s -- --plugin claude" + ) + self.assertEqual( + ( + first.returncode != 0, + "CLI: verified" in first.stdout, + "Plugin (claude): failed" in first.stdout, + "partial installation" in first.stderr, + recovery in first.stderr, + diagnose.returncode, + second.returncode, + "Plugin (claude): verified" in second.stdout, + ), + (True, True, True, True, True, 0, 0, True), + first.stderr or diagnose.stderr or second.stderr, + ) From 39df04db5ff81dffa7f8ddb339975024240979c4 Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 18:49:15 +0800 Subject: [PATCH 2/7] test(installer): measure release asset builder Signed-off-by: Kent --- scripts/tests/test_build_release_assets.py | 121 +++++++++++++++------ 1 file changed, 85 insertions(+), 36 deletions(-) diff --git a/scripts/tests/test_build_release_assets.py b/scripts/tests/test_build_release_assets.py index b047e75..339ee46 100644 --- a/scripts/tests/test_build_release_assets.py +++ b/scripts/tests/test_build_release_assets.py @@ -3,19 +3,24 @@ from __future__ import annotations import hashlib +import io import subprocess import sys import tarfile import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path +from unittest.mock import patch + +from scripts.build_release_assets import build_assets, main ROOT = Path(__file__).resolve().parents[2] BUILDER = ROOT / "scripts/build_release_assets.py" class BuildReleaseAssetsTests(unittest.TestCase): - def build(self, output: Path) -> subprocess.CompletedProcess[str]: + def build_with_cli(self, output: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( [ sys.executable, @@ -39,30 +44,26 @@ def test_builds_deterministic_self_consistent_release_assets(self) -> None: first = Path(first_dir) second = Path(second_dir) - first_result = self.build(first) - second_result = self.build(second) + first_assets = build_assets("v1.2.3", first) + second_assets = build_assets("v1.2.3", second) archive_name = "cargento-runtime-1.2.3.tar.gz" archive = first / archive_name checksum = first / f"{archive_name}.sha256" installer = first / "install.sh" - built = first_result.returncode == 0 and second_result.returncode == 0 - first_bytes = archive.read_bytes() if built else b"" - second_bytes = (second / archive_name).read_bytes() if built else b"missing" + first_bytes = archive.read_bytes() + second_bytes = (second / archive_name).read_bytes() digest = hashlib.sha256(first_bytes).hexdigest() - installer_body = installer.read_text() if built else "" - if built: - with tarfile.open(archive, "r:gz") as bundle: - names = bundle.getnames() - else: - names = [] + installer_body = installer.read_text() + with tarfile.open(archive, "r:gz") as bundle: + names = bundle.getnames() self.assertEqual( ( - first_result.returncode, - second_result.returncode, + first_assets, + second_assets, first_bytes == second_bytes, - checksum.read_text() if built else "", - bool(installer.stat().st_mode & 0o111) if built else False, + checksum.read_text(), + bool(installer.stat().st_mode & 0o111), "v1.2.3" in installer_body, archive_name in installer_body, ( @@ -73,8 +74,12 @@ def test_builds_deterministic_self_consistent_release_assets(self) -> None: "cargento/.claude-plugin/plugin.json" in names, ), ( - 0, - 0, + (installer, archive, checksum), + ( + second / "install.sh", + second / archive_name, + second / f"{archive_name}.sha256", + ), True, f"{digest} {archive_name}\n", True, @@ -84,35 +89,79 @@ def test_builds_deterministic_self_consistent_release_assets(self) -> None: True, True, ), - first_result.stderr or second_result.stderr, ) def test_rejects_non_semver_tag_without_writing_assets(self) -> None: with tempfile.TemporaryDirectory(prefix="cargento-assets-invalid-") as directory: output = Path(directory) - result = subprocess.run( - [ - sys.executable, - str(BUILDER), - "--tag", - "latest", - "--output-dir", - str(output), - ], - cwd=ROOT, - check=False, - capture_output=True, - text=True, - ) + try: + build_assets("latest", output) + except ValueError as error: + message = str(error) + else: + message = "no error" self.assertEqual( ( - result.returncode != 0, - "strict semver" in result.stderr, + "strict semver" in message, list(output.iterdir()), ), - (True, True, []), + (True, []), + ) + + def test_cli_builds_release_assets(self) -> None: + with tempfile.TemporaryDirectory(prefix="cargento-assets-cli-") as directory: + output = Path(directory) + + result = self.build_with_cli(output) + + self.assertEqual( + ( + result.returncode, + [path.name for path in sorted(output.iterdir())], + len(result.stdout.splitlines()), + ), + ( + 0, + [ + "cargento-runtime-1.2.3.tar.gz", + "cargento-runtime-1.2.3.tar.gz.sha256", + "install.sh", + ], + 3, + ), + result.stderr, + ) + + def test_main_builds_and_prints_release_assets_in_process(self) -> None: + with tempfile.TemporaryDirectory(prefix="cargento-assets-main-") as directory: + output = Path(directory) + stdout = io.StringIO() + + with ( + patch.object( + sys, + "argv", + ["build_release_assets.py", "--tag", "v1.2.3", "--output-dir", str(output)], + ), + redirect_stdout(stdout), + ): + result = main() + + self.assertEqual( + ( + result, + stdout.getvalue().splitlines(), + ), + ( + 0, + [ + str(output / "install.sh"), + str(output / "cargento-runtime-1.2.3.tar.gz"), + str(output / "cargento-runtime-1.2.3.tar.gz.sha256"), + ], + ), ) def test_ci_builds_and_tests_installer_assets_without_renaming_gates(self) -> None: From 7901566b914b2299bb8f9dd966a795c8afeac727 Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 19:27:39 +0800 Subject: [PATCH 3/7] test(installer): avoid duplicate builder module identity Signed-off-by: Kent --- scripts/tests/test_build_release_assets.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test_build_release_assets.py b/scripts/tests/test_build_release_assets.py index 339ee46..ef82774 100644 --- a/scripts/tests/test_build_release_assets.py +++ b/scripts/tests/test_build_release_assets.py @@ -4,6 +4,7 @@ import hashlib import io +import runpy import subprocess import sys import tarfile @@ -11,12 +12,23 @@ import unittest from contextlib import redirect_stdout from pathlib import Path +from typing import TYPE_CHECKING, cast from unittest.mock import patch -from scripts.build_release_assets import build_assets, main +if TYPE_CHECKING: + from collections.abc import Callable ROOT = Path(__file__).resolve().parents[2] BUILDER = ROOT / "scripts/build_release_assets.py" +BUILDER_GLOBALS = runpy.run_path( + str(BUILDER), + run_name="cargento_release_asset_builder_test", +) +build_assets = cast( + "Callable[[str, Path], tuple[Path, Path, Path]]", + BUILDER_GLOBALS["build_assets"], +) +main = cast("Callable[[], int]", BUILDER_GLOBALS["main"]) class BuildReleaseAssetsTests(unittest.TestCase): From fa964a769593db9cd985a09ae3e9afb83b355602 Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 20:26:10 +0800 Subject: [PATCH 4/7] fix(installer): harden portable release setup Signed-off-by: Kent --- COMPATIBILITY.md | 4 +- README.md | 26 ++++-- SECURITY.md | 8 +- docs/design-installation.md | 9 +- scripts/install.sh.in | 15 ++-- scripts/tests/test_build_release_assets.py | 22 ++++- scripts/tests/test_installer.py | 100 ++++++++++++++++++++- 7 files changed, 160 insertions(+), 24 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 82fa3b7..04d1fea 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -43,8 +43,8 @@ Other notes: - WSL2's `localhostForwarding` defaults on but can be switched off, and mirrored/NAT networking modes or corporate policy can also break host-browser access to `127.0.0.1:4553`. Probe before assuming; the fallback is `ssh -L` or a browser inside WSL. - Supported WSL topology is server and agents on the same side of the boundary. Reading a Windows-side store from inside WSL works over `/mnt/c`, but 9p latency and mtime granularity make state detection unreliable, so it is not supported. - The release installer is POSIX-only. It supports macOS, Linux, and WSL with Python 3.11+, `curl`, - `tar`, a SHA-256 tool, and Claude Code. Native Windows and all non-Claude plugin selectors are - deferred. WSL needs a release smoke before shipping because hosted CI has no WSL runner. + `gzip`, `tar`, a SHA-256 tool, and Claude Code. Native Windows and all non-Claude plugin selectors + are deferred. WSL needs a release smoke before shipping because hosted CI has no WSL runner. - `sqlite3` is an optional stdlib module. On a build without it (some musl/Alpine images) OpenCode, Cursor and Goose report undiscovered. Antigravity still appears, since its discovery and state come from store mtime and CLI logs, but without a token rate or turn ETA. ## Validation diff --git a/README.md b/README.md index 62179b6..816edbf 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,25 @@ This repo contains one plugin, `cargento`, the agent cartography dashboard skill ### Prerequisites - Python 3.11+. The server is stdlib-only, so there is nothing to install alongside it. -- For the supported installer: Claude Code, `curl`, `tar`, and either `sha256sum` or `shasum`. +- For the supported installer: Claude Code, `curl`, `gzip`, `tar`, and either `sha256sum` or + `shasum`. - For manual plugin setup: Codex, Claude Code, Antigravity/AGY, or Gemini CLI. ### Install the CLI and Claude plugin -Choose the release tag you want, then run its installer: +Install the latest release: ```bash -CARGENTO_TAG=vX.Y.Z # replace with the release tag you want +curl -fsSL https://github.com/spacedock-dev/cargento/releases/latest/download/install.sh \ + | sh -s -- --plugin claude +``` + +The latest URL selects only the rendered bootstrap. The bootstrap's archive, checksum, recovery, +and runtime downloads stay pinned to that release's exact tag. For a reproducible install or +rollback, choose the exact tag yourself: + +```bash +CARGENTO_TAG=vX.Y.Z # replace with the exact release tag you want curl -fsSL "https://github.com/spacedock-dev/cargento/releases/download/$CARGENTO_TAG/install.sh" \ | sh -s -- --plugin claude ``` @@ -43,9 +53,13 @@ Plugin (claude): verified ``` If `~/.local/bin` is not already on `PATH`, the result includes an `export PATH=...` line you can -copy. The installer does not edit shell startup files. Run `cargento --diagnose` to check the CLI. -A plugin failure after CLI activation is reported as a partial installation; rerun the same -installer command to repair it. +copy. The installer does not edit shell startup files. A plugin failure after CLI activation is +reported as a partial installation; rerun the same installer command to repair it. + +`cargento` starts the server in the foreground on `127.0.0.1:4553`; then open +`http://127.0.0.1:4553/`. Use `cargento --port 4553` to choose the port explicitly, and press +Ctrl-C in that terminal to stop the server. `cargento --diagnose` reports the paths and stores +Cargento sees, then exits without starting the server. ### Manual and plugin-only setup diff --git a/SECURITY.md b/SECURITY.md index 4902b17..961420d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,9 +25,11 @@ writes to harness stores, or the hook client reaching a non-loopback destination ## Installer trust and writes The installer checks SHA-256 before an archive enters the Cargento data root. It also rejects -absolute paths, parent traversal, links, and an unexpected archive layout before extraction. The -checksum catches corruption and mismatched assets. It is not an independent signature: GitHub HTTPS, -repository release controls, the archive, and its checksum share one distribution boundary. +absolute paths, parent traversal, links, special archive members, and an unexpected archive layout +before extraction; every member must be a regular file or directory under the expected root. +Extraction uses the preflighted `tar` and `gzip` commands. The checksum catches corruption and +mismatched assets. It is not an independent signature: GitHub HTTPS, repository release controls, +the archive, and its checksum share one distribution boundary. Direct installer writes are user-local and need no `sudo`. Runtime versions live under `${XDG_DATA_HOME:-$HOME/.local/share}/cargento/releases/`, `current` is the stable activation link, diff --git a/docs/design-installation.md b/docs/design-installation.md index 7d8d0b1..9b40a2f 100644 --- a/docs/design-installation.md +++ b/docs/design-installation.md @@ -19,10 +19,11 @@ archive from the released commit, normalizes archive metadata for deterministic tag and filenames into `install.sh`, and uploads a SHA-256 checksum beside it. A resumed workflow rebuilds and replaces the same three assets. -The installer downloads both files from the exact tag, checks the digest, validates archive members, -and extracts only into a temporary directory before activating the release. SHA-256 detects -corruption or an asset mismatch. It does not provide an independent signature because the archive -and checksum are controlled by the same GitHub release boundary. +The installer downloads both files from the exact tag, checks the digest, and requires every archive +member to be a regular file or directory beneath the expected root. It then extracts with the +preflighted `tar` and `gzip` commands, only into a temporary directory, before activating the +release. SHA-256 detects corruption or an asset mismatch. It does not provide an independent +signature because the archive and checksum are controlled by the same GitHub release boundary. ## Claude state is an external contract diff --git a/scripts/install.sh.in b/scripts/install.sh.in index cbf3983..49d198d 100644 --- a/scripts/install.sh.in +++ b/scripts/install.sh.in @@ -33,7 +33,7 @@ if [ "$#" -ne 2 ] || [ "$1" != '--plugin' ] || [ "$2" != 'claude' ]; then usage fi -for command_name in python3 curl tar claude mktemp mkdir rm ln mv chmod; do +for command_name in python3 curl gzip tar claude mktemp mkdir rm ln mv chmod; do if ! command -v "$command_name" >/dev/null 2>&1; then fail "required command not found: $command_name" fi @@ -107,12 +107,13 @@ required = { "cargento/.claude-plugin/plugin.json", } safe = all( - member.name == "cargento" - or ( - member.name.startswith("cargento/") - and ".." not in member.name.split("/") - and not member.issym() - and not member.islnk() + (member.isdir() or member.isfile()) + and ( + member.name == "cargento" + or ( + member.name.startswith("cargento/") + and ".." not in member.name.split("/") + ) ) for member in members ) diff --git a/scripts/tests/test_build_release_assets.py b/scripts/tests/test_build_release_assets.py index ef82774..190102e 100644 --- a/scripts/tests/test_build_release_assets.py +++ b/scripts/tests/test_build_release_assets.py @@ -4,6 +4,7 @@ import hashlib import io +import os import runpy import subprocess import sys @@ -75,7 +76,8 @@ def test_builds_deterministic_self_consistent_release_assets(self) -> None: second_assets, first_bytes == second_bytes, checksum.read_text(), - bool(installer.stat().st_mode & 0o111), + os.name != "posix" or bool(installer.stat().st_mode & 0o111), + installer_body.startswith("#!/bin/sh\n"), "v1.2.3" in installer_body, archive_name in installer_body, ( @@ -100,9 +102,27 @@ def test_builds_deterministic_self_consistent_release_assets(self) -> None: True, True, True, + True, ), ) + def test_readme_documents_latest_exact_and_cli_runtime_contracts(self) -> None: + readme = (ROOT / "README.md").read_text() + + self.assertEqual( + ( + "releases/latest/download/install.sh" in readme, + "releases/download/$CARGENTO_TAG/install.sh" in readme, + "pinned to that release's exact tag" in readme, + "`cargento` starts the server in the foreground" in readme, + "`cargento --port 4553`" in readme, + "http://127.0.0.1:4553/" in readme, + "Ctrl-C" in readme, + "`cargento --diagnose` reports" in readme, + ), + (True, True, True, True, True, True, True, True), + ) + def test_rejects_non_semver_tag_without_writing_assets(self) -> None: with tempfile.TemporaryDirectory(prefix="cargento-assets-invalid-") as directory: output = Path(directory) diff --git a/scripts/tests/test_installer.py b/scripts/tests/test_installer.py index 0892f4b..f66edbe 100644 --- a/scripts/tests/test_installer.py +++ b/scripts/tests/test_installer.py @@ -2,14 +2,19 @@ from __future__ import annotations +import http.client +import io import json import os import shutil +import signal +import socket import subprocess import sys import tarfile import tempfile import textwrap +import time import unittest from hashlib import sha256 from pathlib import Path @@ -72,7 +77,7 @@ def _link_tool(self, name: str) -> None: (self.tools / name).symlink_to(target) def _make_tools(self) -> None: - for name in ("tar", "mktemp", "mkdir", "rm", "ln", "mv", "chmod"): + for name in ("gzip", "tar", "mktemp", "mkdir", "rm", "ln", "mv", "chmod"): self._link_tool(name) hash_tool = "sha256sum" if shutil.which("sha256sum") else "shasum" self._link_tool(hash_tool) @@ -241,6 +246,12 @@ def test_preflight_rejects_missing_download_tool_without_mutation(self) -> None: result = self.run_installer("--plugin", "claude") self.assert_preflight_failure(result, "required command not found: curl") + def test_preflight_rejects_missing_gzip_without_mutation(self) -> None: + (self.tools / "gzip").unlink() + + result = self.run_installer("--plugin", "claude") + self.assert_preflight_failure(result, "required command not found: gzip") + def test_preflight_rejects_missing_hash_tool_without_mutation(self) -> None: for name in ("sha256sum", "shasum"): (self.tools / name).unlink(missing_ok=True) @@ -267,6 +278,51 @@ def test_rejects_wrong_archive_layout_before_activation(self) -> None: result = self.run_installer("--plugin", "claude") self.assert_preflight_failure(result, "unexpected runtime archive layout") + def test_rejects_unsafe_archive_members_before_activation(self) -> None: + cases = ( + ("traversal", "../escape", tarfile.REGTYPE, ""), + ("symlink", "cargento/link", tarfile.SYMTYPE, "skills/cargento/server.py"), + ("hardlink", "cargento/hard", tarfile.LNKTYPE, "cargento/skills/cargento/server.py"), + ("fifo", "cargento/pipe", tarfile.FIFOTYPE, ""), + ) + for label, name, member_type, linkname in cases: + with self.subTest(label=label): + archive = self.assets / "cargento-runtime-1.2.3.tar.gz" + with tarfile.open(archive, "w:gz") as bundle: + root = tarfile.TarInfo("cargento") + root.type = tarfile.DIRTYPE + bundle.addfile(root) + for required in ( + "cargento/skills/cargento/server.py", + "cargento/.claude-plugin/plugin.json", + ): + body = b"{}\n" + member = tarfile.TarInfo(required) + member.size = len(body) + bundle.addfile(member, io.BytesIO(body)) + hostile = tarfile.TarInfo(name) + hostile.type = member_type + hostile.linkname = linkname + if member_type == tarfile.REGTYPE: + hostile.size = 0 + bundle.addfile(hostile, io.BytesIO(b"")) + checksum = sha256(archive.read_bytes()).hexdigest() + (self.assets / f"{archive.name}.sha256").write_text(f"{checksum} {archive.name}\n") + + result = self.run_installer("--plugin", "claude") + + self.assertEqual( + ( + result.returncode != 0, + "unexpected runtime archive layout" in result.stderr, + self.data_root.exists(), + self.bin_dir.exists(), + self.claude_state.exists(), + (self.root / "escape").exists(), + ), + (True, True, False, False, False, False), + ) + def test_installs_cli_and_lagging_marketplace_plugin_idempotently(self) -> None: shell_profile = self.home / ".zshrc" shell_profile.write_text("# preserved\n") @@ -343,6 +399,48 @@ def test_installs_cli_and_lagging_marketplace_plugin_idempotently(self) -> None: first.stderr or second.stderr or diagnose.stderr, ) + def test_installed_launcher_serves_until_sigint(self) -> None: + install = self.run_installer("--plugin", "claude") + launcher = self.bin_dir / "cargento" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + process = subprocess.Popen( + [str(launcher), "--port", str(port)], + env=self.environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def stop_process() -> None: + if process.poll() is None: + process.kill() + + self.addCleanup(stop_process) + payload: dict[str, object] | None = None + for _ in range(100): + try: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=1) + connection.request("GET", "/api/data") + response = connection.getresponse() + payload = json.loads(response.read()) + connection.close() + break + except OSError: + time.sleep(0.05) + process.send_signal(signal.SIGINT) + process.wait(timeout=5) + + self.assertEqual( + ( + install.returncode, + isinstance(payload, dict), + process.poll() is not None, + ), + (0, True, True), + install.stderr, + ) + def test_rejects_same_name_different_source_collision(self) -> None: self.claude_state.write_text( json.dumps( From 6011352fcdcc899f8509e241a0a9ee36a3be7acb Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 21:05:47 +0800 Subject: [PATCH 5/7] docs(dev): publish the Cargento workflow Signed-off-by: Kent --- .gitignore | 3 + AGENTS.md | 5 + COMPATIBILITY.md | 2 +- README.md | 8 +- docs/dev/README.md | 679 +++++++++++++++++++++++ docs/dev/_mods/pr-merge.md | 112 ++++ docs/dev/_mods/reverse-recovery-audit.md | 69 +++ docs/dev/ledger.csv | 1 + 8 files changed, 875 insertions(+), 4 deletions(-) create mode 100644 docs/dev/README.md create mode 100644 docs/dev/_mods/pr-merge.md create mode 100644 docs/dev/_mods/reverse-recovery-audit.md create mode 100644 docs/dev/ledger.csv diff --git a/.gitignore b/.gitignore index d4c2938..8a0fa81 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ *.py[cod] .coverage + +# Per-workspace Spacedock entity state stays in its independent local checkout. +docs/dev/.spacedock-state/ diff --git a/AGENTS.md b/AGENTS.md index 53b6916..15a8061 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ scripts/ └── tests/ ├── test_build_release_assets.py └── test_installer.py +docs/dev/ # tracked Spacedock development workflow +├── README.md # workflow schema and evidence discipline +├── ledger.csv # measurement ledger +└── _mods/ # workflow-specific lifecycle behavior ``` The Codex/AGY marketplace lives at `.agents/plugins/marketplace.json`. There is no Claude @@ -51,6 +55,7 @@ shipped skill body, lives in the `sync-docs` skill at `.claude/skills/sync-docs/ | `COMPATIBILITY.md` | The cross-harness and cross-platform contract, and the Python floor. | | `SECURITY.md` | Security invariants, accepted exposures, and private reporting. | | `cargento/skills/cargento/SKILL.md` | The shipped product surface. A validated artifact — see the portability rules below. | +| `docs/dev/README.md` | The tracked Spacedock development workflow; its split-root entity state remains local and ignored. | | `docs/design-installation.md` | Installer ownership, trust boundary, and rejected distribution alternatives. | | `docs/design-*.md` | Durable design rationale, including alternatives that were tried and rejected. | | `docs/plans/*.md` | Transient plans for unshipped work. Delete a plan once its work ships. | diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 04d1fea..e1319de 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -58,4 +58,4 @@ claude plugin validate ./cargento --strict agy plugin validate ./cargento ``` - + diff --git a/README.md b/README.md index 816edbf..af294ee 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,11 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for the cross-platform contract. ## 5. Contributing Contributions are welcome, and new harness support is especially useful. Start with -[CONTRIBUTING.md](CONTRIBUTING.md) for setup, validation, and PR conventions. This project follows -the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). Please report security issues -privately, as described in [SECURITY.md](SECURITY.md). +[CONTRIBUTING.md](CONTRIBUTING.md) for setup, validation, and PR conventions. Maintainers using +Spacedock can follow the tracked [development workflow](docs/dev/README.md) without publishing its +per-workspace entity state. This project follows the +[Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). Please report security issues privately, +as described in [SECURITY.md](SECURITY.md). ## 6. License diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 0000000..91dbca5 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,679 @@ +--- +commissioned-by: spacedock@0.26.0 +entity-type: task +entity-label: task +entity-label-plural: tasks +id-style: sd-b32 +state: .spacedock-state +trunk: main +stages: + defaults: + worktree: false + concurrency: 2 + states: + - name: backlog + initial: true + gate: true + - name: ideation + gate: true + - name: implementation + worktree: true + - name: validation + worktree: true + fresh: true + feedback-to: implementation + gate: true + - name: done + terminal: true +--- + + + + +# Cargento — Development Workflow + +Cargento is a markdown-first, cross-harness agent cartography plugin. This +workflow ships changes to its shared skill, stdlib-only Python dashboard, +manifests, validators, release automation, and owned documentation without +breaking Codex, Claude Code, Antigravity/AGY, Gemini CLI, or supported host +platforms. The repository contract and canonical pre-PR suite live in +`AGENTS.md`. + +Tasks move `backlog → ideation → implementation → validation → done`. One +gated design stage (ideation), one worktree build stage (implementation), one +fresh-context verification stage (validation) with `feedback-to` +implementation, and a terminal merge. The spacedock binary owns all runtime +semantics: stage transitions, gate records, worktree lifecycle, state +durability, exactly-once approval. This README owns judgment discipline only. + +## File Naming + +Each task is `{slug}.md` (default) or a folder `{slug}/index.md` when +per-stage artifacts accumulate. Slugs: lowercase, hyphens, no spaces. Task +state lives in the split-root state checkout (`state:` above) so stage +transitions never churn the code branch. + +The workflow specification, `_mods/`, and `ledger.csv` are git-tracked and +shared. Only `docs/dev/.spacedock-state/` stays local: it is an independent Git +repository on the `spacedock-state/dev` branch, ignored by the product +repository, and deliberately has no `origin`. Spacedock therefore commits +state changes path-scoped inside that repository and attempts no pull or push. +Each workspace has its own state checkout and exactly one session owns its +mutations. Verify the workspace's state owner before filing: + +```bash +git -C docs/dev/.spacedock-state rev-parse --abbrev-ref HEAD # expect spacedock-state/dev +``` + +Never publish or copy the split-root state checkout into the product +repository. The tracked workflow files describe the shared process; they do +not make entity receipts portable or create an automatic state-publishing +path. + +## Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | SD-B32 stored ID from `status --next-id --id-seed ` | +| `title` | string | Human-readable task name | +| `status` | enum | backlog, ideation, implementation, validation, done | +| `source` | string | Where the task came from (captain note, issue, defect, audit) | +| `started` / `completed` | ISO 8601 | `started` at the first transition out of `backlog`, `completed` at the `done` transition — `wallclock_hours` is their difference, so a task that sits in the queue for a week does not bill that week | +| `verdict` | enum | PASSED or REJECTED — set at final stage | +| `score` | number | Optional priority score from 0.0 to 1.0 | +| `worktree` | string | Set on first worktree dispatch, cleared at terminal merge | +| `issue` / `pr` | string | External references | +| `design` | enum | `required` or `trivial-pass` — set at ideation, or at filing for a `lane: defect` task, which has no ideation stage. May be empty only while the task is still in `backlog`; never empty once it leaves | +| `lane` | enum | `defect` or `main` — the FO's Defect-lane classification, recorded at filing so it is queryable (`status --where lane=defect`) instead of re-derived by re-reading every body. `defect` asserts all four conditions below hold | + +## Proof Policy + +Inherited from the spacedock proof discipline; the six rules below are +binding in every stage report and every gate review. + +1. **No prose-grep, and provenance decides independence.** A string match + over an instruction file the model reads never proves a behavioral claim. + A grep may serve as one-off evidence for an existence fact in a validation + report; the same grep committed as a test is banned — it cannot fail. And + a check the author wrote to grade the author's own artifact is a self-issued + stamp, not a gate. This is about what closes a gate, not about who may write + a test: the worker's own RED-before-GREEN tests are exactly the evidence + this workflow asks for, and they become insufficient only when they are also + offered as the independent verdict on themselves. Independence at a gate + comes from the fresh-context validator and the cross-model pass, never from + the artifact grading itself. +2. **Evidence must be able to fail.** Each AC's cited evidence names the + concrete change that would flip it. If the author cannot name the + falsifying edit, the criterion does not count. +3. **Prove behavior by exercising it.** Output bytes, exit codes, resulting + on-disk state, a browser actually driving the flow. Unit tests prove logic; + they do not prove wiring. Seam-level claims need runtime or E2E evidence. +4. **Trace every mechanism to value.** Any new mechanism names the value AC it + serves, the simplest alternative considered, and why that alternative is + insufficient. A test harness orchestrates and observes the supported + runtime; it never becomes a second implementation of the system under test. +5. **Automatic must-pass behavior checks live at stage boundaries, never in + the worker's inner loop.** Hooks that fire on every commit/edit inside a + work session are limited to fast mechanical checks (format, lint, + typecheck). Behavioral or corpus/consistency checks, *as must-pass gates*, + belong to the validation gate and CI: a must-pass check inside the inner + loop turns "implement the behavior" into "make the check shut up", and the + worker will drift the implementation — or the check's inputs — to satisfy + it. This governs checks the tooling forces, never tests the worker chooses + to run: RED-before-GREEN requires running the behavior's own tests inside + that loop, and that is the mechanism working, not an exception to it. + +6. **A negative result is a claim, and carries the same bar as a positive + one.** "The search found nothing" is evidence about the search. "The file is + unchanged" is evidence about the file, not about the failure. A number + measured while you were perturbing the system is evidence about the + perturbation. Before reporting an absence — no such test, no such caller, + nothing tracked, not a regression — name the scope actually searched and why + that scope is the population, or run a second strategy that would have found + the thing if it existed: one tool, one pattern, one filter is a sample, not a + census. And an unexplained signal is traced, never assigned an invented + origin — "probably another session" is a story, not a cause. + +## Cargento Project Contract + +- **The canonical gate is not abbreviated.** Run the complete pre-PR suite in + `AGENTS.md`, including docs sync, version-field parity, coverage, and native + validators when their CLIs are installed. CI evidence is tied to the exact + reviewed HEAD. +- **Runtime stays stdlib-only on Python 3.11+.** A new runtime dependency, + interpreter-floor change, or platform-specific assumption is a design + decision, not an implementation convenience. +- **One shared skill must remain portable.** Bundled skill Markdown cannot use + host-specific resource variables, cache paths, or tool API names. Exercise + affected behavior through every relevant harness contract. +- **Version fields are release-owned.** Feature PRs do not edit manifest + versions. The tag-driven release workflow owns version movement. +- **Security invariants are load-bearing.** The dashboard remains bound to + `127.0.0.1`, reads harness stores without mutating them, and never widens its + documented project-read surface without explicit security review. +- **Docs ownership is explicit.** Update the owning file and link to it from + other surfaces; run the repository's `sync-docs` development skill before + proposing a PR. + +## Stages + +Every stage report opens with a one-paragraph TL;DR; raw command output, +full diffs, and re-derivations go in collapsed or linked sections. A report +that reads like a session transcript costs reading budget nobody spends. + +### `backlog` — capture (this is the todo queue) + +Any idea, rabbit hole, defect, or captain note enters as a seed task file: +title, `source`, one-paragraph description. Target cost: under two minutes. +Capturing a seed triggers NO design work — the gate is where the captain +curates what advances. A seed too vague for the captain to triage is the only +"bad" here. + +#### Defect lane — skip `ideation` for a bounded fix + +A known defect with a mechanical acceptance test does not need a design stage. +When **all four** hold, the FO advances `backlog → implementation` directly. The +verdict goes in the `lane` frontmatter field so it is queryable, and its +justification in the task body — a classification that lives only in prose gets +re-derived by re-reading every open task, which is the expensive way to learn +something already decided: + +1. The root cause is already identified and cited at `file:line`. +2. Acceptance is mechanical — a test that fails before the fix and passes after. +3. It is a single seam: one surface, no cross-layer ripple, no schema change. +4. No design decision is open. If the fix has two defensible shapes, it is not + in this lane. + +Everything else still applies: RED-before-GREEN, the proof policy, the +validation stage, and the merge bar. **The lane removes a design stage, never +verification** — and a defect whose fix turns out to need a design decision +goes back to `ideation` rather than being decided inside implementation. + +The lane removes the stage, never the stage's outputs. Ideation produces four +things later stages read back — a design determination, the ACs validation +checks against, the appetite and tolerance the correction-round budget measures +against, and the implementation dispatch sizing — so **the FO writes all four +at filing**: `design: trivial-pass` reasoned by the fourth condition above, one +AC that is the mechanical test named by the second, a one-line estimate with +its tolerance, and the sizing (for a bounded fix, one dispatch, unless the +filing says otherwise). That filing is +the lane's ideation of record; every clause elsewhere that says "the +ideation-declared X" reads it here. The lane's AC bar is that mechanical test +alone — a bounded fix restores behavior rather than delivering new value, so +the value-AC requirement does not apply, and a defect that needs one is not a +bounded fix and belongs in the main line. A defect filed missing any of the three is +not in this lane — it is an unfinished filing, and goes back for the same +reason an ideation gate without a design determination is returned unread. + +Any of the four failing means the main line. When in doubt it is the main line; +the cost of over-shaping one fix is smaller than the cost of designing inside +an implementation stage nobody is reviewing for design. + +### `ideation` — one gate for design, plan, and acceptance + +The single judgment-heavy stage. Flesh out the problem, decide the approach, +define acceptance criteria and the test plan. The gate reviews all of it at +once. Discipline clauses: + +- **The captain authors scope; the agent never infers it for a + rubber-stamp.** For non-trivial tasks, open ideation by asking the captain + a few short scope questions (what gets worse without this; the time + budget; what to keep if forced to cut; what we are happily NOT doing; + which assumption could be wrong) and compose Problem/Scope from the + answers verbatim. Skip only with a stated small-scope reason. +- **Appetite is a forcing budget.** Record a time/scope budget in the task + body, plus the deviation past which the work stops and gets re-cut rather + than continuing. Those two numbers are the "ideation-declared estimate" and + the "declared tolerance" the validation stage's correction-round budget + measures each rework round against; a task that declares neither has nothing + for that brake to read. When work is about to exceed it: cut scope (defer a sub-part to + backlog) or park cleanly with re-enterable state and explicit open + findings — never extend the budget silently, and never compress + validation to land inside it. Size or budget variance is a drift signal + to investigate, never a number to hit by padding artifacts or stripping + tests. +- **The cheapest path that satisfies the AC is the default, and the gate is + told which one it took.** Ideation answers two questions in the task body + before choosing an approach: *what is the fastest path?* and *what is the + smallest cut?* It then records the cheaper option it is taking, the more + thorough option it is not taking, and why the difference is not needed to + satisfy the AC. **Default to the cheap one.** This is a scope default, never + a quality one — the proof policy, the AC bar, RED-before-GREEN and the + validation stage are untouched, and "cheap" never means thinner evidence. + The FO surfaces the choice at the gate in one line ("taking the cheap path: + X; deferring Y") so the captain can override it before work starts. A cheap + path taken silently is the agent authoring scope, which the clause above + forbids — and an expensive path taken by default is the more common and + more expensive mistake, because nobody is ever asked to approve it. +- **One-sentence pre-mortem.** Before the gate: "if this ships exactly per + spec and still fails, the most likely cause is ___" — pick one of {wrong + problem, criteria that pass without delivering value, wrong framing lens, + hidden assumption, over-conviction}. This is an orthogonal + future-failure check the AC rubric structurally cannot generate. +- **Design determination is mandatory, never skipped.** Every task records + `design: required` (UI, contract, interface, schema, or visual surface + affected — attach the concrete design decision: wireframe reference, API + shape, before/after) or `design: trivial-pass` with a one-line reason. An + ideation gate presented without a design determination is returned unread. +- **Reverse-recovery audit before any "build/add X"** (brownfield default): + assume the abstraction may already exist. Layer-trace the path (UI → + contract → handler → domain → persistence → readback) and classify each + layer WORKING / EXISTS_BROKEN / STUB / MISSING with file:line. Greenfield + is allowed only after proof of absence (multi-strategy, multi-language + search) — the general bar for any absence claim is Proof Policy rule 6. A single broken seam means repair scoped to that seam, not a + rebuild. Full procedure: `_mods/reverse-recovery-audit.md`. + **Audit against the merge target** (fetch `origin/` first), never + only the working branch — a stale branch shows stale infrastructure, and + a MISSING verdict read off it can be seven weeks wrong. Implementation + re-verifies the audit's load-bearing MISSING claims against a fresh + merge target before building, and escalates instead of building when a + premise has collapsed. + **Enforcement facts are read live, never inferred from repo files**: + what CI actually requires (required checks, branch protection) comes + from the platform API (e.g. `gh api .../branches//protection`), + and a change touching a required job treats the job `name:` as that + protection's identity — adding steps is identity-safe; renaming the job + silently drops the protection. +- **AC are end-state properties with falsifiable proof.** Each AC names a + property of the finished task (not a stage action) plus a `Verified by:` + clause citing proof outside the task's own prose. At least one AC measures + the end value the task exists for, against a baseline that can move the + wrong way. +- **E2E-first acceptance.** When the task changes full-stack or user-visible + behavior, at least one AC is verified by exercising the real flow end to + end (browser drive, CLI invocation, service round-trip). Unit-only proof is + insufficient for wiring claims. Skip only for docs/config/CI-only tasks, + and record the skip reason. +- **Doc diff proposed here.** When the task changes behavior described by an + owned Cargento document, ideation proposes the concrete doc diff + (before/after wording) in the task body. The gate reviews it; implementation + applies it; validation verifies behavior diff and doc diff landed together. +- **Spike the riskiest unverified mechanism first**, and record the result in + the task body — or record "no spike needed: {proven mechanisms relied on}" + so the determination is auditable. +- **Size the implementation dispatch here.** Default is ONE worker session — + every extra dispatch pays a cold-start (re-reading the README, task body, + and surrounding code). Split only when the estimate exceeds ~90 minutes, + the work has 3+ independent behaviors, or parallel worktree lanes buy real + wall-clock — and always split along behavior boundaries, each slice a + complete RED→GREEN loop (never "tests in one dispatch, code in the next"). + Record the sizing decision in the task body so implementation inherits it. + +### `implementation` — build in a worktree, test-first + +- **RED before GREEN, with evidence.** For each behavior: write the failing + test, run it, record the RED evidence in the stage report (test name + + failure output digest), then write the minimum code to pass. GREEN without + recorded RED is treated by validation as unproven — tests written after the + fact to confirm existing code do not count. +- **Count new assertions against the RED output.** Every assertion added must be + *able* to appear as a failure in that run. A case stops at its first failing + assertion, so later assertions in the same case never execute — compare failing + *cases* against the cases that should fail, and for the rest ask per assertion + whether any RED run could reach it. One that would be green in RED holds in the + pre-fix world too, so it is decoration, not evidence — rewrite it to pin the + literal expected value, or delete it. This is the mechanical enforcement of + "evidence must be able to fail"; the RED record aims at it but does not check + it, and the tell is an added assertion no RED run can reach. +- **When you change a behavior, audit the tests that arrange the old one.** A + suite that goes green after a behavior change can mean a fixture was silently + re-purposed rather than that coverage held. Grep the suite for scenarios that + *set up* the behavior under repair, and state per scenario whether the edit + restored its original intent or quietly narrowed it. +- **A change that adds tests checks the CI job's remaining margin before + pushing.** Job-level cancellation presents as a red check with **no failing + assertion** — every suite reports passing and the step is killed anyway — + which reads like a flake and invites a retry instead of a diagnosis. Thin + margin is a gate-level disclosure, not a CI discovery. +- **RED and GREEN close in the same session, and commit together.** Never + commit failing tests as a handoff contract for a later worker: an agent + handed a red suite optimizes for "make it green", and will drift the + implementation to fit a possibly-wrong test — or the test to fit the + implementation — instead of delivering the behavior. The RED record is + stage-report evidence; committed tests arrive with the code that passes + them. If a session must stop mid-loop, the unfinished RED work stays + uncommitted and the stage report says exactly where the loop stopped. +- **Scoped tests in the loop, full suite plus ripple at the exit.** During the + build loop run only the tests scoped to the behavior under change (file, + module, or tagged subset). Run the full suite exactly once, after scoped + tests are green, as the stage-exit regression check — not on every + iteration — and for a change to shared code, every affected validator and + native platform check named by the canonical suite in `AGENTS.md`. + **The exit condition is never "the reported error is gone."** That is a + not-a-regression claim and Proof Policy 6 governs it: the one spec that named + the bug was never the population. A failure surviving the exit run is written + off as pre-existing only by the per-failing-line rule the validation stage + states — never per file, never per impression. +- Minimal diff that satisfies the AC. No unrelated refactoring. Apply the doc + diff approved at ideation in the same branch. +- The deliverable must be self-contained for a fresh validator: stage report + says what was produced, where, and how to run it. + +### `validation` — fresh eyes, adversarial by default + +A fresh-context agent verifies the deliverable against the ideation AC. The +validator checks what was produced; it never finishes the work. + +The gate is presented with a filled **evidence block** — one line of *specific, +falsifiable* evidence per item (presence of text is not the bar), and anything +left blank counts as not-done, never a silent pass. It records five lines — +`Lenses:` (the diff classification, and per fired lens its verdict and finding +count), `Diff coverage:` (the measured %), `Adversarial:`, `Cross-model:`, +`E2E:` — each naming what was actually run and what it returned. + +**Any of them may be written `N/A — `, never a bare `N/A`.** A skip +without its reason and a skip with one do not read alike, which is the same +rule this workflow applies to `escaped_defects_7d`. **The condition that +permits the skip lives in that field's own clause, and is not restated here** +— for `E2E:` that is the E2E-first acceptance clause at ideation; for the +rest, the validation clauses in this section. Only two are set here, because +nowhere else states them: `Adversarial: N/A — ` for a diff with no +behavioral guard to break, and `Diff coverage: N/A — no coverable source` when +the coverage gate reports none. `Lenses:` and `Cross-model:` are never `N/A`. +Scale changes how deep each item goes, never whether it runs — this block is +where an agent is tempted to convert "small" into "skipped", and small is not +a skip condition for any of the five. A gate presented without the block is +returned unread — the +same bar the ideation stage's design determination is held to. + +- Reproduce each AC's `Verified by:` clause; report PASS/FAIL per criterion + with actual evidence (command output, screenshots, on-disk state) — never + the implementer's self-report. Same execution order as implementation: + scoped checks per AC first, one full-suite run at the end — a full-suite + failure outside the diff's blast radius is reported as context, not + debugged by the validator. +- **Lens selection is mechanical, not a judgment call.** Classify the diff and + fire every matching lens; a "touches none" is justified by naming the surfaces + the diff *does* touch (so a reviewer can check the classification — not by an + adversarial revert, which tests code, not a skipped lens). Correctness always + fires; then, by what the diff touches: **security** (auth / permission / trust + boundary) · **silent-failure** (error handling, input validation, fallbacks, + swallowed errors) · **type-design** (a new or changed type) · **concurrency** + (locks, async ordering, shared/mutable state) · **resource-lifecycle** + (processes, handles, memory, unbounded growth) · **migration/back-compat** (a + schema, wire-contract, or persisted-state change — does old data or an + in-flight peer still work?). The independent cross-model gate (below) always + runs and is recorded separately; it is not one of these lenses. For prose + diffs (skills, agents, hooks-as-instructions), the correctness lens is + **exercise-based**: actually invoke the changed skill/hook and observe + behavior — a prose change reviewed only by reading is not reviewed. (Reviewer + agents, fully qualified so the identifier can be dispatched as written: + `pr-review-toolkit:code-reviewer`, `pr-review-toolkit:silent-failure-hunter`, + `pr-review-toolkit:type-design-analyzer`, `kc-pr-flow:tob-security-reviewer`.) +- **A documented guarantee is a claim, and gets the AC treatment.** When a doc + diff states an absolute — "only", "always", "never", "exactly one" — name the + input or edit that would falsify it, and check it, exactly as an AC names its + falsifier. A guarantee the enforcement point does not make is a defect **in + the doc even when the code is correct**, and a worse one than an undocumented + gap, because the next reader builds on it. Validation verifies doc *claims*, + not just doc presence. +- **Verify reviewer citations before acting on findings.** Check every cited + `file:line` against the actual file — LLM reviewers fabricate plausible + citations. If more than roughly a third of one reviewer's citations are + wrong, discard that reviewer's entire round rather than triaging it. And + when writing off a failure as pre-existing, prove it per failing line + (blame against the change's commit range), never per file or surface — and + never from a run whose conditions you were perturbing yourself. +- **Converge by naming residuals.** When a review round's findings stop + being fixable defects and become a named class the chosen approach + genuinely cannot solve, stop iterating: record the residual and its + acceptance reason instead of opening another round. Chasing irreducible + residuals is gold-plating dressed as rigor. +- **Cross-model gate before merge approval**: run one independent cross-model + review of the diff. **Cross-vendor is relative to the model running the gate**, + not a fixed list: pick the first available tool from a different vendor than + the reviewing model — from a Claude session that is `codex` → `agy`, from a + codex session it starts at `agy`. A lighter variant from the same family is + not a second vendor and does not satisfy this. No single vendor is required, + but skipping the second opinion entirely is not. **Unavailability is established by an + attempted run that failed** (quota, auth, missing binary), never assumed — + record which model ran the gate, which reviewer ran, and when a preferred one + was skipped, the observed failure. A P1 finding is fixed or explicitly waived + with a recorded reason at the gate — never silently dropped. +- Exercise the E2E AC in the real runtime. Whether the task owes one at all is + decided by the E2E-first clause at ideation, not here. +- **Coverage is a ratchet, not a target.** The mechanical floor is the + `fail_under` value currently configured in `pyproject.toml`, and repo-wide + coverage never decreases from its measured baseline. + A red coverage check is fixed or explicitly waived at the gate with a + recorded reason. Coverage percent is never an AC by itself — + RED-before-GREEN evidence proves behavior; the percentage only catches + untested seams the TDD loop missed. +- **Adversarial spot-check.** For one or two core behaviors, make a + claim-breaking edit (revert a guard, flip a boundary) in a scratch copy and + confirm the suite goes red. A suite that stays green under a claim-breaking + edit is a hole — route back with that evidence. +- **Live-CI red evidence short-circuits per step.** When an AC requires + proving a required check actually fails on bad input, use a non-draft + probe PR observed red on live CI — and plan one probe commit per step: + steps within a CI job short-circuit, so a single red run proves only the + first failing step, and proving N steps each go red takes N sequential + probe commits. Close the probe PR without merging, delete its branch, + and record the run URLs as gate evidence. +- Rejection routes back to implementation (`feedback-to`) with concrete, + file-anchored fixes. A second consecutive rejection at this gate ends the + loop and goes to the captain, per Gate Authority. **The trigger is the + count, not the findings** — a cycle that closes every prior finding and + immediately surfaces new ones on adjacent surfaces is the stronger stop + signal, not a fresh start: the approach cannot hold the boundary, and the + next cycle finds the next surface. Counting only repeated findings never + fires on that case, and it is the common one. +- **Every correction round carries a budget record.** Each rework round + appends one entry: the round's actual effort against the ideation-declared + estimate, the deviation, and the findings disposition. Past the declared + tolerance, record a design-reset decision (back to ideation to re-cut) + before opening any further round — the counter-based escalation above and + this budget-based brake are independent circuit breakers. A round whose + findings are all declined records `0 fixed` with every decline named: + "nothing was found" and "everything found was declined" must never read + alike. +- **Rework re-anchors on the source requirement.** On any route-back, the + rework agent re-reads the original requirement and diffs it against the + current ACs before touching code — rework loops naturally optimize + against intermediate artifacts and silently drop original constraints. + Any dropped constraint is restored or explicitly justified first. **The diff + runs the other way too: name every changed file no AC requires, and either + delete it or state which AC it serves.** A rework loop adds machinery as + readily as it drops constraints, and added machinery is the more expensive + direction — it arrives with its own defects and its own review rounds, and + each round it survives makes it look more load-bearing than it is. +- **One scope checkpoint before the first validation dispatch.** The FO sends + the captain one line: files and lines changed, and **which changed files map + to no AC**. Map each file to the AC it serves and report the unmapped ones — + do not ask whether an AC names the file. ACs are end-state properties and + rarely name an implementation path, so a name-matching check reports every + legitimate file as unnamed while a stray one whose path happens to appear in + an AC slips through. + This is a notification with an optional veto, not a gate — the FO proceeds + unless the captain answers. It exists because scope is the captain's alone to + hold, and the cheapest moment to cut is after the diff is real but before + review rounds have compounded on it. A round spent reviewing machinery nobody + wants is paid twice: once to find its defects, once to fix them. + +### `done` — terminal + +Merge after a passed validation gate (merge policy: PR to `main`), set `completed` and `verdict`, archive the task. Record the +measurement ledger row (below) in the same transition. + +- **Merge only on observed green CI for the exact HEAD.** A passing local + suite, a static PR approval, or "CI was green earlier" never substitutes + for a live CI run observed green on the commit being merged. A red or + running check at merge time blocks the merge — no exceptions by memory. + +## Continuation & handoff + +Picking up an in-flight branch — a closed sibling tab, a session-limit resume, a +handoff record — does **not** inherit the prior agent's validation. Before +advancing: inventory what the prior agent left (committed **and** +uncommitted/WIP working-tree state), re-anchor on the source requirement, +re-classify the diff, and reconcile any upstream drift that landed on the trunk +during the hiatus. Those four are owed at whatever stage the work resumes. The +validation evidence block is **not** re-run by the resuming implementer — that +would be the self-report this workflow forbids; it is owed on entering or +re-entering `validation`, against a fresh merge target, by the fresh-context +validator that stage requires. +A prior agent's "mostly done / green tests / one review passed" is +a starting point to verify, never a validation to trust — the continuation frame +is exactly where a half-done validation gets silently inherited as complete. +Re-verify every inherited finding's load-bearing claim against the code, not the +prior narrative. This is the `Rework re-anchors on the source requirement` clause +with a broader trigger (any resumed work), not a separate workflow. + +## Gate Authority + +A gate is a decision point, not a status report. Who holds it depends on the +kind of decision, not on which stage it sits at. + +| Seat | Holds | Examples | +|------|-------|----------| +| **Captain** | Direction and irreversibility | Scope authorship; what to work on next; schema / architecture / scope-cut / costly_no; accepting a documented residual against a red gate; any seat disagreement | +| **EM** (`ship-flow:science-officer-em`) | Bounded judgment on completed work | The ideation and validation verdicts — proceed / narrow / return / block | +| **FO** | Nothing adjudicative | Checklist accounting, AC-evidence presence, dispatch, merge mechanics, cleanup | + +**Default: EM holds the gate.** The FO assembles the review — checklist +accounting, AC cross-check, reviewer findings — and routes it to EM for the +verdict. The FO neither renders the verdict itself nor forwards a completed, +findings-already-resolved stage to the captain for a rubber stamp. + +**Auto-advance.** When a gate has zero Material findings, every AC carries +evidence, and the decision is reversible, EM approves and the FO advances +immediately. The captain is *notified in one line*, not asked. A captain who +wants it back says so; silence is not a gate. + +**Escalate to the captain only when one of these holds — and name which:** + +- The call is irreversible per Judgment Escalation below. +- Scope is being authored or re-cut. Only the captain holds scope. +- A Material finding survives EM review and changes what ships. +- A gate is red and the ask is to accept the residual on record. +- EM and FO disagree — that goes to the captain, never to a vote. +- Two consecutive rejected cycles closed at the same gate — see the validation + stage's rejection clause. Unlike the bullets above it, this one fires on + cycle count alone, whatever the findings were. + +Anything else reaching the captain is over-escalation, and it costs more than +it protects: a captain pulled into six ceremonies per task stops reading the +two that mattered. + +**Approval is scoped to the decision presented.** "The captain approved the +previous gate" is never authority for a later one. + +**Speak consequence, not vocabulary.** A gate presented in the system's own +terms — a migration, a claim path, a corpus freeze — is not a decision the +captain can weigh; it is a request to trust the presenter. The tell is a +captain who answers "go with your recommendation" every time: at that point the +gate costs attention and returns nothing, and the seat has quietly moved back +to the FO without anyone deciding that it should. + +Every escalation carries a plain restatement — literally "換句話說" — before it +asks for anything: + +- **What breaks if this is wrong**, in terms of what a user or the team can no + longer do. Not the mechanism; the consequence. +- **How expensive it is to reverse.** "Ships to production" and "one commit to + revert" are different decisions and must not read the same. +- **What is actually being chosen.** Often it is narrower than the technical + framing suggests — "restore something that was dropped by accident" is not + "change how the system behaves", and the captain rules differently on each. + +If the restatement cannot be written, the escalation is not ready: either the +FO does not yet understand the consequence, or there is no decision here and it +belongs to EM. + +## Judgment Escalation + +Irreversible calls — schema, architecture, scope-cut, costly_no, anything +merge-governing — are never self-adjudicated by the working agent. +**Merge-governing means a change to the merge rules themselves** — branch +protection, a required check, the merge policy — **not a gate verdict that +lets this one merge proceed.** A passed validation gate is the second kind, so +it stays inside the auto-advance rule above; reading it as the first kind +would make auto-advance dead for the only stage it matters at. Route to a +fresh-context engineering-judgment agent (`ship-flow:science-officer-em`) for +independent synthesis, add one cross-vendor pass (codex/gemini) when the call +is contested, and bring the captain a CONVERGED recommendation. The captain +rules; disagreement between seats goes to the captain, not to a vote. + +## Canonical Docs Ownership + +| File | Owner | Updated | +|------|-------|---------| +| `README.md` | Product front door, installation, skill inventory | When user-facing setup or capability changes | +| `AGENTS.md` | Repository contract and canonical pre-PR suite | When contributor or gate policy changes | +| `CONTRIBUTING.md` | Contributor journey and server design constraints | When development practice changes | +| `COMPATIBILITY.md` | Cross-harness/platform contract and Python floor | When compatibility changes | +| `SECURITY.md` | Security invariants and accepted exposure | When trust boundaries change | +| `cargento/skills/cargento/SKILL.md` | Shipped product surface | In the PR that changes agent-facing behavior | +| This README | Captain-approved revision | When ledger data says a clause needs tuning | + +## Measurement Ledger + +Every task that reaches `done` (or is abandoned after implementation started) +appends one row to `docs/dev/ledger.csv`: + +``` +task_id,slug,dispatches,rework_rounds,wallclock_hours,tokens_if_known,coverage,escaped_defects_7d +``` + +Record measurements at their natural boundary instead of reconstructing them: +the FO increments `dispatches` before handing control to a worker and appends +token usage when the harness exposes it. A worker that returns no usage records +`n/a`; it is not silently converted into a measured zero. + +`escaped_defects_7d` starts as `pending` and is back-filled after the seven-day +window. The first ten complete rows form a prospective baseline for this +workflow. Until that cohort exists, the ledger supports observation only, not a +claim that this flow is cheaper or more effective than another workflow. + +## Task Template + +```yaml +--- +id: +title: +status: backlog +source: +started: +completed: +verdict: +score: +worktree: +issue: +pr: +design: +lane: +--- + +## Problem + +## Proposed approach + +## Design determination + +`required` (attach decision) or `trivial-pass — `. + +## Acceptance criteria + +**AC-1 — .** +Verified by: . Falsified by: . + +## Test plan + +## Doc diff + + + +## Out of scope +``` + +## Commit Discipline + +- Status changes commit at dispatch and merge boundaries (binary-owned). +- State commits are path-scoped per entity in the state checkout — never bare `git add -A`. +- Implementation commits land on the worktree branch; merge only after the validation gate passes. diff --git a/docs/dev/_mods/pr-merge.md b/docs/dev/_mods/pr-merge.md new file mode 100644 index 0000000..14f5e19 --- /dev/null +++ b/docs/dev/_mods/pr-merge.md @@ -0,0 +1,112 @@ +--- +name: pr-merge +description: Push branches and create/track GitHub PRs for workflow entities +version: 0.12.2 +--- + +# PR Merge + +Manages the PR lifecycle for workflow entities processed in worktree stages. Pushes branches, creates PRs, detects merged PRs, and advances entities accordingly. + +## Hook: startup + +Scan all entity files (in the workflow directory only, not `_archive/`) for entities with a non-empty `pr` field and a non-terminal status. For each, extract the PR number (strip any `#`, `owner/repo#` prefix) and check: `gh pr view {number} --json state --jq '.state'`. + +If `MERGED`, advance the entity to its terminal stage. Because a `mod-block` may be set while the PR is pending, the clear and the terminalization are two separate `--set` calls (the mechanism refuses combining `mod-block=` with terminal fields): +1. `spacedock status --workflow-dir {dir} --set {slug} mod-block=` when a `mod-block` is set (skip when empty); +2. `spacedock status --workflow-dir {dir} --set {slug} status={terminal} completed verdict=PASSED worktree=`, then `spacedock status --workflow-dir {dir} --archive {slug}`. + +Clean up any worktree/branch. Report each auto-advanced entity to the captain. + +If `CLOSED` (closed without merge), report to the captain: "{entity title} has PR {pr number} which was closed without merging. How to proceed? Options: reopen the PR, create a new PR from the same branch, or clear `pr` and fall back to local merge." Wait for the captain's direction before taking action. + +If `OPEN`, no action needed — the PR is still in review. + +If `gh` is not available, warn the captain and skip PR state checks. + +## Hook: idle + +Check PR-pending entities using the same logic as the startup hook: scan entity files for non-empty `pr` and non-terminal status, run `gh pr view` for each, and advance merged PRs (two-step `mod-block=` clear then terminalize). This is the workflow's PR-pending scan: the generic event loop fires this idle hook and owns no PR scan of its own, so a workflow with no `pr-merge` mod never reaches for `gh` in its loop. Report any advanced entities to the captain. + +## Hook: merge + +Resolve the PR base once: `BASE=$(spacedock dispatch trunk --workflow-dir {dir})` — the workflow's configured integration trunk (default `main` when no `trunk:` key is set). `dispatch trunk` emits exactly a **bare branch name** (e.g. `main`), so `$( )` yields `$BASE` clean (command substitution strips the single trailing newline). Always quote `"$BASE"` at use sites — the push, the rebase, the draft, and the `gh pr create --base` below. + +**PR APPROVAL GUARDRAIL — Do NOT push or create a PR without explicit captain approval.** Before presenting the draft, construct the full PR body so the captain reviews the actual prose that will land on GitHub. + +Compute the product short SHA first with `git rev-parse --short HEAD` in the worktree directory. If it exits non-zero, substitute the literal string `main` and report the fallback to the captain. + +Then resolve the audit receipt from the entity file's own Git repository, not from the product worktree. A link is valid only when all of these checks succeed: `git -C {entity directory} rev-parse --show-toplevel`, `git -C {state root} ls-files --error-unmatch {entity-relative-path}`, `git -C {state root} remote get-url origin`, parsing that origin as a GitHub `owner/repo`, and `gh repo view {owner/repo}`. Accept only `https://github.com/{owner}/{repo}[.git]` and `git@github.com:{owner}/{repo}[.git]` origin forms; every other form takes the plaintext fallback. For a valid surface, use the state repository's `git rev-parse --short HEAD`, the shortest entity id from `spacedock status --short-id {entity ref}`, and the entity-relative path to emit the existing link: + +Concatenate `[{short-id}]` and +`(/{owner}/{repo}/blob/{state-short-sha}/{entity-relative-path})` with no +separator. + +If any check fails, including a split-root state checkout with no `origin`, emit this plaintext receipt instead: + +`Audit: local-only workflow receipt; product head {product-short-sha}.` + +Do not invent a remote, point the link at the product repository, copy state into the product branch, or publish state automatically. + +Build the full PR body using the template below: motivation lead, `## What changed`, `## Evidence`, `---` separator, the resolved audit receipt, and a `Closes {issue}` line if frontmatter `issue` is set. This is the body that will be passed to `gh pr create` verbatim; do not reconstruct it after approval. + +Then present the draft to the captain: + +- **Title:** {entity title} +- **Branch:** {branch} -> $BASE +- **Changes:** {N} file(s) changed across {N} commit(s) +- **Files:** {list of changed files} +- **Body:** + + ``` + {constructed body} + ``` + +Wait for the captain's explicit approval before pushing. Do NOT infer approval from silence, acknowledgment of the summary, or the gate approval that preceded this step — only an explicit "push it", "go ahead", "yes", or equivalent counts. + +**On approval:** First, push the trunk so the integration branch is current: `git push origin "$BASE"`. This never publishes split-root state. Then rebase the worktree branch onto the trunk: `git rebase "$BASE"` (from the worktree directory). Then push the worktree branch: `git push origin {branch}`. If any step fails (no remote, auth error, rebase conflict), report to the captain and fall back to local merge. + +Then create the PR by running `gh pr create --base "$BASE" --head {branch} --title "{entity title}" --body "{constructed body}"` against the body already constructed above — do not rebuild it. If `gh` is not available, warn the captain and fall back to local merge. + +### PR body template + +Lead with motivation + end-user value; audit metadata goes at the bottom. The goal is that a reviewer or future debugger sees the "why" first and the audit receipt last. + +**Template structure (top to bottom):** + +| Section | Required | Content | +|---|---|---| +| Motivation lead | **yes** | 1 sentence, ≤ 25 words, blending motivation and end-user value. No parentheticals. | +| `## What changed` | **yes** | Action-verb bullets, 3–5 total, each ≤ 15 words. One change per bullet. No rationale inside the bullet — if a change needs justification, it belongs in the task body, not the PR. | +| `## Evidence` | **yes when validation ran** | Test suites with `N/N passed` format, 1–2 bullets. Do not include per-test-class breakdowns or enumerated suite lists — one pass ratio per suite, plus at most one line confirming live-probe verification. | +| `## Review guidance` | optional | 1 line pointing reviewer at the critical file or risky change — include only when a stage report explicitly flagged it | +| `---` separator + audit receipt | **yes** | Use the entity-state link only for a valid remote/repo surface; otherwise use `Audit: local-only workflow receipt; product head {product-short-sha}.` | +| `Closes {issue}` | **yes when issue set** | Under the audit receipt, using the value exactly as it appears in frontmatter, e.g., `#48` or `owner/repo#48` | +| `Related: {siblings}` | optional | Under Closes, only when stage reports flagged follow-ups | + +**Extraction rules (apply deterministically from the entity file):** + +| PR body section | Source in entity file | Transformation | +|---|---|---| +| Motivation lead | Entity body paragraph(s) between closing `---` and the first `##` heading | Condense first paragraph to 1-2 sentences. Lead with impact or action verb — not "This PR" or "This task". Blend motivation + value. | +| What changed | Implementation stage report's `[x]` DONE items | One action-verb bullet per meaningful unit. Collapse sibling bullets that describe the same thing. Drop `[x]` markers. Do NOT include "what we deliberately did NOT change" bullets — scope boundaries belong in the task body, not the PR, unless a validation stage report flagged them as risk. | +| Evidence | Validation stage report items that assert AC verification (typically rerun-test items) | One bullet per suite with `N/N passed` format. Include any quantitative result the stage report explicitly called out (wallclock delta, size %, perf). Fallback to implementation report's self-test items if no validation stage exists. | +| Review guidance | Explicit "focus on X" / "risk here" notes in either stage report | 1 line. **Omit if no such note exists.** | +| Audit receipt | Entity file's Git root, tracked relative path, `origin`, and GitHub repository lookup; product short SHA from the worktree | When all entity-state remote checks succeed, concatenate `[{short-id}]` and `(/{owner}/{repo}/blob/{state-short-sha}/{entity-relative-path})` with no separator; otherwise emit `Audit: local-only workflow receipt; product head {product-short-sha}.` | +| Closes | Entity frontmatter `issue` field (exactly as written) | Prefix `Closes ` | +| Related | Explicit "related task" / "follow-up" mentions in stage reports | 1 line. **Omit if none.** | + +Target total length: **60-120 words**. + +**Key design decisions:** + +1. **Lead with motivation + end-user value.** First content is a 1-2 sentence user-facing impact statement. The audit receipt moves to the bottom as audit metadata. +2. **Prescribed sections + extraction rules** — not a strict verbatim template, not free-form. The mod specifies headings and source subsections; the FO paraphrases rather than pasting. +3. **Evidence section is conditional on validation stage.** Non-validated workflows fall back to implementation self-test evidence. +4. **Review guidance and Related are opt-in.** They appear only when stage reports explicitly flagged them, to prevent bloat. + +Set the entity's `pr` field to the PR number (e.g., `#57`). Report the PR to the captain. + +**On decline:** Do NOT automatically fall back to local merge. Ask the captain how to proceed — options include local merge or leaving the branch unmerged. Only act on the captain's explicit choice. + +Do NOT archive yet. The entity stays at its current stage with `pr` set until the PR is merged. The FO handles advancement to the terminal stage and archival when it detects the merge (via this idle hook, the startup hook, or the reconcile sweep's un-advanced-pr class). diff --git a/docs/dev/_mods/reverse-recovery-audit.md b/docs/dev/_mods/reverse-recovery-audit.md new file mode 100644 index 0000000..c973dca --- /dev/null +++ b/docs/dev/_mods/reverse-recovery-audit.md @@ -0,0 +1,69 @@ +--- +name: reverse-recovery-audit +description: "Brownfield shape/plan mindset: assume the abstraction already exists, classify it with evidence (5-tier), and only greenfield what is confirmed MISSING" +version: 0.1.0 +--- + +# Reverse-Recovery Audit — Assume It Exists, Prove What's Missing + +> Adapted from the qnow development workflow for Cargento. Keep Cargento +> examples here rather than changing the generic rule below. + +## Why This Exists + +In a brownfield codebase, the default planning instinct — "the feature doesn't +work, so plan to build it" — is systematically wrong and expensive. It +produces duplicate implementations beside broken-but-present ones, misses +one-line wiring fixes disguised as features, and inflates a one-seam repair +into a broad rebuild. Cargento is a compact codebase with shared behavior +across several harnesses, so recovering the existing seam is normally cheaper +and safer than introducing a parallel launcher, parser, or install path. + +## The Rule + +**Before planning ANY capability as new work, run the reverse-recovery audit: +assume the abstraction already exists, hunt for it, classify it with +evidence, and only greenfield what is confirmed MISSING.** + +### 5-tier classification (evidence ladder) + +| Tier | Meaning | Minimum evidence | +|------|---------|------------------| +| `WORKING` | works end-to-end | behavioral E2E (API-level or browser) or a runtime walk — **unit tests alone never qualify** | +| `WORKING_UNIT_UNPROVEN` | logic tested, wiring unproven | unit tests pass, no seam proof | +| `EXISTS_BROKEN` | implemented but fails | concrete defect evidence: broken wiring, contract mismatch, swallowed error/rejection path, failing runtime probe | +| `STUB` | abstraction only | type/contract/route/page skeleton with placeholder logic | +| `MISSING` | no abstraction | exhaustive search came up empty (see below) | + +### Discipline + +1. **Layer-trace before classifying**: UI entry → API contract → handler → + domain logic → persistence/projection → UI readback. Record file:line per + layer or the literal `MISSING`. One broken layer ≠ MISSING — it is + EXISTS_BROKEN at that seam, and the fix is scoped to that seam. +2. **MISSING requires proof of absence, not absence of proof.** Search domain + nouns in every language the codebase uses, across contracts, routes, + domain types, and UI surfaces, with at least two search strategies before + writing MISSING. "Not found after one grep" is the easiest false claim. +3. **Every non-runtime classification carries a `disproof_hook`** — the one + command or observation that would flip it. The audit stays + self-correcting instead of authoritative. +4. **Unit tests prove logic, never wiring.** Silent-failure architectures + (event-sourced rejection-as-event, schema-boundary stripping, CQRS + projection lag) fail BETWEEN tested units; seam claims need runtime or + E2E evidence. +5. **Boundary conditions.** Greenfield domains take no search tax — the rule + is "prove MISSING before building", not "never build". And + cheapest-literal recovery is a scope tool, not an architecture tool: when + a recovered abstraction fights the domain model, escalate to a redesign + decision instead of contorting the old shape. + +### Where it binds + +- **shape stage**: frame the entity around recovered capability + named gaps, + citing existing abstractions by file:line, not around "build X". +- **plan stage**: every task that creates a new file/domain/route MUST carry + a classification line justifying why recovery was impossible (MISSING with + search evidence). Plan reviewers reject greenfield tasks without it. +- **any "build/add/implement X" request**: run the audit for the touched + capability before writing the plan. diff --git a/docs/dev/ledger.csv b/docs/dev/ledger.csv new file mode 100644 index 0000000..a1585d8 --- /dev/null +++ b/docs/dev/ledger.csv @@ -0,0 +1 @@ +task_id,slug,dispatches,rework_rounds,wallclock_hours,tokens_if_known,coverage,escaped_defects_7d From fd062fe3327357b09923d5ef13de39854820074b Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 21:14:48 +0800 Subject: [PATCH 6/7] docs(dev): require remote audit proof Signed-off-by: Kent --- docs/dev/_mods/pr-merge.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/dev/_mods/pr-merge.md b/docs/dev/_mods/pr-merge.md index 14f5e19..5613642 100644 --- a/docs/dev/_mods/pr-merge.md +++ b/docs/dev/_mods/pr-merge.md @@ -36,13 +36,28 @@ Resolve the PR base once: `BASE=$(spacedock dispatch trunk --workflow-dir {dir}) Compute the product short SHA first with `git rev-parse --short HEAD` in the worktree directory. If it exits non-zero, substitute the literal string `main` and report the fallback to the captain. -Then resolve the audit receipt from the entity file's own Git repository, not from the product worktree. A link is valid only when all of these checks succeed: `git -C {entity directory} rev-parse --show-toplevel`, `git -C {state root} ls-files --error-unmatch {entity-relative-path}`, `git -C {state root} remote get-url origin`, parsing that origin as a GitHub `owner/repo`, and `gh repo view {owner/repo}`. Accept only `https://github.com/{owner}/{repo}[.git]` and `git@github.com:{owner}/{repo}[.git]` origin forms; every other form takes the plaintext fallback. For a valid surface, use the state repository's `git rev-parse --short HEAD`, the shortest entity id from `spacedock status --short-id {entity ref}`, and the entity-relative path to emit the existing link: +Resolve the audit receipt from the entity file's own Git repository, never by assumption: + +1. From the product worktree, resolve `PRODUCT_REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')`. +2. Resolve the entity's state root with `git -C {entity directory} rev-parse --show-toplevel`; require `git -C {state root} ls-files --error-unmatch {entity-relative-path}`. +3. Read `git -C {state root} remote get-url origin`. Accept only `https://github.com/{owner}/{repo}[.git]` and `git@github.com:{owner}/{repo}[.git]`, strip the optional `.git`, and call the result `STATE_REPO`. +4. Require `STATE_REPO` to differ from `PRODUCT_REPO`. Equality is the product-repository prohibition and always takes the plaintext fallback. +5. Resolve the full state commit with `STATE_SHA=$(git -C {state root} rev-parse HEAD)`. +6. Prove that exact commit is remote with `gh api "repos/$STATE_REPO/commits/$STATE_SHA" --silent`. +7. Prove that exact entity path exists at that commit with `gh api --method GET "repos/$STATE_REPO/contents/{entity-relative-path}" -f "ref=$STATE_SHA" --silent`. + +Only after every command above succeeds may the receipt link. Use the full +`STATE_SHA`, the shortest entity id from +`spacedock status --short-id {entity ref}`, and the entity-relative path: Concatenate `[{short-id}]` and -`(/{owner}/{repo}/blob/{state-short-sha}/{entity-relative-path})` with no +`(/{state-owner}/{state-repo}/blob/{state-full-sha}/{entity-relative-path})` with no separator. -If any check fails, including a split-root state checkout with no `origin`, emit this plaintext receipt instead: +Any failure takes the plaintext fallback: missing `gh`, auth, or network; +unparseable or absent origin; same product and state repository; untracked +entity; missing remote commit; or missing path at that exact commit. A +split-root state checkout with no `origin` therefore always falls back: `Audit: local-only workflow receipt; product head {product-short-sha}.` @@ -80,7 +95,7 @@ Lead with motivation + end-user value; audit metadata goes at the bottom. The go | `## What changed` | **yes** | Action-verb bullets, 3–5 total, each ≤ 15 words. One change per bullet. No rationale inside the bullet — if a change needs justification, it belongs in the task body, not the PR. | | `## Evidence` | **yes when validation ran** | Test suites with `N/N passed` format, 1–2 bullets. Do not include per-test-class breakdowns or enumerated suite lists — one pass ratio per suite, plus at most one line confirming live-probe verification. | | `## Review guidance` | optional | 1 line pointing reviewer at the critical file or risky change — include only when a stage report explicitly flagged it | -| `---` separator + audit receipt | **yes** | Use the entity-state link only for a valid remote/repo surface; otherwise use `Audit: local-only workflow receipt; product head {product-short-sha}.` | +| `---` separator + audit receipt | **yes** | Link only for a distinct state repository whose exact full commit and entity path are proven through GitHub; every failed check uses `Audit: local-only workflow receipt; product head {product-short-sha}.` | | `Closes {issue}` | **yes when issue set** | Under the audit receipt, using the value exactly as it appears in frontmatter, e.g., `#48` or `owner/repo#48` | | `Related: {siblings}` | optional | Under Closes, only when stage reports flagged follow-ups | @@ -92,7 +107,7 @@ Lead with motivation + end-user value; audit metadata goes at the bottom. The go | What changed | Implementation stage report's `[x]` DONE items | One action-verb bullet per meaningful unit. Collapse sibling bullets that describe the same thing. Drop `[x]` markers. Do NOT include "what we deliberately did NOT change" bullets — scope boundaries belong in the task body, not the PR, unless a validation stage report flagged them as risk. | | Evidence | Validation stage report items that assert AC verification (typically rerun-test items) | One bullet per suite with `N/N passed` format. Include any quantitative result the stage report explicitly called out (wallclock delta, size %, perf). Fallback to implementation report's self-test items if no validation stage exists. | | Review guidance | Explicit "focus on X" / "risk here" notes in either stage report | 1 line. **Omit if no such note exists.** | -| Audit receipt | Entity file's Git root, tracked relative path, `origin`, and GitHub repository lookup; product short SHA from the worktree | When all entity-state remote checks succeed, concatenate `[{short-id}]` and `(/{owner}/{repo}/blob/{state-short-sha}/{entity-relative-path})` with no separator; otherwise emit `Audit: local-only workflow receipt; product head {product-short-sha}.` | +| Audit receipt | Product `owner/repo`; entity file's Git root, tracked relative path, parsed state `owner/repo`, full state HEAD SHA, remote commit proof, and exact path-at-ref proof | Link only when product and state repositories differ and both GitHub API checks succeed. Concatenate `[{short-id}]` and `(/{state-owner}/{state-repo}/blob/{state-full-sha}/{entity-relative-path})` with no separator; every failure emits `Audit: local-only workflow receipt; product head {product-short-sha}.` | | Closes | Entity frontmatter `issue` field (exactly as written) | Prefix `Closes ` | | Related | Explicit "related task" / "follow-up" mentions in stage reports | 1 line. **Omit if none.** | From 3885a66104c56370648130b0c20061ee229020ab Mon Sep 17 00:00:00 2001 From: Kent Date: Tue, 28 Jul 2026 21:41:04 +0800 Subject: [PATCH 7/7] fix(workflow): repair measurement ledger lifecycle Signed-off-by: Kent --- docs/dev/README.md | 60 +++++++++++++++++++++++++++++++------- docs/dev/_mods/pr-merge.md | 25 ++++++++++++++-- docs/dev/ledger.csv | 1 + 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/dev/README.md b/docs/dev/README.md index 91dbca5..6b32eec 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -84,7 +84,7 @@ path. | `title` | string | Human-readable task name | | `status` | enum | backlog, ideation, implementation, validation, done | | `source` | string | Where the task came from (captain note, issue, defect, audit) | -| `started` / `completed` | ISO 8601 | `started` at the first transition out of `backlog`, `completed` at the `done` transition — `wallclock_hours` is their difference, so a task that sits in the queue for a week does not bill that week | +| `started` / `completed` | ISO 8601 | `started` at the first transition out of `backlog`; `completed` at the `done` transition. Ledger `wallclock_hours` is measured separately, from `started` to the accepted validation evidence boundary. | | `verdict` | enum | PASSED or REJECTED — set at final stage | | `score` | number | Optional priority score from 0.0 to 1.0 | | `worktree` | string | Set on first worktree dispatch, cleared at terminal merge | @@ -496,10 +496,21 @@ same bar the ideation stage's design determination is held to. review rounds have compounded on it. A round spent reviewing machinery nobody wants is paid twice: once to find its defects, once to fix them. +An accepted validation boundary upserts the task's unique Measurement Ledger +row before any new or updated PR is pushed. Re-entry after rework updates that +same `task_id` row; it never adds a duplicate. The accepted evidence boundary, +not a later merge, supplies the coverage and wall-clock endpoint. The +pre-publication exact-one-row check below is part of the validation gate. + ### `done` — terminal -Merge after a passed validation gate (merge policy: PR to `main`), set `completed` and `verdict`, archive the task. Record the -measurement ledger row (below) in the same transition. +Merge after a passed validation gate (merge policy: PR to `main`). Before +setting `completed` and `verdict` or archiving the task, run the exact-one-row +ledger check below for its `task_id`. A missing, duplicate, or incomplete row +is a measurement defect: report it and block terminalization and archive until +a normal product-branch PR repairs the tracked ledger. Never reconstruct or +edit metrics during merge, and never write them directly to protected `main`. +Once exactly one complete row is present, terminalize and archive the task. - **Merge only on observed green CI for the exact HEAD.** A passing local suite, a static PR approval, or "CI was green earlier" never substitutes @@ -614,19 +625,42 @@ rules; disagreement between seats goes to the captain, not to a vote. ## Measurement Ledger -Every task that reaches `done` (or is abandoned after implementation started) -appends one row to `docs/dev/ledger.csv`: +Every accepted validation boundary upserts one row in the tracked +`docs/dev/ledger.csv`, uniquely keyed by `task_id`: ``` task_id,slug,dispatches,rework_rounds,wallclock_hours,tokens_if_known,coverage,escaped_defects_7d ``` -Record measurements at their natural boundary instead of reconstructing them: -the FO increments `dispatches` before handing control to a worker and appends -token usage when the harness exposes it. A worker that returns no usage records -`n/a`; it is not silently converted into a measured zero. +The upsert is a product change on the task branch and must be committed before +that branch is pushed for a new or updated PR. Rework that returns to +validation updates the existing row; it never appends another row for the same +`task_id`. Protected `main` changes only through the normal product PR. + +Record measurements at their natural boundary instead of reconstructing them. +The FO keeps live dispatch and token evidence in the entity's +`## Measurement` section: increment `dispatches` before every worker launch, +and append exposed token usage when the worker returns. A task adopted after +those boundaries may record an explicit `n/a`; unknown values are never +written as zero. `rework_rounds` is the number of validation route-backs, +`coverage` comes from the accepted validation evidence, and +`wallclock_hours` runs from `started` to that accepted evidence boundary. +Merge wait is not included. + +Before any PR push, creation, or update after accepted validation, and again +before terminalization and archive, run this deterministic check from the +product checkout with `TASK_ID` set to the entity task ID: + +```bash +python3 -c 'import csv,sys; rows=list(csv.DictReader(open("docs/dev/ledger.csv", encoding="utf-8", newline=""))); matches=[row for row in rows if row["task_id"] == sys.argv[1]]; raise SystemExit(0 if len(matches) == 1 and all(matches[0].values()) else 1)' "$TASK_ID" +``` -`escaped_defects_7d` starts as `pending` and is back-filled after the seven-day +Failure means the required row is missing, duplicated, or incomplete. Stop the +PR or archive transition, report the measurement defect, and repair the same +row before retrying. The `done`/MERGED boundary only verifies this evidence; +it does not recreate it. + +`escaped_defects_7d` starts as `pending` and is backfilled after the seven-day window. The first ten complete rows form a prospective baseline for this workflow. Until that cohort exists, the ledger supports observation only, not a claim that this flow is cheaper or more effective than another workflow. @@ -669,6 +703,12 @@ Verified by: . Falsified by: +## Measurement + +dispatches: +tokens_if_known: +accepted_validation_at: + ## Out of scope ``` diff --git a/docs/dev/_mods/pr-merge.md b/docs/dev/_mods/pr-merge.md index 5613642..a110335 100644 --- a/docs/dev/_mods/pr-merge.md +++ b/docs/dev/_mods/pr-merge.md @@ -12,7 +12,17 @@ Manages the PR lifecycle for workflow entities processed in worktree stages. Pus Scan all entity files (in the workflow directory only, not `_archive/`) for entities with a non-empty `pr` field and a non-terminal status. For each, extract the PR number (strip any `#`, `owner/repo#` prefix) and check: `gh pr view {number} --json state --jq '.state'`. -If `MERGED`, advance the entity to its terminal stage. Because a `mod-block` may be set while the PR is pending, the clear and the terminalization are two separate `--set` calls (the mechanism refuses combining `mod-block=` with terminal fields): +If `MERGED`, run the exact-one-row check in the workflow's Measurement Ledger +against the product checkout's tracked `docs/dev/ledger.csv`, using the entity +`task_id`. A missing, duplicate, or incomplete row is a measurement defect: +report it and block terminalization and archive. Do not reconstruct metrics at +merge. Repair the ledger through a normal product-branch PR, never by writing +directly to protected `main`. + +When exactly one complete row is present, advance the entity to its terminal +stage. Because a `mod-block` may be set while the PR is pending, the clear and +the terminalization are two separate `--set` calls (the mechanism refuses +combining `mod-block=` with terminal fields): 1. `spacedock status --workflow-dir {dir} --set {slug} mod-block=` when a `mod-block` is set (skip when empty); 2. `spacedock status --workflow-dir {dir} --set {slug} status={terminal} completed verdict=PASSED worktree=`, then `spacedock status --workflow-dir {dir} --archive {slug}`. @@ -34,6 +44,13 @@ Resolve the PR base once: `BASE=$(spacedock dispatch trunk --workflow-dir {dir}) **PR APPROVAL GUARDRAIL — Do NOT push or create a PR without explicit captain approval.** Before presenting the draft, construct the full PR body so the captain reviews the actual prose that will land on GitHub. +Before drafting or presenting the PR, upsert the entity's unique `task_id` row +in the product checkout's tracked `docs/dev/ledger.csv` from the accepted +validation evidence, commit it on the product branch, and run the exact-one-row +check in the workflow's Measurement Ledger. A validation retry updates the +same row. If the check fails, stop before any PR push, creation, or update and +report the measurement defect. + Compute the product short SHA first with `git rev-parse --short HEAD` in the worktree directory. If it exits non-zero, substitute the literal string `main` and report the fallback to the captain. Resolve the audit receipt from the entity file's own Git repository, never by assumption: @@ -124,4 +141,8 @@ Set the entity's `pr` field to the PR number (e.g., `#57`). Report the PR to the **On decline:** Do NOT automatically fall back to local merge. Ask the captain how to proceed — options include local merge or leaving the branch unmerged. Only act on the captain's explicit choice. -Do NOT archive yet. The entity stays at its current stage with `pr` set until the PR is merged. The FO handles advancement to the terminal stage and archival when it detects the merge (via this idle hook, the startup hook, or the reconcile sweep's un-advanced-pr class). +Do NOT archive yet. The entity stays at its current stage with `pr` set until +the PR is merged. The FO handles advancement to the terminal stage and +archival when it detects the merge and the exact-one-row ledger check passes +(via this idle hook, the startup hook, or the reconcile sweep's +un-advanced-pr class). diff --git a/docs/dev/ledger.csv b/docs/dev/ledger.csv index a1585d8..f1add52 100644 --- a/docs/dev/ledger.csv +++ b/docs/dev/ledger.csv @@ -1 +1,2 @@ task_id,slug,dispatches,rework_rounds,wallclock_hours,tokens_if_known,coverage,escaped_defects_7d +whc6e089t7p9heztsfcrzbp6,one-command-cargento-installer,n/a,4,4.41,n/a,83.9,pending