Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
run: |
cd site
npm ci
node scripts/check-archives.mjs
npm run build
node scripts/check-no-html.mjs
node scripts/check-pages.mjs
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Twelve skills for Hermes Agent. Frame the job, shape the change, prove it, and o
curl -fsSL https://hstack.sh/install | sh
```

The curl form downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone this repository. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. With no version set, the installer fetches the latest alias, `hstack.tar.gz`, which is 0.2.0. `HSTACK_VERSION=0.1.0` still fetches `hstack-v0.1.0.tar.gz`. The archive is one directory containing the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers, so `--with` works from the curl form too.
The curl form downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone this repository. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. With no version set, the installer fetches the latest alias, `hstack.tar.gz`, which is 0.2.1. `HSTACK_VERSION=0.2.0` still fetches the frozen `hstack-v0.2.0.tar.gz`. `HSTACK_VERSION=0.1.0` still fetches `hstack-v0.1.0.tar.gz`. The archive is one directory containing the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers, so `--with` works from the curl form too.

From a checkout of this repo, `sh install.sh` installs the skills beside the script. A second run replaces the HStack skills and leaves every other skill alone. Pass `--dry-run` to print the plan and write nothing.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.2.1
2 changes: 1 addition & 1 deletion docs/v0.2-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Each installed hook is a few lines that call `hooks/hstack_hook.py`. The skills

Hermes still asks the first time it sees each `(event, command)` pair and stores the answer in `~/.hermes/shell-hooks-allowlist.json`. The installer does not write that file. A gateway, cron job, or other non-interactive surface will not run a new hook until that prompt has been answered, or until the user passes `--accept-hooks` / `HERMES_ACCEPT_HOOKS=1` themselves. Editing the script later does not clear the allowlist. Hermes keys it on the command string (`hermes hooks doctor` reports mtime drift).

`--with hermes-hooks` installs all three. A subset is `hermes-hooks-guard`, `hermes-hooks-save`, or `hermes-hooks-suggest`. The installer copies the shims to `$HERMES_HOME/hstack/hooks/`, edits `config.yaml`, and copies `apply_hooks.py` beside the addon script so a later `curl | sh -s -- --uninstall` can remove them. It backs up `config.yaml` to `$HERMES_HOME/hstack/backups/` before a change. A second run that would write the same block does not write and does not take another backup. `--uninstall` removes only the entries marked `# hstack:<id>` and the files this installer wrote. Another product's hook stays.
`--with hermes-hooks` installs all three. A subset is `hermes-hooks-guard`, `hermes-hooks-save`, or `hermes-hooks-suggest`. The installer copies the shims to `$HERMES_HOME/hstack/hooks/`, edits `config.yaml`, and copies `apply_hooks.py` beside the addon script so a later `curl | sh -s -- --uninstall` can remove them. It backs up `config.yaml` to `$HERMES_HOME/hstack-backups/` before a change. Uninstall removes `$HERMES_HOME/hstack/` and leaves that backup directory in place. A second run that would write the same block does not write and does not take another backup. `--uninstall` removes only the entries marked `# hstack:<id>` and the files this installer wrote. Another product's hook stays.

### `guard` — `pre_tool_call` on `terminal`

Expand Down
2 changes: 1 addition & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ if ($links.Count -gt 0) {
Die 'refusing to copy a link or special file from the skill tree'
}

$ver = '0.2.0'
$ver = '0.2.1'
$versionFile = if ($AddonRoot) { Join-Path $AddonRoot 'VERSION' } else { $null }
if ($versionFile -and (Test-Path -LiteralPath $versionFile)) {
$digits = ([System.IO.File]::ReadAllText($versionFile) -replace '[^0-9.]', '')
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ if find "$SRC" \( -type l -o -type b -o -type c -o -type p -o -type s \) -print
die "refusing to copy a link or special file from the skill tree"
fi

ver=0.2.0
ver=0.2.1
if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/VERSION" ]; then
got=$(tr -cd '0-9.' < "$ADDON_ROOT/VERSION")
if [ -n "$got" ]; then
Expand Down
11 changes: 5 additions & 6 deletions scripts/apply_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,13 @@ def copy_self(dest_root: Path, dry_run: bool) -> None:
target.chmod(0o644)


def backup_dir(root: Path) -> Path:
return root / "hstack-backups"


def backup_config(config: Path, root: Path) -> None:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
dest = root / "hstack" / "backups" / f"config.yaml.{stamp}"
dest = backup_dir(root) / f"config.yaml.{stamp}"
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(config, dest)
say(f"backed up config.yaml to {dest}")
Expand Down Expand Up @@ -437,11 +441,6 @@ def apply(root: Path, source: Path | None, hooks: list[str], remove: bool, dry_r
self_copy = dest_root / "apply_hooks.py"
if self_copy.is_file() and not self_copy.is_symlink():
self_copy.unlink()
backups = dest_root / "backups"
if backups.is_dir():
for child in backups.iterdir():
if child.is_file() and child.name.startswith("config.yaml."):
child.unlink()
prune_root(dest_root)
return
if not marker.exists():
Expand Down
23 changes: 19 additions & 4 deletions scripts/check_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ def live(root: Path, command_home: str, hooks: list[str]) -> None:
fail(f"dry-run wrote config for {command_home}")
apply_hooks.apply(root, source, hooks, remove=False, dry_run=False, command_home=command_home)
config = (root / "config.yaml").read_text(encoding="utf-8")
backups = list((root / "hstack" / "backups").glob("config.yaml.*")) if (root / "hstack" / "backups").exists() else []
backups = list((root / "hstack-backups").glob("config.yaml.*")) if (root / "hstack-backups").exists() else []
# First write creates the file, so there is no previous config to back up.
apply_hooks.apply(root, source, hooks, remove=False, dry_run=False, command_home=command_home)
backups_after = list((root / "hstack" / "backups").glob("config.yaml.*")) if (root / "hstack" / "backups").exists() else []
backups_after = list((root / "hstack-backups").glob("config.yaml.*")) if (root / "hstack-backups").exists() else []
if len(backups_after) != len(backups):
fail(f"idempotent install took another backup for {command_home}")
again = (root / "config.yaml").read_text(encoding="utf-8")
Expand All @@ -93,20 +93,25 @@ def live(root: Path, command_home: str, hooks: list[str]) -> None:
fail(f"uninstall left config for {command_home}")
if (root / "hstack" / "hooks").exists():
fail(f"uninstall left hook scripts for {command_home}")
kept = list((root / "hstack-backups").glob("config.yaml.*"))
if len(kept) != 1 or kept[0].read_text(encoding="utf-8") != config:
fail(f"uninstall deleted the config backup for {command_home}")
if (root / "hstack" / "backups").exists():
fail(f"uninstall left a backup inside hstack for {command_home}")


def foreign_backup(root: Path) -> None:
root.mkdir(parents=True, exist_ok=True)
original = 'model: local\nhooks:\n post_tool_call:\n - command: "/usr/bin/log"\n'
(root / "config.yaml").write_text(original, encoding="utf-8")
apply_hooks.apply(root, ROOT, ["guard"], remove=False, dry_run=False, command_home=root.as_posix())
backups = list((root / "hstack" / "backups").glob("config.yaml.*"))
backups = list((root / "hstack-backups").glob("config.yaml.*"))
if len(backups) != 1:
fail(f"expected one backup, found {len(backups)}")
if backups[0].read_text(encoding="utf-8") != original:
fail("backup is not the original config")
apply_hooks.apply(root, ROOT, ["guard"], remove=False, dry_run=False, command_home=root.as_posix())
if len(list((root / "hstack" / "backups").glob("config.yaml.*"))) != 1:
if len(list((root / "hstack-backups").glob("config.yaml.*"))) != 1:
fail("second install added a backup")
text = (root / "config.yaml").read_text(encoding="utf-8")
if "/usr/bin/log" not in text or "# hstack:guard" not in text:
Expand All @@ -117,6 +122,11 @@ def foreign_backup(root: Path) -> None:
fail("guard uninstall left its entry")
if "/usr/bin/log" not in left:
fail("guard uninstall removed the foreign hook")
surviving = list((root / "hstack-backups").glob("config.yaml.*"))
if not any(item.read_text(encoding="utf-8") == original for item in surviving):
fail("uninstall deleted the original config backup")
if (root / "hstack" / "backups").exists():
fail("uninstall left a backup inside hstack")


def installer() -> None:
Expand Down Expand Up @@ -157,6 +167,11 @@ def installer() -> None:
state = home / "hstack" / "hooks.json"
if state.exists():
fail(f"hook state remained: {state.read_text()}")
kept = list((home / "hstack-backups").glob("config.yaml.*"))
if not kept or not any("hstack" in item.read_text(encoding="utf-8") for item in kept):
fail("uninstall deleted its config backup")
if (home / "hstack" / "backups").exists():
fail("uninstall left a backup inside the removed tree")


def behavior() -> None:
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_piped_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ install_with() {
test -f "$home/hermes/skills/hstack/hstack/references/frame.md" || fail "$label missing playbook"
test "$(count_skills "$home/hermes")" -eq 12 || fail "$label expected 12 skills"
test ! -e "$home/hermes/skills/hstack/VERSION" || fail "$label left VERSION in the skill tree"
grep -q 'version: 0.2.0' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version"
grep -q 'version: 0.2.1' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version"
diff -q "$ROOT/skills/hstack/reframe/SKILL.md" "$home/hermes/skills/hstack/reframe/SKILL.md" >/dev/null \
|| fail "$label skill bytes differ"
if [ -n "$path_value" ]; then
Expand Down
4 changes: 2 additions & 2 deletions scripts/check_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ def check_skills() -> dict[str, int]:
fail(f"{name}: description must be one sentence of at most 60 characters")
if clip_description(desc) != desc or clip_description_stable(desc) != desc:
fail(f"{name}: Hermes would clip the description")
if meta.get("version") != "0.2.0":
fail(f"{name}: version must be 0.2.0")
if meta.get("version") != "0.2.1":
fail(f"{name}: version must be 0.2.1")
if meta.get("license") != "MIT":
fail(f"{name}: license must be MIT")
if meta.get("author") != "Preetham Kyanam":
Expand Down
15 changes: 9 additions & 6 deletions site/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,22 @@ Leave the Porkbun nameservers alone. The registrar stays Porkbun. Vercel does no
- Any other path under `/install/` is a plain-text 404 (`not found`), not an HTML page.
- `curl -fsSL hstack.sh/install` follows HTTP to HTTPS and receives the script. `curl -fsSL www.hstack.sh/install` follows the 308 to the apex and receives the same script.

Do not rebuild `/dl/hstack-v0.1.0.tar.gz`. The site build copies `site/vendor/dl/hstack-v0.1.0.tar.gz` into `dist` unchanged. Those bytes are the archive already on hstack.sh. sha256 `0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e`, 19967 bytes. The checksum file is that digest, two spaces, then `hstack-v0.1.0.tar.gz`.
Do not rebuild `/dl/hstack-v0.1.0.tar.gz` or `/dl/hstack-v0.2.0.tar.gz`. The site build copies both from `site/vendor/dl/` unchanged.

`hstack-v0.1.0.tar.gz` is sha256 `0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e`, 19967 bytes. `hstack-v0.2.0.tar.gz` is the file hstack.sh served for 0.2.0, sha256 `c16dfee4cd2841939a97a5cebf816c3fc3e8d71e28f2f1874639ea539ea20810`, 30914 bytes. Each checksum line is that digest, two spaces, then the filename.

After this deploy, `/dl` is:

| Path | Bytes | Cache-Control |
|---|---|---|
| `/dl/hstack-v0.1.0.tar.gz` and `.sha256` | the vendored 0.1.0 archive, unchanged | `public, max-age=31536000, immutable` |
| `/dl/hstack-v0.2.0.tar.gz` and `.sha256` | the new archive from this tree | `public, max-age=31536000, immutable` |
| `/dl/hstack.tar.gz` and `.sha256` | the same bytes as `hstack-v0.2.0.tar.gz` | `public, max-age=300` |
| `/dl/hstack-v0.1.0.tar.gz` and `.sha256` | the vendored 0.1.0 archive | `public, max-age=31536000, immutable` |
| `/dl/hstack-v0.2.0.tar.gz` and `.sha256` | the vendored live 0.2.0 archive, not a rebuild | `public, max-age=31536000, immutable` |
| `/dl/hstack-v0.2.1.tar.gz` and `.sha256` | the vendored 0.2.1 archive | `public, max-age=31536000, immutable` |
| `/dl/hstack.tar.gz` and `.sha256` | the same bytes as `hstack-v0.2.1.tar.gz` | `public, max-age=300` |

`HSTACK_VERSION=0.1.0` requests the versioned 0.1.0 file, not the alias. The alias changes on this deploy. Its five-minute cache is the existing rule. Until this build is deployed, hstack.sh still serves 0.1.0 at `/dl/hstack.tar.gz`.
`HSTACK_VERSION=0.1.0` and `HSTACK_VERSION=0.2.0` request those versioned files, not the alias. The alias changes on this deploy. Its five-minute cache is the existing rule. Until this build is deployed, hstack.sh still serves 0.2.0 at `/dl/hstack.tar.gz`.

The 0.2.0 archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, `bin/` (the addon helpers), and `VERSION`.
The 0.2.1 archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, `bin/` (the addon helpers), and `VERSION`.

## Cloudflare Pages fallback

Expand Down
8 changes: 8 additions & 0 deletions site/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
Content-Type: text/plain; charset=utf-8
Cache-Control: public, max-age=31536000, immutable

/dl/hstack-v0.2.1.tar.gz
Content-Type: application/gzip
Cache-Control: public, max-age=31536000, immutable

/dl/hstack-v0.2.1.tar.gz.sha256
Content-Type: text/plain; charset=utf-8
Cache-Control: public, max-age=31536000, immutable

/fonts/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=31536000, immutable
11 changes: 10 additions & 1 deletion site/content/changelog.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
---
title: Changelog
description: HStack 0.2.0. Optional Hermes hooks, a consent gate for outside stacks, and the same 204-token idle index.
description: HStack 0.2.1. Frozen release archives, deterministic builds, and config backups that survive uninstall.
---

## 0.2.1

28 October 2026.

- `/dl/hstack-v0.2.0.tar.gz` is the archive that hstack.sh already served. The build copies those bytes and does not rebuild them. The latest alias is 0.2.1.
- New archives use a fixed mtime, sorted names, numeric owner 0, and `gzip -n`. Two builds of the same tree produce the same sha256.
- `--uninstall` keeps `config.yaml` backups in `$HERMES_HOME/hstack-backups/`. It no longer stores them under `$HERMES_HOME/hstack/`, which uninstall removes.
- Idle index stays 204.

## 0.2.0

28 October 2026.
Expand Down
2 changes: 1 addition & 1 deletion site/content/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` when Hermes lives s

The planning path is `hstack`, `reframe`, and `shape`: 1,052 tokens, under 2% of GStack's planning load on Hermes. The idle index is 204 tokens. All twelve `SKILL.md` bodies are 3,907. Counts use Hermes's estimator, `ceil(bytes / 4)`.

Playbooks live under `skills/hstack/hstack/references/` and load one at a time. After that playbook, the router may read `stacks.md` and ask before using gstack, pstack, tstack, or gbrain. Hermes shell hooks stay off unless the install names `--with hermes-hooks`. The [skills index](/skills) lists each skill. [Parity](/parity) has one row for every GStack, TStack, and PStack feature that was inventoried.
Playbooks live under `skills/hstack/hstack/references/` and load one at a time. After that playbook, the router may read `stacks.md` and ask before using gstack, pstack, tstack, or gbrain. Hermes shell hooks, since 0.2.0, stay off unless the install names `--with hermes-hooks`. The [skills index](/skills) lists each skill. [Parity](/parity) has one row for every GStack, TStack, and PStack feature that was inventoried.
4 changes: 2 additions & 2 deletions site/content/docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 --uninstall

## Version and host

With no version set, the installer fetches `/dl/hstack.tar.gz`, the latest alias. That alias is 0.2.0. `HSTACK_VERSION=0.1.0` still fetches `/dl/hstack-v0.1.0.tar.gz`, the previous archive, byte for byte. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place.
With no version set, the installer fetches `/dl/hstack.tar.gz`, the latest alias. That alias is 0.2.1. `HSTACK_VERSION=0.2.0` still fetches `/dl/hstack-v0.2.0.tar.gz`, and `HSTACK_VERSION=0.1.0` still fetches `/dl/hstack-v0.1.0.tar.gz`. Those versioned files stay byte for byte. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place.

```sh install.sh
curl -fsSL https://hstack.sh/install | HSTACK_VERSION=0.1.0 sh
```

The archive is one directory. Inside 0.2.0: the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers. The published checksum files are `HEX filename`, two spaces. A mismatch stops the install and does not write skills.
The archive is one directory. Inside 0.2.1: the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers. The published checksum files are `HEX filename`, two spaces. A mismatch stops the install and does not write skills.

WSL can run `install.sh`. The Windows script takes the same flags and the same archive.
2 changes: 1 addition & 1 deletion site/content/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Commands, dry-run, and the Windows `-File` form are on the [add-ons](/docs/addon

## Hooks

`--with hermes-hooks` registers shell hooks in `~/.hermes/config.yaml`. Nothing in the skills imports them, so a Hermes update that moves a Python API does not break the router. The guard blocks a short denylist of destructive terminal commands. The session hook appends a stamp when the session actually ends (`on_session_finalize`, not `on_session_end`, which fires every turn). The suggest hook can add context on the first turn only, and only an HStack playbook name. Hermes still asks before a new hook command runs, unless you pass `--accept-hooks` yourself.
Since 0.2.0, `--with hermes-hooks` registers shell hooks in `~/.hermes/config.yaml`. Nothing in the skills imports them, so a Hermes update that moves a Python API does not break the router. The guard blocks a short denylist of destructive terminal commands. The session hook appends a stamp when the session actually ends (`on_session_finalize`, not `on_session_end`, which fires every turn). The suggest hook can add context on the first turn only, and only an HStack playbook name. Hermes still asks before a new hook command runs, unless you pass `--accept-hooks` yourself.

## Outside stacks

Expand Down
Loading
Loading